Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions actions/winget-submit/test_winget_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,18 +383,18 @@ def test_render_bootstrap_manifests_rejects_output_inside_source(tmp_path):
def test_run_submit_existing_package_copies_upstream_manifests(monkeypatch):
assets = _assets()
calls = {}
monkeypatch.setattr(winget_submit, "load_release_assets", lambda *a: ("checksums", {}))
monkeypatch.setattr(winget_submit, "load_release_assets", lambda *a: ("checksums", {}, {}))
monkeypatch.setattr(winget_submit, "resolve_windows_assets", lambda *a, **kw: assets)
monkeypatch.setattr(winget_submit, "package_exists", lambda *a: True)
monkeypatch.setattr(winget_submit, "render_update_manifests", lambda directory, package_id, version, assets, token: [directory / "a.yaml"])
monkeypatch.setattr(winget_submit, "render_update_manifests", lambda directory, package_id, version, assets, token, release: [directory / "a.yaml"])
monkeypatch.setattr(winget_submit, "validate_manifests", lambda *a: None)
monkeypatch.setattr(winget_submit, "submit_manifest_pr", lambda package_id, version, files, token, **kw: calls.setdefault("submit", (package_id, version, files, token, kw)))
assert winget_submit.run_submit(_args(bootstrap="true")) == 0
assert calls["submit"][3:] == ("winget-token", {"new_package": False})


def test_run_submit_bootstrap_uses_new_github_path(monkeypatch):
monkeypatch.setattr(winget_submit, "load_release_assets", lambda *a: ("checksums", {}))
monkeypatch.setattr(winget_submit, "load_release_assets", lambda *a: ("checksums", {}, {}))
monkeypatch.setattr(winget_submit, "resolve_windows_assets", lambda *a, **kw: _assets())
monkeypatch.setattr(winget_submit, "package_exists", lambda *a: False)
monkeypatch.setattr(winget_submit, "render_bootstrap_manifests", lambda **kw: [])
Expand Down Expand Up @@ -554,7 +554,7 @@ def fetch(url, token):
return {"content": base64.b64encode(json.dumps(records[name]).encode()).decode()}
return [{"name": "1.0.0", "type": "dir"}]
monkeypatch.setattr(winget_submit, "github_request_json", fetch)
output = winget_submit.render_update_manifests(tmp_path, package, "1.2.3", _assets(), "token")
output = winget_submit.render_update_manifests(tmp_path, package, "1.2.3", _assets(), "token", {})
assert len(output) == 3
version = yaml.safe_load((tmp_path / f"{package}.yaml").read_text())
assert version["UnknownNewField"] == {"keep": "me"}
Expand All @@ -563,6 +563,8 @@ def fetch(url, token):
assert installer["Installers"][0]["UnknownField"] == "keep"
assert installer["Installers"][0]["InstallerUrl"] == _assets().x64.url
assert installer["Installers"][1]["InstallerSha256"] == _assets().arm64.sha256
locale = yaml.safe_load((tmp_path / f"{package}.locale.en-US.yaml").read_text())
assert "ReleaseNotesUrl" not in locale and "ReleaseDate" not in installer


def test_submit_manifest_pr_refuses_non_fork_before_writing(monkeypatch):
Expand Down Expand Up @@ -601,7 +603,7 @@ def fetch(url, token):
return [{"name": "1.0.0", "type": "dir"}]
monkeypatch.setattr(winget_submit, "github_request_json", fetch)
with pytest.raises(winget_submit.SubmitError, match="exactly one x64 and one arm64"):
winget_submit.render_update_manifests(tmp_path, package, "1.2.3", _assets(), "token")
winget_submit.render_update_manifests(tmp_path, package, "1.2.3", _assets(), "token", {})


