From 78968202c5fbc35a1f7026b1417f58c0d00536a6 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 4 Jun 2026 09:01:50 -0400 Subject: [PATCH 1/4] feat(release): bootstrap first-time winget submissions Closes #27 --- .github/workflows/release.yml | 33 +- .github/workflows/test-actions.yml | 15 + actions/identity-check/identity.py | 16 +- actions/identity-check/test_identity.py | 19 ++ actions/winget-submit/action.yml | 59 ++++ actions/winget-submit/test_winget_submit.py | 184 ++++++++++ actions/winget-submit/winget_submit.py | 355 ++++++++++++++++++++ 7 files changed, 657 insertions(+), 24 deletions(-) create mode 100644 actions/winget-submit/action.yml create mode 100644 actions/winget-submit/test_winget_submit.py create mode 100644 actions/winget-submit/winget_submit.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bbc374..30f0c23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -349,29 +349,16 @@ jobs: WINGET_ID: ${{ fromJSON(needs.goreleaser.outputs.identity-json).packages.winget.id }} steps: - uses: actions/checkout@v4 - - name: Resolve windows asset URLs from the release - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - run: | - gh release download "$env:FINAL_TAG" --pattern "checksums.txt" --dir . - $checksums = Get-Content checksums.txt - $x64Name = ($checksums | Select-String "windows_amd64.zip").Line.Split()[-1] - $arm64Name = ($checksums | Select-String "windows_arm64.zip").Line.Split()[-1] - if (-not $x64Name) { Write-Error "no windows_amd64.zip entry in checksums.txt"; exit 1 } - if (-not $arm64Name) { Write-Error "no windows_arm64.zip entry in checksums.txt"; exit 1 } - $base = "https://github.com/$env:REPO/releases/download/$env:FINAL_TAG" - echo "X64_URL=$base/$x64Name" >> $env:GITHUB_ENV - echo "ARM64_URL=$base/$arm64Name" >> $env:GITHUB_ENV - - name: Submit to winget - shell: pwsh - env: - WINGET_TOKEN: ${{ secrets.winget-token }} - run: | - if (-not $env:WINGET_TOKEN) { Write-Error "winget-token not set"; exit 1 } - Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe - ./wingetcreate.exe update $env:WINGET_ID --version $env:VERSION --urls $env:X64_URL $env:ARM64_URL --submit --token $env:WINGET_TOKEN + - uses: open-cli-collective/.github/actions/winget-submit@v1 + with: + package-id: ${{ fromJSON(needs.goreleaser.outputs.identity-json).packages.winget.id }} + version: ${{ needs.goreleaser.outputs.version }} + final-tag: ${{ needs.goreleaser.outputs.final-tag }} + repo: ${{ github.repository }} + working-directory: ${{ inputs.working-directory }} + bootstrap: ${{ fromJSON(needs.goreleaser.outputs.identity-json).packages.winget.bootstrap }} + github-token: ${{ github.token }} + winget-token: ${{ secrets.winget-token }} # Surface ANY failure (missing token, asset resolution, wingetcreate) — a # continue-on-error job would otherwise go green with no trace. - name: Note winget failure diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index f288dd3..6ebb7b2 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -156,6 +156,21 @@ jobs: shell: bash working-directory: actions/release-preflight + # winget-submit helper logic: package existence decision, asset resolution, and + # first-submission manifest rendering. + winget-submit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install "PyYAML==6.0.2" "pytest>=8,<9" + shell: bash + - run: python -m pytest -q + shell: bash + working-directory: actions/winget-submit + # identity.py unit tests — PASS + every drift rule + missing-manifest, in tmp dirs. identity-unit: runs-on: ubuntu-latest diff --git a/actions/identity-check/identity.py b/actions/identity-check/identity.py index 9c2b23b..38cbd57 100644 --- a/actions/identity-check/identity.py +++ b/actions/identity-check/identity.py @@ -63,6 +63,7 @@ def normalize(m: dict) -> dict: """The stable shape #7/#8 consume. Defaults fill what the workflows need.""" pkgs = m.get("packages", {}) or {} hb = pkgs.get("homebrew", {}) or {} + winget = pkgs.get("winget", {}) or {} tag = m.get("tag", {}) or {} return { "binary": m["binary"], @@ -76,7 +77,7 @@ def normalize(m: dict) -> dict: "canonical_cask": hb.get("canonical_cask"), "alias_casks": hb.get("alias_casks", []) or [], }, - "winget": {"id": (pkgs.get("winget", {}) or {}).get("id")}, + "winget": {"id": winget.get("id"), "bootstrap": _winget_bootstrap(winget)}, "chocolatey": {"id": (pkgs.get("chocolatey", {}) or {}).get("id")}, "linux": {"package_name": (pkgs.get("linux", {}) or {}).get("package_name")}, "snap": {"state": (pkgs.get("snap", {}) or {}).get("state")}, @@ -85,6 +86,15 @@ def normalize(m: dict) -> dict: } +def _winget_bootstrap(winget: dict) -> bool: + if "bootstrap" not in winget: + return False + value = winget.get("bootstrap") + if not isinstance(value, bool): + raise ManifestError("packages.winget.bootstrap must be a boolean") + return value + + def _validate_keychain_probe(m: dict) -> list[str]: errors: list[str] = [] probe = m.get("keychain_probe") @@ -190,6 +200,10 @@ def validate(manifest_path: str, working_dir: str, repo_root: str = ".") -> list errors.extend(_validate_keychain_probe(m)) + winget_cfg = pkgs.get("winget", {}) or {} + if "bootstrap" in winget_cfg and not isinstance(winget_cfg.get("bootstrap"), bool): + errors.append("packages.winget.bootstrap must be a boolean") + # --- linux nfpm + homebrew cask (declared-channel; both read .goreleaser) --- # alias_casks are intentionally NOT checked here: they live only in the # manifest and are generated by the #8 alias post-step, so there is no diff --git a/actions/identity-check/test_identity.py b/actions/identity-check/test_identity.py index bbfd8c3..a2db1e8 100644 --- a/actions/identity-check/test_identity.py +++ b/actions/identity-check/test_identity.py @@ -262,10 +262,29 @@ def test_export_json_shape(tmp_path): assert norm["tag"]["prefix"] == "v" assert norm["archives"]["name_template"].startswith("slck_v") assert norm["packages"]["homebrew"]["alias_casks"] == ["slack-chat-cli"] + assert norm["packages"]["winget"] == { + "id": "OpenCLICollective.slack-chat-cli", + "bootstrap": False, + } assert norm["packages"]["linux"]["package_name"] == "slck" assert norm["version_file"] == "version.txt" +def test_export_json_winget_bootstrap_true(tmp_path): + m = copy.deepcopy(BASE_MANIFEST) + m["packages"]["winget"]["bootstrap"] = True + wd = build(tmp_path, manifest=m) + norm = identity.normalize(identity.load_manifest(manifest_path(wd))) + assert norm["packages"]["winget"]["bootstrap"] is True + + +def test_winget_bootstrap_must_be_boolean(tmp_path): + m = copy.deepcopy(BASE_MANIFEST) + m["packages"]["winget"]["bootstrap"] = "true" + wd = build(tmp_path, manifest=m) + assert any("packages.winget.bootstrap must be a boolean" in e for e in identity.validate(manifest_path(wd), wd, wd)) + + # --- monorepo: tool-local identity + packaging under tools/, but the # goreleaser config lives at the repo root and resolves via --repo-root, not # --working-dir (distribution.md §8.3). Models atlassian-cli's cfl tool. --- diff --git a/actions/winget-submit/action.yml b/actions/winget-submit/action.yml new file mode 100644 index 0000000..8c1ad75 --- /dev/null +++ b/actions/winget-submit/action.yml @@ -0,0 +1,59 @@ +name: Winget submit +description: Submit an existing winget package update or bootstrap a first-time package submission. +inputs: + package-id: + description: "Winget PackageIdentifier, for example OpenCLICollective.codereview-cli" + required: true + version: + description: "PackageVersion to publish" + required: true + final-tag: + description: "GitHub release tag containing the published assets" + required: true + repo: + description: "GitHub repository containing the release assets, owner/name" + required: true + working-directory: + description: "Tool root containing packaging/winget templates" + required: false + default: "." + bootstrap: + description: "Allow first-time package submission if the winget package does not exist" + required: false + default: "false" + github-token: + description: "GitHub token for release asset download and winget-pkgs existence lookup" + required: true + winget-token: + description: "GitHub token used by wingetcreate to submit to microsoft/winget-pkgs" + required: true +runs: + using: composite + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - shell: bash + run: pip install "PyYAML==6.0.2" + - shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + PACKAGE_ID: ${{ inputs.package-id }} + VERSION: ${{ inputs.version }} + FINAL_TAG: ${{ inputs.final-tag }} + REPO: ${{ inputs.repo }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + BOOTSTRAP: ${{ inputs.bootstrap }} + GITHUB_TOKEN: ${{ inputs.github-token }} + WINGET_TOKEN: ${{ inputs.winget-token }} + run: | + set -euo pipefail + python "$ACTION_PATH/winget_submit.py" submit \ + --package-id "$PACKAGE_ID" \ + --version "$VERSION" \ + --final-tag "$FINAL_TAG" \ + --repo "$REPO" \ + --working-dir "$WORKING_DIRECTORY" \ + --bootstrap "$BOOTSTRAP" \ + --github-token "$GITHUB_TOKEN" \ + --winget-token "$WINGET_TOKEN" diff --git a/actions/winget-submit/test_winget_submit.py b/actions/winget-submit/test_winget_submit.py new file mode 100644 index 0000000..7716888 --- /dev/null +++ b/actions/winget-submit/test_winget_submit.py @@ -0,0 +1,184 @@ +import pathlib + +import pytest +import yaml + +import winget_submit + + +def test_package_id_to_winget_path(): + assert ( + winget_submit.package_id_to_winget_path("OpenCLICollective.codereview-cli") + == "manifests/o/OpenCLICollective/codereview-cli" + ) + assert ( + winget_submit.package_id_to_winget_path("Microsoft.PowerShell.Preview") + == "manifests/m/Microsoft/PowerShell/Preview" + ) + + +def test_select_mode_prefers_update_for_existing_package(): + assert winget_submit.select_mode(package_exists=True, bootstrap=True) == winget_submit.MODE_UPDATE + + +def test_select_mode_missing_package_with_bootstrap_disabled(): + assert ( + winget_submit.select_mode(package_exists=False, bootstrap=False) + == winget_submit.MODE_MISSING_BOOTSTRAP_DISABLED + ) + + +def test_select_mode_missing_package_with_bootstrap_enabled(): + assert ( + winget_submit.select_mode(package_exists=False, bootstrap=True) + == winget_submit.MODE_FIRST_SUBMISSION + ) + + +def test_package_exists_returns_false_only_for_confirmed_404(): + def request_json(url, token): + raise winget_submit.GitHubAPIError(404, "not found") + + assert winget_submit.package_exists( + "OpenCLICollective.codereview-cli", + "token", + request_json=request_json, + ) is False + + +def test_package_exists_fails_closed_for_non_404(): + def request_json(url, token): + raise winget_submit.GitHubAPIError(500, "server error") + + with pytest.raises(winget_submit.SubmitError, match="could not verify"): + winget_submit.package_exists( + "OpenCLICollective.codereview-cli", + "token", + request_json=request_json, + ) + + +def test_resolve_windows_assets_from_checksums_and_release_assets(): + checksums = """\ +abc123 cr_v1.2.3_windows_amd64.zip +def456 cr_v1.2.3_windows_arm64.zip +""" + release_assets = { + "cr_v1.2.3_windows_amd64.zip": "https://example.test/x64.zip", + "cr_v1.2.3_windows_arm64.zip": "https://example.test/arm64.zip", + } + + assets = winget_submit.resolve_windows_assets(checksums, release_assets) + + assert assets.x64.url == "https://example.test/x64.zip" + assert assets.x64.sha256 == "abc123" + assert assets.arm64.url == "https://example.test/arm64.zip" + assert assets.arm64.sha256 == "def456" + + +def test_render_bootstrap_manifests_updates_values_without_mutating_source(tmp_path): + package_id = "OpenCLICollective.codereview-cli" + source = tmp_path / "tool" + winget_dir = source / "packaging" / "winget" + winget_dir.mkdir(parents=True) + _write_manifest( + winget_dir / f"{package_id}.yaml", + { + "PackageIdentifier": package_id, + "PackageVersion": "0.0.0", + "DefaultLocale": "en-US", + "ManifestType": "version", + "ManifestVersion": "1.10.0", + }, + ) + _write_manifest( + winget_dir / f"{package_id}.locale.en-US.yaml", + { + "PackageIdentifier": package_id, + "PackageVersion": "0.0.0", + "PackageLocale": "en-US", + "Publisher": "Open CLI Collective", + "PackageName": "Code Review CLI", + "ShortDescription": "Automated pull-request review CLI", + "ManifestType": "defaultLocale", + "ManifestVersion": "1.10.0", + }, + ) + installer_path = winget_dir / f"{package_id}.installer.yaml" + _write_manifest( + installer_path, + { + "PackageIdentifier": package_id, + "PackageVersion": "0.0.0", + "InstallerType": "zip", + "Installers": [ + { + "Architecture": "x64", + "InstallerUrl": "https://github.com/open-cli-collective/codereview-cli/releases/download/v0.0.0/cr_v0.0.0_windows_amd64.zip", + "InstallerSha256": "CHECKSUM_AMD64_PLACEHOLDER", + }, + { + "Architecture": "arm64", + "InstallerUrl": "https://github.com/open-cli-collective/codereview-cli/releases/download/v0.0.0/cr_v0.0.0_windows_arm64.zip", + "InstallerSha256": "CHECKSUM_ARM64_PLACEHOLDER", + }, + ], + "ManifestType": "installer", + "ManifestVersion": "1.10.0", + }, + ) + original_installer = installer_path.read_text() + rendered = tmp_path / "rendered" + assets = winget_submit.WindowsAssets( + x64=winget_submit.WindowsAsset( + name="cr_v1.2.3_windows_amd64.zip", + url="https://github.com/open-cli-collective/codereview-cli/releases/download/v1.2.3/cr_v1.2.3_windows_amd64.zip", + sha256="a" * 64, + ), + arm64=winget_submit.WindowsAsset( + name="cr_v1.2.3_windows_arm64.zip", + url="https://github.com/open-cli-collective/codereview-cli/releases/download/v1.2.3/cr_v1.2.3_windows_arm64.zip", + sha256="b" * 64, + ), + ) + + output_paths = winget_submit.render_bootstrap_manifests( + package_id=package_id, + version="1.2.3", + working_dir=source, + output_dir=rendered, + assets=assets, + ) + + assert installer_path.read_text() == original_installer + assert all(pathlib.Path(path).is_relative_to(rendered) for path in output_paths) + rendered_installer = yaml.safe_load((rendered / f"{package_id}.installer.yaml").read_text()) + assert rendered_installer["PackageVersion"] == "1.2.3" + installers = {item["Architecture"]: item for item in rendered_installer["Installers"]} + assert installers["x64"]["InstallerUrl"] == assets.x64.url + assert installers["x64"]["InstallerSha256"] == "a" * 64 + assert installers["arm64"]["InstallerUrl"] == assets.arm64.url + assert installers["arm64"]["InstallerSha256"] == "b" * 64 + + +def test_render_bootstrap_manifests_rejects_output_inside_source(tmp_path): + source = tmp_path / "tool" + source.mkdir() + output = source / "packaging" / "winget" / "rendered" + assets = winget_submit.WindowsAssets( + x64=winget_submit.WindowsAsset("x64.zip", "https://example.test/x64.zip", "a"), + arm64=winget_submit.WindowsAsset("arm64.zip", "https://example.test/arm64.zip", "b"), + ) + + with pytest.raises(winget_submit.SubmitError, match="outside"): + winget_submit.render_bootstrap_manifests( + package_id="OpenCLICollective.codereview-cli", + version="1.2.3", + working_dir=source, + output_dir=output, + assets=assets, + ) + + +def _write_manifest(path, data): + path.write_text(yaml.safe_dump(data, sort_keys=False)) diff --git a/actions/winget-submit/winget_submit.py b/actions/winget-submit/winget_submit.py new file mode 100644 index 0000000..0bdfbf4 --- /dev/null +++ b/actions/winget-submit/winget_submit.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""Submit Open CLI Collective winget updates, including first-time packages.""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen, urlretrieve + +import yaml + +MODE_UPDATE = "update" +MODE_FIRST_SUBMISSION = "first-submission" +MODE_MISSING_BOOTSTRAP_DISABLED = "missing-with-bootstrap-disabled" + +WINGETCREATE_URL = "https://aka.ms/wingetcreate/latest" + + +class SubmitError(Exception): + """A release-channel failure that should surface as a GitHub Actions error.""" + + +class GitHubAPIError(Exception): + def __init__(self, status: int | None, message: str): + super().__init__(message) + self.status = status + + +@dataclass(frozen=True) +class WindowsAsset: + name: str + url: str + sha256: str + + +@dataclass(frozen=True) +class WindowsAssets: + x64: WindowsAsset + arm64: WindowsAsset + + +def package_id_to_winget_path(package_id: str) -> str: + parts = [part for part in package_id.split(".") if part] + if len(parts) < 2: + raise SubmitError(f"invalid winget package id: {package_id!r}") + return "/".join(["manifests", parts[0][0].lower(), *parts]) + + +def select_mode(package_exists: bool, bootstrap: bool) -> str: + if package_exists: + return MODE_UPDATE + if bootstrap: + return MODE_FIRST_SUBMISSION + return MODE_MISSING_BOOTSTRAP_DISABLED + + +def package_exists(package_id: str, github_token: str, request_json=None) -> bool: + request_json = request_json or github_request_json + path = package_id_to_winget_path(package_id) + url = f"https://api.github.com/repos/microsoft/winget-pkgs/contents/{quote(path, safe='/')}" + try: + request_json(url, github_token) + except GitHubAPIError as exc: + if exc.status == 404: + return False + raise SubmitError( + f"could not verify whether {package_id} exists in microsoft/winget-pkgs " + f"(GitHub API status {exc.status}); refusing to bootstrap" + ) from exc + return True + + +def load_release_assets(repo: str, final_tag: str, github_token: str) -> tuple[str, dict[str, str]]: + 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='')}" + release = github_request_json(release_url, github_token) + assets = release.get("assets") or [] + by_name = {asset.get("name"): asset for asset in assets if asset.get("name")} + checksums = by_name.get("checksums.txt") + if not checksums: + raise SubmitError(f"checksums.txt asset not found on release {repo}@{final_tag}") + checksums_url = checksums.get("url") + if not checksums_url: + raise SubmitError("checksums.txt asset has no API URL") + checksums_text = github_request_text(checksums_url, github_token, accept="application/octet-stream") + download_urls = { + name: asset.get("browser_download_url") + for name, asset in by_name.items() + if asset.get("browser_download_url") + } + return checksums_text, download_urls + + +def resolve_windows_assets(checksums_text: str, release_assets: dict[str, str]) -> WindowsAssets: + found: dict[str, WindowsAsset] = {} + for raw in checksums_text.splitlines(): + parts = raw.strip().split() + if len(parts) < 2: + continue + sha256, name = parts[0], parts[-1].lstrip("*") + if "windows_amd64.zip" in name: + arch = "x64" + elif "windows_arm64.zip" in name: + arch = "arm64" + else: + continue + url = release_assets.get(name) + if not url: + raise SubmitError(f"release asset {name} listed in checksums.txt was not found") + found[arch] = WindowsAsset(name=name, url=url, sha256=sha256) + + missing = [arch for arch in ("x64", "arm64") if arch not in found] + if missing: + raise SubmitError(f"missing Windows asset checksums for: {', '.join(missing)}") + return WindowsAssets(x64=found["x64"], arm64=found["arm64"]) + + +def render_bootstrap_manifests( + *, + package_id: str, + version: str, + working_dir: str | Path, + output_dir: str | Path, + assets: WindowsAssets, +) -> list[Path]: + source_dir = Path(working_dir) / "packaging" / "winget" + output = Path(output_dir) + source_resolved = source_dir.resolve() + output_resolved = output.resolve() + if _is_relative_to(output_resolved, source_resolved): + raise SubmitError("bootstrap render output must be outside the source packaging/winget directory") + + version_manifest = source_dir / f"{package_id}.yaml" + installer_manifest = source_dir / f"{package_id}.installer.yaml" + locale_manifests = sorted(source_dir.glob(f"{package_id}.locale.*.yaml")) + missing = [str(path) for path in (version_manifest, installer_manifest) if not path.is_file()] + if not locale_manifests: + missing.append(str(source_dir / f"{package_id}.locale.*.yaml")) + if missing: + raise SubmitError(f"winget bootstrap template missing: {', '.join(missing)}") + + output.mkdir(parents=True, exist_ok=True) + rendered: list[Path] = [] + for src in [version_manifest, *locale_manifests, installer_manifest]: + data = _load_manifest(src, package_id) + data["PackageVersion"] = version + if src == installer_manifest: + _update_installer_manifest(data, assets) + dest = output / src.name + dest.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + rendered.append(dest) + return rendered + + +def build_update_command(wingetcreate: Path, package_id: str, version: str, assets: WindowsAssets, token: str) -> list[str]: + return [ + str(wingetcreate), + "update", + package_id, + "--version", + version, + "--urls", + assets.x64.url, + assets.arm64.url, + "--submit", + "--token", + token, + ] + + +def build_submit_command(wingetcreate: Path, package_id: str, version: str, rendered_dir: Path, token: str) -> list[str]: + return [ + str(wingetcreate), + "submit", + "--prtitle", + f"New package: {package_id} version {version}", + "--token", + token, + "--no-open", + str(rendered_dir), + ] + + +def github_request_json(url: str, token: str): + try: + with urlopen(_github_request(url, token, "application/vnd.github+json")) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + raise GitHubAPIError(exc.code, exc.read().decode("utf-8", "replace")) from exc + except (URLError, OSError) as exc: + raise GitHubAPIError(None, str(exc)) from exc + + +def github_request_text(url: str, token: str, accept: str = "application/vnd.github+json") -> str: + try: + with urlopen(_github_request(url, token, accept)) as response: + return response.read().decode("utf-8") + except HTTPError as exc: + raise GitHubAPIError(exc.code, exc.read().decode("utf-8", "replace")) from exc + except (URLError, OSError) as exc: + raise GitHubAPIError(None, str(exc)) from exc + + +def run_submit(args) -> int: + bootstrap = _parse_bool(args.bootstrap) + if not args.github_token: + raise SubmitError("github-token is required") + if not args.winget_token: + raise SubmitError("winget-token is required") + + checksums_text, release_assets = load_release_assets(args.repo, args.final_tag, args.github_token) + assets = resolve_windows_assets(checksums_text, release_assets) + exists = package_exists(args.package_id, args.github_token) + mode = select_mode(exists, bootstrap) + _write_summary(f"winget-submit mode: {mode} for {args.package_id} {args.version}") + + if mode == MODE_MISSING_BOOTSTRAP_DISABLED: + raise SubmitError( + f"{args.package_id} does not exist in microsoft/winget-pkgs and " + "packages.winget.bootstrap is not true" + ) + + wingetcreate = Path.cwd() / "wingetcreate.exe" + urlretrieve(WINGETCREATE_URL, wingetcreate) + + if mode == MODE_UPDATE: + subprocess.run( + build_update_command(wingetcreate, args.package_id, args.version, assets, args.winget_token), + check=True, + ) + return 0 + + with tempfile.TemporaryDirectory(prefix="winget-submit-") as tmp: + rendered_dir = Path(tmp) + render_bootstrap_manifests( + package_id=args.package_id, + version=args.version, + working_dir=args.working_dir, + output_dir=rendered_dir, + assets=assets, + ) + subprocess.run( + build_submit_command( + wingetcreate, + args.package_id, + args.version, + rendered_dir, + args.winget_token, + ), + check=True, + ) + return 0 + + +def _load_manifest(path: Path, package_id: str) -> dict: + with path.open("r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + if not isinstance(data, dict): + raise SubmitError(f"{path}: expected a YAML mapping") + if data.get("PackageIdentifier") != package_id: + raise SubmitError(f"{path}: PackageIdentifier does not match {package_id}") + if "PackageVersion" not in data: + raise SubmitError(f"{path}: PackageVersion is required for bootstrap rendering") + return data + + +def _update_installer_manifest(data: dict, assets: WindowsAssets) -> None: + installers = data.get("Installers") + if not isinstance(installers, list): + raise SubmitError("installer manifest must contain Installers list") + by_arch = {"x64": assets.x64, "arm64": assets.arm64} + seen: set[str] = set() + for installer in installers: + if not isinstance(installer, dict): + continue + arch = installer.get("Architecture") + if arch in by_arch: + asset = by_arch[arch] + installer["InstallerUrl"] = asset.url + installer["InstallerSha256"] = asset.sha256 + seen.add(arch) + missing = [arch for arch in ("x64", "arm64") if arch not in seen] + if missing: + raise SubmitError(f"installer manifest missing architectures: {', '.join(missing)}") + + +def _github_request(url: str, token: str, accept: str) -> Request: + headers = { + "Accept": accept, + "User-Agent": "open-cli-collective-winget-submit", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + return Request(url, headers=headers) + + +def _parse_bool(value: str) -> bool: + return str(value).strip().lower() in {"1", "true", "yes"} + + +def _write_summary(line: str) -> None: + print(line) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as fh: + fh.write(f"{line}\n") + + +def _is_relative_to(path: Path, other: Path) -> bool: + try: + path.relative_to(other) + return True + except ValueError: + return False + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="winget_submit.py") + sub = parser.add_subparsers(dest="cmd", required=True) + submit = sub.add_parser("submit") + submit.add_argument("--package-id", required=True) + submit.add_argument("--version", required=True) + submit.add_argument("--final-tag", required=True) + submit.add_argument("--repo", required=True) + submit.add_argument("--working-dir", default=".") + submit.add_argument("--bootstrap", default="false") + submit.add_argument("--github-token", required=True) + submit.add_argument("--winget-token", required=True) + args = parser.parse_args(argv) + try: + if args.cmd == "submit": + return run_submit(args) + except SubmitError as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + except GitHubAPIError as exc: + print(f"::error::GitHub API request failed with status {exc.status}: {exc}", file=sys.stderr) + return 1 + except subprocess.CalledProcessError as exc: + print(f"::error::wingetcreate failed with exit code {exc.returncode}", file=sys.stderr) + return exc.returncode or 1 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) From 47b27c3c0d9ce64b8455c1350bccde986fc13097 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 4 Jun 2026 09:09:38 -0400 Subject: [PATCH 2/4] test(winget): cover submit orchestration --- actions/winget-submit/test_winget_submit.py | 105 ++++++++++++++++++++ actions/winget-submit/winget_submit.py | 24 ++++- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/actions/winget-submit/test_winget_submit.py b/actions/winget-submit/test_winget_submit.py index 7716888..aea7e8a 100644 --- a/actions/winget-submit/test_winget_submit.py +++ b/actions/winget-submit/test_winget_submit.py @@ -1,4 +1,5 @@ import pathlib +from types import SimpleNamespace import pytest import yaml @@ -180,5 +181,109 @@ def test_render_bootstrap_manifests_rejects_output_inside_source(tmp_path): ) +def test_run_submit_existing_package_uses_update_command_and_token_contexts(monkeypatch): + assets = _assets() + calls = {} + + def load_release_assets(repo, final_tag, github_token): + calls["load_release_assets"] = (repo, final_tag, github_token) + return "checksums", {"asset": "url"} + + def resolve_windows_assets(checksums_text, release_assets): + calls["resolve_windows_assets"] = (checksums_text, release_assets) + return assets + + def package_exists(package_id, github_token): + calls["package_exists"] = (package_id, github_token) + return True + + def download_file(url, dest, timeout_seconds): + calls["download_file"] = (url, pathlib.Path(dest).name, timeout_seconds) + + def run(command, check): + calls["run"] = (command, check) + + monkeypatch.setattr(winget_submit, "load_release_assets", load_release_assets) + monkeypatch.setattr(winget_submit, "resolve_windows_assets", resolve_windows_assets) + monkeypatch.setattr(winget_submit, "package_exists", package_exists) + monkeypatch.setattr(winget_submit, "download_file", download_file) + monkeypatch.setattr(winget_submit.subprocess, "run", run) + + rc = winget_submit.run_submit(_args(bootstrap="true")) + + assert rc == 0 + assert calls["load_release_assets"] == ("open-cli-collective/codereview-cli", "v1.2.3", "github-token") + assert calls["package_exists"] == ("OpenCLICollective.codereview-cli", "github-token") + command, check = calls["run"] + assert check is True + assert command[1:5] == ["update", "OpenCLICollective.codereview-cli", "--version", "1.2.3"] + assert command[-2:] == ["--token", "winget-token"] + + +def test_run_submit_missing_package_with_bootstrap_submits_rendered_directory(monkeypatch): + assets = _assets() + calls = {} + + monkeypatch.setattr( + winget_submit, + "load_release_assets", + lambda repo, final_tag, github_token: ("checksums", {"asset": "url"}), + ) + monkeypatch.setattr(winget_submit, "resolve_windows_assets", lambda checksums, release_assets: assets) + monkeypatch.setattr(winget_submit, "package_exists", lambda package_id, github_token: False) + monkeypatch.setattr( + winget_submit, + "download_file", + lambda url, dest, timeout_seconds: calls.setdefault("download_file", pathlib.Path(dest).name), + ) + + def render_bootstrap_manifests(package_id, version, working_dir, output_dir, assets): + calls["render"] = (package_id, version, pathlib.Path(working_dir), pathlib.Path(output_dir).exists()) + return [pathlib.Path(output_dir) / f"{package_id}.yaml"] + + def run(command, check): + calls["run"] = (command, check, pathlib.Path(command[-1]).exists()) + + monkeypatch.setattr(winget_submit, "render_bootstrap_manifests", render_bootstrap_manifests) + monkeypatch.setattr(winget_submit.subprocess, "run", run) + + rc = winget_submit.run_submit(_args(bootstrap="true")) + + assert rc == 0 + assert calls["render"] == ( + "OpenCLICollective.codereview-cli", + "1.2.3", + pathlib.Path("."), + True, + ) + command, check, rendered_dir_exists_during_submit = calls["run"] + assert check is True + assert rendered_dir_exists_during_submit is True + assert command[1] == "submit" + assert command[2:5] == ["--prtitle", "New package: OpenCLICollective.codereview-cli version 1.2.3", "--token"] + assert command[5] == "winget-token" + assert command[6] == "--no-open" + + def _write_manifest(path, data): path.write_text(yaml.safe_dump(data, sort_keys=False)) + + +def _assets(): + return winget_submit.WindowsAssets( + x64=winget_submit.WindowsAsset("x64.zip", "https://example.test/x64.zip", "a"), + arm64=winget_submit.WindowsAsset("arm64.zip", "https://example.test/arm64.zip", "b"), + ) + + +def _args(bootstrap): + return SimpleNamespace( + package_id="OpenCLICollective.codereview-cli", + version="1.2.3", + final_tag="v1.2.3", + repo="open-cli-collective/codereview-cli", + working_dir=".", + bootstrap=bootstrap, + github_token="github-token", + winget_token="winget-token", + ) diff --git a/actions/winget-submit/winget_submit.py b/actions/winget-submit/winget_submit.py index 0bdfbf4..38ad8f3 100644 --- a/actions/winget-submit/winget_submit.py +++ b/actions/winget-submit/winget_submit.py @@ -5,6 +5,7 @@ import argparse import json import os +import shutil import subprocess import sys import tempfile @@ -12,7 +13,7 @@ from pathlib import Path from urllib.error import HTTPError, URLError from urllib.parse import quote -from urllib.request import Request, urlopen, urlretrieve +from urllib.request import Request, urlopen import yaml @@ -21,6 +22,8 @@ MODE_MISSING_BOOTSTRAP_DISABLED = "missing-with-bootstrap-disabled" WINGETCREATE_URL = "https://aka.ms/wingetcreate/latest" +HTTP_TIMEOUT_SECONDS = 30 +WINGETCREATE_TIMEOUT_SECONDS = 60 class SubmitError(Exception): @@ -191,7 +194,10 @@ def build_submit_command(wingetcreate: Path, package_id: str, version: str, rend def github_request_json(url: str, token: str): try: - with urlopen(_github_request(url, token, "application/vnd.github+json")) as response: + with urlopen( + _github_request(url, token, "application/vnd.github+json"), + timeout=HTTP_TIMEOUT_SECONDS, + ) as response: return json.loads(response.read().decode("utf-8")) except HTTPError as exc: raise GitHubAPIError(exc.code, exc.read().decode("utf-8", "replace")) from exc @@ -201,7 +207,7 @@ def github_request_json(url: str, token: str): def github_request_text(url: str, token: str, accept: str = "application/vnd.github+json") -> str: try: - with urlopen(_github_request(url, token, accept)) as response: + with urlopen(_github_request(url, token, accept), timeout=HTTP_TIMEOUT_SECONDS) as response: return response.read().decode("utf-8") except HTTPError as exc: raise GitHubAPIError(exc.code, exc.read().decode("utf-8", "replace")) from exc @@ -229,7 +235,7 @@ def run_submit(args) -> int: ) wingetcreate = Path.cwd() / "wingetcreate.exe" - urlretrieve(WINGETCREATE_URL, wingetcreate) + download_file(WINGETCREATE_URL, wingetcreate, WINGETCREATE_TIMEOUT_SECONDS) if mode == MODE_UPDATE: subprocess.run( @@ -303,6 +309,16 @@ def _github_request(url: str, token: str, accept: str) -> Request: return Request(url, headers=headers) +def download_file(url: str, dest: Path, timeout_seconds: int) -> None: + request = Request(url, headers={"User-Agent": "open-cli-collective-winget-submit"}) + try: + with urlopen(request, timeout=timeout_seconds) as response: + with dest.open("wb") as fh: + shutil.copyfileobj(response, fh) + except (HTTPError, URLError, TimeoutError, OSError) as exc: + raise SubmitError(f"failed to download {url}: {exc}") from exc + + def _parse_bool(value: str) -> bool: return str(value).strip().lower() in {"1", "true", "yes"} From 32d51e10b3eecfbd26f1aaa68502443ef3bebb75 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 4 Jun 2026 09:13:44 -0400 Subject: [PATCH 3/4] test(winget): cover fail-closed submit paths --- actions/winget-submit/test_winget_submit.py | 62 +++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/actions/winget-submit/test_winget_submit.py b/actions/winget-submit/test_winget_submit.py index aea7e8a..f34ab78 100644 --- a/actions/winget-submit/test_winget_submit.py +++ b/actions/winget-submit/test_winget_submit.py @@ -152,7 +152,16 @@ def test_render_bootstrap_manifests_updates_values_without_mutating_source(tmp_p ) assert installer_path.read_text() == original_installer + assert {path.name for path in output_paths} == { + f"{package_id}.yaml", + f"{package_id}.locale.en-US.yaml", + f"{package_id}.installer.yaml", + } assert all(pathlib.Path(path).is_relative_to(rendered) for path in output_paths) + rendered_version = yaml.safe_load((rendered / f"{package_id}.yaml").read_text()) + rendered_locale = yaml.safe_load((rendered / f"{package_id}.locale.en-US.yaml").read_text()) + assert rendered_version["PackageVersion"] == "1.2.3" + assert rendered_locale["PackageVersion"] == "1.2.3" rendered_installer = yaml.safe_load((rendered / f"{package_id}.installer.yaml").read_text()) assert rendered_installer["PackageVersion"] == "1.2.3" installers = {item["Architecture"]: item for item in rendered_installer["Installers"]} @@ -217,6 +226,7 @@ def run(command, check): command, check = calls["run"] assert check is True assert command[1:5] == ["update", "OpenCLICollective.codereview-cli", "--version", "1.2.3"] + assert command[5:8] == ["--urls", assets.x64.url, assets.arm64.url] assert command[-2:] == ["--token", "winget-token"] @@ -265,6 +275,58 @@ def run(command, check): assert command[6] == "--no-open" +def test_run_submit_missing_package_without_bootstrap_fails_before_download(monkeypatch): + calls = {} + + monkeypatch.setattr( + winget_submit, + "load_release_assets", + lambda repo, final_tag, github_token: ("checksums", {"asset": "url"}), + ) + monkeypatch.setattr(winget_submit, "resolve_windows_assets", lambda checksums, release_assets: _assets()) + monkeypatch.setattr(winget_submit, "package_exists", lambda package_id, github_token: False) + monkeypatch.setattr( + winget_submit, + "download_file", + lambda url, dest, timeout_seconds: calls.setdefault("download_file", True), + ) + monkeypatch.setattr(winget_submit.subprocess, "run", lambda command, check: calls.setdefault("run", True)) + + with pytest.raises(winget_submit.SubmitError, match="bootstrap is not true"): + winget_submit.run_submit(_args(bootstrap="false")) + + assert "download_file" not in calls + assert "run" not in calls + + +def test_run_submit_unverifiable_package_lookup_fails_closed(monkeypatch): + calls = {} + + monkeypatch.setattr( + winget_submit, + "load_release_assets", + lambda repo, final_tag, github_token: ("checksums", {"asset": "url"}), + ) + monkeypatch.setattr(winget_submit, "resolve_windows_assets", lambda checksums, release_assets: _assets()) + + def package_exists(package_id, github_token): + raise winget_submit.SubmitError("could not verify whether package exists") + + monkeypatch.setattr(winget_submit, "package_exists", package_exists) + monkeypatch.setattr( + winget_submit, + "download_file", + lambda url, dest, timeout_seconds: calls.setdefault("download_file", True), + ) + monkeypatch.setattr(winget_submit.subprocess, "run", lambda command, check: calls.setdefault("run", True)) + + with pytest.raises(winget_submit.SubmitError, match="could not verify"): + winget_submit.run_submit(_args(bootstrap="true")) + + assert "download_file" not in calls + assert "run" not in calls + + def _write_manifest(path, data): path.write_text(yaml.safe_dump(data, sort_keys=False)) From 1907036358a2f630e5aa5cfc5783e3fdaddccf50 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 4 Jun 2026 09:23:57 -0400 Subject: [PATCH 4/4] fix(winget): harden submit action runtime --- actions/identity-check/identity.py | 9 +- actions/winget-submit/action.yml | 16 ++- actions/winget-submit/test_winget_submit.py | 112 +++++++++++++++++--- actions/winget-submit/winget_submit.py | 67 ++++++++++-- 4 files changed, 171 insertions(+), 33 deletions(-) diff --git a/actions/identity-check/identity.py b/actions/identity-check/identity.py index 38cbd57..d940384 100644 --- a/actions/identity-check/identity.py +++ b/actions/identity-check/identity.py @@ -30,6 +30,7 @@ from xml.etree.ElementTree import ParseError SCHEMA = "open-cli-identity/v1" +WINGET_BOOTSTRAP_TYPE_ERROR = "packages.winget.bootstrap must be a boolean" class ManifestError(Exception): @@ -91,7 +92,7 @@ def _winget_bootstrap(winget: dict) -> bool: return False value = winget.get("bootstrap") if not isinstance(value, bool): - raise ManifestError("packages.winget.bootstrap must be a boolean") + raise ManifestError(WINGET_BOOTSTRAP_TYPE_ERROR) return value @@ -201,8 +202,10 @@ def validate(manifest_path: str, working_dir: str, repo_root: str = ".") -> list errors.extend(_validate_keychain_probe(m)) winget_cfg = pkgs.get("winget", {}) or {} - if "bootstrap" in winget_cfg and not isinstance(winget_cfg.get("bootstrap"), bool): - errors.append("packages.winget.bootstrap must be a boolean") + try: + _winget_bootstrap(winget_cfg) + except ManifestError as exc: + errors.append(str(exc)) # --- linux nfpm + homebrew cask (declared-channel; both read .goreleaser) --- # alias_casks are intentionally NOT checked here: they live only in the diff --git a/actions/winget-submit/action.yml b/actions/winget-submit/action.yml index 8c1ad75..ca4bd85 100644 --- a/actions/winget-submit/action.yml +++ b/actions/winget-submit/action.yml @@ -1,5 +1,5 @@ name: Winget submit -description: Submit an existing winget package update or bootstrap a first-time package submission. +description: Submit an existing winget package update or bootstrap a first-time package submission on a Windows runner. inputs: package-id: description: "Winget PackageIdentifier, for example OpenCLICollective.codereview-cli" @@ -30,6 +30,16 @@ inputs: runs: using: composite steps: + - name: Assert Windows runner + shell: bash + env: + RUNNER_OS_NAME: ${{ runner.os }} + run: | + set -euo pipefail + if [ "$RUNNER_OS_NAME" != "Windows" ]; then + echo "::error::winget-submit requires a Windows runner because wingetcreate.exe is Windows-only" + exit 1 + fi - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -44,7 +54,7 @@ runs: REPO: ${{ inputs.repo }} WORKING_DIRECTORY: ${{ inputs.working-directory }} BOOTSTRAP: ${{ inputs.bootstrap }} - GITHUB_TOKEN: ${{ inputs.github-token }} + WINGET_GITHUB_TOKEN: ${{ inputs.github-token }} WINGET_TOKEN: ${{ inputs.winget-token }} run: | set -euo pipefail @@ -55,5 +65,5 @@ runs: --repo "$REPO" \ --working-dir "$WORKING_DIRECTORY" \ --bootstrap "$BOOTSTRAP" \ - --github-token "$GITHUB_TOKEN" \ + --github-token "$WINGET_GITHUB_TOKEN" \ --winget-token "$WINGET_TOKEN" diff --git a/actions/winget-submit/test_winget_submit.py b/actions/winget-submit/test_winget_submit.py index f34ab78..a76ee90 100644 --- a/actions/winget-submit/test_winget_submit.py +++ b/actions/winget-submit/test_winget_submit.py @@ -1,4 +1,6 @@ import pathlib +import hashlib +import io from types import SimpleNamespace import pytest @@ -19,19 +21,19 @@ def test_package_id_to_winget_path(): def test_select_mode_prefers_update_for_existing_package(): - assert winget_submit.select_mode(package_exists=True, bootstrap=True) == winget_submit.MODE_UPDATE + assert winget_submit.select_mode(exists=True, bootstrap=True) == winget_submit.MODE_UPDATE def test_select_mode_missing_package_with_bootstrap_disabled(): assert ( - winget_submit.select_mode(package_exists=False, bootstrap=False) + winget_submit.select_mode(exists=False, bootstrap=False) == winget_submit.MODE_MISSING_BOOTSTRAP_DISABLED ) def test_select_mode_missing_package_with_bootstrap_enabled(): assert ( - winget_submit.select_mode(package_exists=False, bootstrap=True) + winget_submit.select_mode(exists=False, bootstrap=True) == winget_submit.MODE_FIRST_SUBMISSION ) @@ -77,6 +79,64 @@ def test_resolve_windows_assets_from_checksums_and_release_assets(): assert assets.arm64.sha256 == "def456" +def test_load_wingetcreate_asset_uses_github_release_digest(): + def request_json(url, token): + return { + "assets": [ + { + "name": "wingetcreate.exe", + "browser_download_url": "https://example.test/wingetcreate.exe", + "digest": "sha256:" + "a" * 64, + } + ] + } + + asset = winget_submit.load_wingetcreate_asset("github-token", request_json=request_json) + + assert asset.url == "https://example.test/wingetcreate.exe" + assert asset.sha256 == "a" * 64 + + +def test_download_file_rejects_sha256_mismatch(tmp_path, monkeypatch): + class Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(winget_submit, "urlopen", lambda request, timeout: Response(b"not the expected bytes")) + dest = tmp_path / "wingetcreate.exe" + + with pytest.raises(winget_submit.SubmitError, match="checksum"): + winget_submit.download_file("https://example.test/wingetcreate.exe", dest, 1, "a" * 64) + + assert not dest.exists() + + +def test_download_file_accepts_matching_sha256(tmp_path, monkeypatch): + payload = b"expected bytes" + + class Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(winget_submit, "urlopen", lambda request, timeout: Response(payload)) + dest = tmp_path / "wingetcreate.exe" + + winget_submit.download_file( + "https://example.test/wingetcreate.exe", + dest, + 1, + hashlib.sha256(payload).hexdigest(), + ) + + assert dest.read_bytes() == payload + + def test_render_bootstrap_manifests_updates_values_without_mutating_source(tmp_path): package_id = "OpenCLICollective.codereview-cli" source = tmp_path / "tool" @@ -206,25 +266,37 @@ def package_exists(package_id, github_token): calls["package_exists"] = (package_id, github_token) return True - def download_file(url, dest, timeout_seconds): - calls["download_file"] = (url, pathlib.Path(dest).name, timeout_seconds) + def download_file(url, dest, timeout_seconds, expected_sha256): + calls["download_file"] = (url, pathlib.Path(dest).name, timeout_seconds, expected_sha256) - def run(command, check): - calls["run"] = (command, check) + def run(command, check, timeout): + calls["run"] = (command, check, timeout) monkeypatch.setattr(winget_submit, "load_release_assets", load_release_assets) monkeypatch.setattr(winget_submit, "resolve_windows_assets", resolve_windows_assets) monkeypatch.setattr(winget_submit, "package_exists", package_exists) + monkeypatch.setattr( + winget_submit, + "load_wingetcreate_asset", + lambda github_token: winget_submit.DownloadAsset("https://example.test/wingetcreate.exe", "a" * 64), + ) monkeypatch.setattr(winget_submit, "download_file", download_file) monkeypatch.setattr(winget_submit.subprocess, "run", run) rc = winget_submit.run_submit(_args(bootstrap="true")) assert rc == 0 - assert calls["load_release_assets"] == ("open-cli-collective/codereview-cli", "v1.2.3", "github-token") assert calls["package_exists"] == ("OpenCLICollective.codereview-cli", "github-token") - command, check = calls["run"] + assert calls["load_release_assets"] == ("open-cli-collective/codereview-cli", "v1.2.3", "github-token") + assert calls["download_file"] == ( + "https://example.test/wingetcreate.exe", + "wingetcreate.exe", + winget_submit.WINGETCREATE_DOWNLOAD_TIMEOUT_SECONDS, + "a" * 64, + ) + command, check, timeout = calls["run"] assert check is True + assert timeout == winget_submit.WINGETCREATE_COMMAND_TIMEOUT_SECONDS assert command[1:5] == ["update", "OpenCLICollective.codereview-cli", "--version", "1.2.3"] assert command[5:8] == ["--urls", assets.x64.url, assets.arm64.url] assert command[-2:] == ["--token", "winget-token"] @@ -244,15 +316,20 @@ def test_run_submit_missing_package_with_bootstrap_submits_rendered_directory(mo monkeypatch.setattr( winget_submit, "download_file", - lambda url, dest, timeout_seconds: calls.setdefault("download_file", pathlib.Path(dest).name), + lambda url, dest, timeout_seconds, expected_sha256: calls.setdefault("download_file", pathlib.Path(dest).name), + ) + monkeypatch.setattr( + winget_submit, + "load_wingetcreate_asset", + lambda github_token: winget_submit.DownloadAsset("https://example.test/wingetcreate.exe", "a" * 64), ) def render_bootstrap_manifests(package_id, version, working_dir, output_dir, assets): calls["render"] = (package_id, version, pathlib.Path(working_dir), pathlib.Path(output_dir).exists()) return [pathlib.Path(output_dir) / f"{package_id}.yaml"] - def run(command, check): - calls["run"] = (command, check, pathlib.Path(command[-1]).exists()) + def run(command, check, timeout): + calls["run"] = (command, check, timeout, pathlib.Path(command[-1]).exists()) monkeypatch.setattr(winget_submit, "render_bootstrap_manifests", render_bootstrap_manifests) monkeypatch.setattr(winget_submit.subprocess, "run", run) @@ -266,8 +343,9 @@ def run(command, check): pathlib.Path("."), True, ) - command, check, rendered_dir_exists_during_submit = calls["run"] + command, check, timeout, rendered_dir_exists_during_submit = calls["run"] assert check is True + assert timeout == winget_submit.WINGETCREATE_COMMAND_TIMEOUT_SECONDS assert rendered_dir_exists_during_submit is True assert command[1] == "submit" assert command[2:5] == ["--prtitle", "New package: OpenCLICollective.codereview-cli version 1.2.3", "--token"] @@ -288,9 +366,9 @@ def test_run_submit_missing_package_without_bootstrap_fails_before_download(monk monkeypatch.setattr( winget_submit, "download_file", - lambda url, dest, timeout_seconds: calls.setdefault("download_file", True), + lambda url, dest, timeout_seconds, expected_sha256: calls.setdefault("download_file", True), ) - monkeypatch.setattr(winget_submit.subprocess, "run", lambda command, check: calls.setdefault("run", True)) + monkeypatch.setattr(winget_submit.subprocess, "run", lambda command, check, timeout: calls.setdefault("run", True)) with pytest.raises(winget_submit.SubmitError, match="bootstrap is not true"): winget_submit.run_submit(_args(bootstrap="false")) @@ -316,9 +394,9 @@ def package_exists(package_id, github_token): monkeypatch.setattr( winget_submit, "download_file", - lambda url, dest, timeout_seconds: calls.setdefault("download_file", True), + lambda url, dest, timeout_seconds, expected_sha256: calls.setdefault("download_file", True), ) - monkeypatch.setattr(winget_submit.subprocess, "run", lambda command, check: calls.setdefault("run", True)) + monkeypatch.setattr(winget_submit.subprocess, "run", lambda command, check, timeout: calls.setdefault("run", True)) with pytest.raises(winget_submit.SubmitError, match="could not verify"): winget_submit.run_submit(_args(bootstrap="true")) diff --git a/actions/winget-submit/winget_submit.py b/actions/winget-submit/winget_submit.py index 38ad8f3..ee145ba 100644 --- a/actions/winget-submit/winget_submit.py +++ b/actions/winget-submit/winget_submit.py @@ -3,9 +3,9 @@ from __future__ import annotations import argparse +import hashlib import json import os -import shutil import subprocess import sys import tempfile @@ -21,9 +21,10 @@ MODE_FIRST_SUBMISSION = "first-submission" MODE_MISSING_BOOTSTRAP_DISABLED = "missing-with-bootstrap-disabled" -WINGETCREATE_URL = "https://aka.ms/wingetcreate/latest" +WINGETCREATE_RELEASE_URL = "https://api.github.com/repos/microsoft/winget-create/releases/latest" HTTP_TIMEOUT_SECONDS = 30 -WINGETCREATE_TIMEOUT_SECONDS = 60 +WINGETCREATE_DOWNLOAD_TIMEOUT_SECONDS = 60 +WINGETCREATE_COMMAND_TIMEOUT_SECONDS = 300 class SubmitError(Exception): @@ -49,6 +50,12 @@ class WindowsAssets: arm64: WindowsAsset +@dataclass(frozen=True) +class DownloadAsset: + url: str + sha256: str + + def package_id_to_winget_path(package_id: str) -> str: parts = [part for part in package_id.split(".") if part] if len(parts) < 2: @@ -56,8 +63,8 @@ def package_id_to_winget_path(package_id: str) -> str: return "/".join(["manifests", parts[0][0].lower(), *parts]) -def select_mode(package_exists: bool, bootstrap: bool) -> str: - if package_exists: +def select_mode(exists: bool, bootstrap: bool) -> str: + if exists: return MODE_UPDATE if bootstrap: return MODE_FIRST_SUBMISSION @@ -102,6 +109,23 @@ def load_release_assets(repo: str, final_tag: str, github_token: str) -> tuple[s return checksums_text, download_urls +def load_wingetcreate_asset(github_token: str, request_json=None) -> DownloadAsset: + request_json = request_json or github_request_json + release = request_json(WINGETCREATE_RELEASE_URL, github_token) + for asset in release.get("assets") or []: + if asset.get("name") != "wingetcreate.exe": + continue + url = asset.get("browser_download_url") + digest = asset.get("digest") or "" + prefix = "sha256:" + if not url: + raise SubmitError("wingetcreate.exe release asset has no download URL") + if not digest.startswith(prefix): + raise SubmitError("wingetcreate.exe release asset has no SHA-256 digest") + return DownloadAsset(url=url, sha256=digest[len(prefix):]) + raise SubmitError("wingetcreate.exe asset not found in microsoft/winget-create latest release") + + def resolve_windows_assets(checksums_text: str, release_assets: dict[str, str]) -> WindowsAssets: found: dict[str, WindowsAsset] = {} for raw in checksums_text.splitlines(): @@ -222,8 +246,6 @@ def run_submit(args) -> int: if not args.winget_token: raise SubmitError("winget-token is required") - checksums_text, release_assets = load_release_assets(args.repo, args.final_tag, args.github_token) - assets = resolve_windows_assets(checksums_text, release_assets) exists = package_exists(args.package_id, args.github_token) mode = select_mode(exists, bootstrap) _write_summary(f"winget-submit mode: {mode} for {args.package_id} {args.version}") @@ -234,13 +256,22 @@ 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) + assets = resolve_windows_assets(checksums_text, release_assets) + wingetcreate_asset = load_wingetcreate_asset(args.github_token) wingetcreate = Path.cwd() / "wingetcreate.exe" - download_file(WINGETCREATE_URL, wingetcreate, WINGETCREATE_TIMEOUT_SECONDS) + download_file( + wingetcreate_asset.url, + wingetcreate, + WINGETCREATE_DOWNLOAD_TIMEOUT_SECONDS, + wingetcreate_asset.sha256, + ) if mode == MODE_UPDATE: subprocess.run( build_update_command(wingetcreate, args.package_id, args.version, assets, args.winget_token), check=True, + timeout=WINGETCREATE_COMMAND_TIMEOUT_SECONDS, ) return 0 @@ -262,6 +293,7 @@ def run_submit(args) -> int: args.winget_token, ), check=True, + timeout=WINGETCREATE_COMMAND_TIMEOUT_SECONDS, ) return 0 @@ -309,14 +341,26 @@ def _github_request(url: str, token: str, accept: str) -> Request: return Request(url, headers=headers) -def download_file(url: str, dest: Path, timeout_seconds: int) -> None: +def download_file(url: str, dest: Path, timeout_seconds: int, expected_sha256: str) -> None: request = Request(url, headers={"User-Agent": "open-cli-collective-winget-submit"}) try: + hasher = hashlib.sha256() with urlopen(request, timeout=timeout_seconds) as response: with dest.open("wb") as fh: - shutil.copyfileobj(response, fh) + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + hasher.update(chunk) + fh.write(chunk) except (HTTPError, URLError, TimeoutError, OSError) as exc: raise SubmitError(f"failed to download {url}: {exc}") from exc + actual = hasher.hexdigest() + if actual.lower() != expected_sha256.lower(): + dest.unlink(missing_ok=True) + raise SubmitError( + f"downloaded {url} checksum {actual} did not match expected {expected_sha256}" + ) def _parse_bool(value: str) -> bool: @@ -361,6 +405,9 @@ def main(argv: list[str] | None = None) -> int: except GitHubAPIError as exc: print(f"::error::GitHub API request failed with status {exc.status}: {exc}", file=sys.stderr) return 1 + except subprocess.TimeoutExpired as exc: + print(f"::error::wingetcreate timed out after {exc.timeout} seconds", file=sys.stderr) + return 1 except subprocess.CalledProcessError as exc: print(f"::error::wingetcreate failed with exit code {exc.returncode}", file=sys.stderr) return exc.returncode or 1