def test_submit_manifest_pr_uses_explicit_upstream_only_for_test(monkeypatch, tmp_path):
Expand All @@ -625,3 +627,38 @@ def api(url, token, method="GET", payload=None):
monkeypatch.setattr(winget_submit, "github_api", api)
winget_submit.submit_manifest_pr("OpenCLICollective.example", "1.2.3", [path], "token", new_package=True, upstream_repo="alice/winget-pkgs")
assert any(url == "https://api.github.com/repos/alice/winget-pkgs/pulls" and method == "POST" for url, method, payload in calls)


@pytest.mark.parametrize("release", [{}, {"html_url": "http://example.test", "published_at": "bad"}])
def test_missing_release_metadata_fails_closed(release):
with pytest.raises(winget_submit.SubmitError):
winget_submit._release_notes_url(release)
with pytest.raises(winget_submit.SubmitError):
winget_submit._release_date(release)


def test_render_update_uses_actual_release_metadata(monkeypatch, tmp_path):
import base64, json
package = "OpenCLICollective.newrelic-cli"
records = {
f"{package}.yaml": {"PackageIdentifier": package, "PackageVersion": "1.0.0", "ManifestType": "version", "ManifestVersion": "1.28.0"},
f"{package}.locale.en-US.yaml": {"PackageIdentifier": package, "PackageVersion": "1.0.0", "ManifestType": "defaultLocale", "ManifestVersion": "1.28.0", "ReleaseNotes": "stale notes", "ReleaseNotesUrl": "https://github.com/old/release"},
f"{package}.installer.yaml": {"PackageIdentifier": package, "PackageVersion": "1.0.0", "ManifestType": "installer", "ManifestVersion": "1.28.0", "ReleaseDate": "2020-01-01", "Installers": [{"Architecture": "x64", "ReleaseDate": "2020-01-01"}, {"Architecture": "arm64"}]},
}
def fetch(url, token):
if url.endswith("/1.0.0"):
return [{"name": name, "url": f"https://test/{name}"} for name in records]
if url.startswith("https://test/"):
return {"content": base64.b64encode(json.dumps(records[url.rsplit("/", 1)[-1]]).encode()).decode()}
return [{"name": "1.0.0", "type": "dir"}]
monkeypatch.setattr(winget_submit, "github_request_json", fetch)
release = {"html_url": "https://github.com/open-cli-collective/newrelic-cli/releases/tag/v1.2.3", "published_at": "2026-10-28T14:18:11Z"}
winget_submit.render_update_manifests(tmp_path, package, "1.2.3", _assets(), "token", release)
locale = yaml.safe_load((tmp_path / f"{package}.locale.en-US.yaml").read_text())
installer = yaml.safe_load((tmp_path / f"{package}.installer.yaml").read_text())
assert locale["ReleaseNotesUrl"] == release["html_url"]
assert "ReleaseNotes" not in locale
assert installer["ReleaseDate"] == "2026-10-28"
assert installer["Installers"][0]["ReleaseDate"] == "2026-10-28"
assert "ReleaseDate" not in installer["Installers"][1]
assert winget_submit._release_date({"published_at": "2026-10-28T23:30:00-05:00"}) == "2026-10-29"
39 changes: 31 additions & 8 deletions actions/winget-submit/winget_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def package_exists(package_id: str, github_token: str, request_json=None) -> boo
return True


def load_release_assets(repo: str, final_tag: str, github_token: str) -> tuple[str, dict[str, str]]:
def load_release_assets(repo: str, final_tag: str, github_token: str) -> tuple[str, dict[str, str], dict]:
if repo.count("/") != 1:
raise SubmitError(f"repo must be owner/name, got {repo!r}")
release_url = f"https://api.github.com/repos/{repo}/releases/tags/{quote(final_tag, safe='')}"
Expand All @@ -106,7 +106,7 @@ def load_release_assets(repo: str, final_tag: str, github_token: str) -> tuple[s
for name, asset in by_name.items()
if asset.get("browser_download_url")
}
return checksums_text, download_urls
return checksums_text, download_urls, release


def resolve_windows_assets(
Expand Down Expand Up @@ -214,7 +214,7 @@ def upgrade_generated_manifests(directory: Path, package_id: str, version: str)
return paths


def render_update_manifests(directory: Path, package_id: str, version: str, assets: WindowsAssets, token: str) -> list[Path]:
def render_update_manifests(directory: Path, package_id: str, version: str, assets: WindowsAssets, token: str, release: dict) -> list[Path]:
"""Copy latest published manifests and update only the release-specific fields."""
package_path = package_id_to_winget_path(package_id)
base_url = f"https://api.github.com/repos/microsoft/winget-pkgs/contents/{quote(package_path, safe='/')}"
Expand Down Expand Up @@ -261,16 +261,19 @@ def render_update_manifests(directory: Path, package_id: str, version: str, asse
data["PackageVersion"] = version
if kind in {"defaultLocale", "locale"}:
data.pop("ReleaseNotes", None)
data.pop("ReleaseNotesUrl", None)
if "ReleaseNotesUrl" in data:
data["ReleaseNotesUrl"] = _release_notes_url(release)
if kind == "installer":
data.pop("ReleaseDate", None)
if "ReleaseDate" in data:
data["ReleaseDate"] = _release_date(release)
if "InstallerUrl" in data or "InstallerSha256" in data:
raise SubmitError("root-level installer URL or hash requires manual update")
installers = data.get("Installers")
if not isinstance(installers, list) or len(installers) != 2 or sorted(i.get("Architecture", "") for i in installers if isinstance(i, dict)) != ["arm64", "x64"]:
raise SubmitError("expected exactly one x64 and one arm64 installer; refusing automatic update")
for installer in installers:
installer.pop("ReleaseDate", None)
if "ReleaseDate" in installer:
installer["ReleaseDate"] = _release_date(release)
_update_installer_manifest(data, assets)
data["ManifestVersion"] = TARGET_MANIFEST_VERSION
dest = directory / name
Expand All @@ -281,6 +284,26 @@ def render_update_manifests(directory: Path, package_id: str, version: str, asse
return output


def _release_notes_url(release: dict) -> str:
url = release.get("html_url")
if not isinstance(url, str) or not url.startswith("https://github.com/"):
raise SubmitError("GitHub release has no valid public release URL")
return url


def _release_date(release: dict) -> str:
published_at = release.get("published_at")
try:
if not isinstance(published_at, str):
raise ValueError("missing timestamp")
published = datetime.datetime.fromisoformat(published_at.replace("Z", "+00:00"))
if published.tzinfo is None:
raise ValueError("timestamp has no timezone")
return published.astimezone(datetime.timezone.utc).date().isoformat()
except ValueError as exc:
raise SubmitError("GitHub release has no valid publication timestamp") from exc


def validate_manifests(files: list[Path]) -> None:
schemas = {}
for path in files:
Expand Down Expand Up @@ -396,7 +419,7 @@ def run_submit(args) -> int:
"packages.winget.bootstrap is not true"
)

checksums_text, release_assets = load_release_assets(args.repo, args.final_tag, args.github_token)
checksums_text, release_assets, release = load_release_assets(args.repo, args.final_tag, args.github_token)
assets = resolve_windows_assets(
checksums_text,
release_assets,
Expand All @@ -407,7 +430,7 @@ def run_submit(args) -> int:
root = Path(tmp)
rendered_dir = root / "rendered"
if mode == MODE_UPDATE:
files = render_update_manifests(rendered_dir, args.package_id, args.version, assets, args.github_token)
files = render_update_manifests(rendered_dir, args.package_id, args.version, assets, args.github_token, release)
else:
files = render_bootstrap_manifests(package_id=args.package_id, version=args.version, working_dir=args.working_dir, output_dir=rendered_dir, assets=assets)
files = upgrade_generated_manifests(rendered_dir, args.package_id, args.version)
Expand Down
Loading