diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30f0c23..04179fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -324,17 +324,11 @@ jobs: $content = $content -replace 'CHECKSUM_ARM64_PLACEHOLDER', $env:ARM64_HASH Set-Content $script $content - name: Pack and push - shell: pwsh - working-directory: ${{ inputs.working-directory }} - env: - CHOCO_API_KEY: ${{ secrets.chocolatey-api-key }} - run: | - if (-not $env:CHOCO_API_KEY) { Write-Error "chocolatey-api-key secret is required"; exit 1 } - cd packaging/chocolatey - choco pack - $pkgs = @(Get-ChildItem *.nupkg) - if ($pkgs.Count -ne 1) { Write-Error "expected exactly one .nupkg, found $($pkgs.Count)"; exit 1 } - choco push $pkgs[0].Name --source https://push.chocolatey.org/ --key $env:CHOCO_API_KEY + uses: open-cli-collective/.github/actions/chocolatey-push@v1 + with: + package-id: ${{ fromJSON(needs.goreleaser.outputs.identity-json).packages.chocolatey.id }} + working-directory: ${{ inputs.working-directory }} + api-key: ${{ secrets.chocolatey-api-key }} # winget/linux are best-effort: Microsoft-hosted flakiness must not gate the # binary/Homebrew artifacts. Failure surfaces in the step summary. diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 6ebb7b2..c468cc4 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -171,6 +171,21 @@ jobs: shell: bash working-directory: actions/winget-submit + # chocolatey-push helper logic: package push and first-submission moderation + # detection. + chocolatey-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install "pytest>=8,<9" "defusedxml==0.7.1" + shell: bash + - run: python -m pytest -q + shell: bash + working-directory: actions/chocolatey-push + # identity.py unit tests — PASS + every drift rule + missing-manifest, in tmp dirs. identity-unit: runs-on: ubuntu-latest diff --git a/actions/chocolatey-push/action.yml b/actions/chocolatey-push/action.yml new file mode 100644 index 0000000..0f4778f --- /dev/null +++ b/actions/chocolatey-push/action.yml @@ -0,0 +1,30 @@ +name: Chocolatey Pack and Push +description: Pack and push Chocolatey packages, including first-submission moderation handling. +inputs: + package-id: + description: Chocolatey package id. + required: true + working-directory: + description: Repository-relative tool working directory. + required: false + default: "." + api-key: + description: Chocolatey API key. + required: true +runs: + using: composite + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - shell: bash + env: + CHOCO_API_KEY: ${{ inputs.api-key }} + PACKAGE_ID: ${{ inputs.package-id }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + python -m pip install "defusedxml==0.7.1" + python "$GITHUB_ACTION_PATH/chocolatey_push.py" push \ + --package-id "$PACKAGE_ID" \ + --working-directory "$WORKING_DIRECTORY" \ + --api-key-env CHOCO_API_KEY diff --git a/actions/chocolatey-push/chocolatey_push.py b/actions/chocolatey-push/chocolatey_push.py new file mode 100644 index 0000000..7864e95 --- /dev/null +++ b/actions/chocolatey-push/chocolatey_push.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Pack and push Chocolatey packages with first-submission moderation handling.""" +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + +from defusedxml import ElementTree as ET + +HTTP_TIMEOUT_SECONDS = 30 +CHOCO_SOURCE = "https://push.chocolatey.org/" +COMMUNITY_API = "https://community.chocolatey.org/api/v2" +ATOM_NS = "{http://www.w3.org/2005/Atom}" + + +class PushError(Exception): + """A Chocolatey publishing failure that should fail the release.""" + + +class ProbeError(Exception): + """A Chocolatey package-state probe was unavailable or ambiguous.""" + + +@dataclass(frozen=True) +class CommandResult: + returncode: int + stdout: str + stderr: str + + @property + def output(self) -> str: + return "\n".join(part for part in (self.stdout, self.stderr) if part) + + +@dataclass(frozen=True) +class HttpResponse: + status: int + body: str + + +@dataclass(frozen=True) +class PackageState: + direct_package_exists: bool + approved_entries: int + + @property + def pending_first_submission(self) -> bool: + return self.direct_package_exists and self.approved_entries == 0 + + +def pack_and_push( + *, + package_id: str, + working_dir: str | Path, + api_key: str, + command_runner=None, + http_get=None, + summary_path: str | None = None, +) -> int: + if not api_key: + raise PushError("chocolatey-api-key secret is required") + + choco_dir = Path(working_dir) / "packaging" / "chocolatey" + pack = run_command(["choco", "pack"], choco_dir, command_runner) + _print_command_output(pack) + if pack.returncode != 0: + raise PushError(f"choco pack failed with exit code {pack.returncode}") + + packages = sorted(choco_dir.glob("*.nupkg")) + if len(packages) != 1: + raise PushError(f"expected exactly one .nupkg, found {len(packages)}") + + push = run_command( + [ + "choco", + "push", + packages[0].name, + "--source", + CHOCO_SOURCE, + "--key", + api_key, + ], + choco_dir, + command_runner, + ) + _print_command_output(push) + if push.returncode == 0: + return 0 + if not _looks_like_forbidden(push.output): + raise PushError(f"choco push failed with exit code {push.returncode}") + if _looks_like_credential_or_owner_failure(push.output): + raise PushError("choco push returned 403 with a credential or ownership failure") + + state = probe_package_state(package_id, http_get=http_get) + if not state.pending_first_submission: + raise PushError( + "choco push returned 403, but Chocolatey did not report a pending first-submission state" + ) + + message = ( + f"Chocolatey package {package_id} has a submitted version in moderation and no " + "approved/listed versions yet. The current .nupkg was not accepted for this " + "release; retry after Chocolatey approves the first submitted version." + ) + print(f"::warning::{message}") + _write_summary(summary_path, f"WARNING: {message}") + return 0 + + +def run_command(command: list[str], cwd: Path, command_runner=None) -> CommandResult: + command_runner = command_runner or subprocess.run + result = command_runner(command, cwd=cwd, text=True, capture_output=True) + return CommandResult( + returncode=result.returncode, + stdout=result.stdout or "", + stderr=result.stderr or "", + ) + + +def probe_package_state(package_id: str, http_get=None) -> PackageState: + http_get = http_get or request_text + package_url = f"{COMMUNITY_API}/package/{quote(package_id, safe='')}" + package_response = http_get(package_url) + if package_response.status == 404: + return PackageState(direct_package_exists=False, approved_entries=0) + if package_response.status != 200: + raise ProbeError(f"Chocolatey package endpoint returned HTTP {package_response.status}") + + query = urlencode({"$filter": f"Id eq '{_odata_string(package_id)}'", "$orderby": "Version desc"}) + feed_response = http_get(f"{COMMUNITY_API}/Packages()?{query}") + if feed_response.status != 200: + raise ProbeError(f"Chocolatey package listing returned HTTP {feed_response.status}") + entries = _parse_atom_entries(feed_response.body) + return PackageState(direct_package_exists=True, approved_entries=entries) + + +def request_text(url: str) -> HttpResponse: + request = Request(url, headers={"User-Agent": "open-cli-collective-chocolatey-push"}) + try: + with urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response: + return HttpResponse(response.status, response.read().decode("utf-8", "replace")) + except HTTPError as exc: + return HttpResponse(exc.code, exc.read().decode("utf-8", "replace")) + except (URLError, OSError, TimeoutError) as exc: + raise ProbeError(f"Chocolatey request failed for {url}: {exc}") from exc + + +def _parse_atom_entries(body: str) -> int: + try: + root = ET.fromstring(body) + except ET.ParseError as exc: + raise ProbeError("Chocolatey package listing returned malformed XML") from exc + if root.tag != f"{ATOM_NS}feed": + raise ProbeError("Chocolatey package listing did not return an Atom feed") + return len(root.findall(f"{ATOM_NS}entry")) + + +def _looks_like_forbidden(output: str) -> bool: + return "403" in output and "Forbidden" in output + + +def _odata_string(value: str) -> str: + return value.replace("'", "''") + + +def _looks_like_credential_or_owner_failure(output: str) -> bool: + lower = output.lower() + patterns = ( + "invalid api key", + "invalid apikey", + "api key is invalid", + "unauthorized", + "not authorized", + "not owned", + "not the owner", + "package owner", + ) + return any(pattern in lower for pattern in patterns) + + +def _print_command_output(result: CommandResult) -> None: + if result.stdout: + print(result.stdout, end="" if result.stdout.endswith("\n") else "\n") + if result.stderr: + print(result.stderr, end="" if result.stderr.endswith("\n") else "\n", file=sys.stderr) + + +def _write_summary(summary_path: str | None, line: str) -> None: + summary = summary_path or os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as fh: + fh.write(f"{line}\n") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="chocolatey_push.py") + sub = parser.add_subparsers(dest="cmd", required=True) + push = sub.add_parser("push") + push.add_argument("--package-id", required=True) + push.add_argument("--working-directory", default=".") + push.add_argument("--api-key-env", default="CHOCO_API_KEY") + args = parser.parse_args(argv) + + try: + if args.cmd == "push": + return pack_and_push( + package_id=args.package_id, + working_dir=args.working_directory, + api_key=os.environ.get(args.api_key_env, ""), + ) + except (PushError, ProbeError) as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/actions/chocolatey-push/test_chocolatey_push.py b/actions/chocolatey-push/test_chocolatey_push.py new file mode 100644 index 0000000..b5b766f --- /dev/null +++ b/actions/chocolatey-push/test_chocolatey_push.py @@ -0,0 +1,290 @@ +from types import SimpleNamespace +from urllib.error import URLError + +import pytest + +import chocolatey_push + + +EMPTY_FEED = """\ + + + Packages + +""" + +ENTRY_FEED = """\ + + + codereview-cli + +""" + + +def test_pack_and_push_success(tmp_path): + calls = [] + work = _working_dir(tmp_path) + choco_dir = work / "packaging" / "chocolatey" + + def runner(command, cwd, text, capture_output): + calls.append((command, cwd)) + if command == ["choco", "pack"]: + (cwd / "codereview-cli.1.0.0.nupkg").write_text("pkg") + return _result(0, stdout="packed\n") + return _result(0, stdout="pushed\n") + + rc = chocolatey_push.pack_and_push( + package_id="codereview-cli", + working_dir=work, + api_key="key", + command_runner=runner, + ) + + assert rc == 0 + assert calls == [ + (["choco", "pack"], choco_dir), + ( + [ + "choco", + "push", + "codereview-cli.1.0.0.nupkg", + "--source", + "https://push.chocolatey.org/", + "--key", + "key", + ], + choco_dir, + ), + ] + + +def test_pack_and_push_requires_api_key(tmp_path): + with pytest.raises(chocolatey_push.PushError, match="chocolatey-api-key"): + chocolatey_push.pack_and_push( + package_id="codereview-cli", + working_dir=_working_dir(tmp_path), + api_key="", + command_runner=lambda command, cwd, text, capture_output: _result(0), + ) + + +def test_pack_failure_fails_release(tmp_path): + def runner(command, cwd, text, capture_output): + return _result(1, stderr="pack failed") + + with pytest.raises(chocolatey_push.PushError, match="choco pack failed"): + chocolatey_push.pack_and_push( + package_id="codereview-cli", + working_dir=_working_dir(tmp_path), + api_key="key", + command_runner=runner, + ) + + +def test_non_forbidden_push_failure_fails_release(tmp_path): + def runner(command, cwd, text, capture_output): + if command == ["choco", "pack"]: + (cwd / "codereview-cli.1.0.0.nupkg").write_text("pkg") + return _result(0) + return _result(1, stderr="500 server error") + + with pytest.raises(chocolatey_push.PushError, match="choco push failed"): + chocolatey_push.pack_and_push( + package_id="codereview-cli", + working_dir=_working_dir(tmp_path), + api_key="key", + command_runner=runner, + ) + + +def test_forbidden_pending_first_submission_succeeds_with_warning(tmp_path): + summary = tmp_path / "summary.md" + work = _working_dir(tmp_path) + choco_dir = work / "packaging" / "chocolatey" + calls = [] + + rc = chocolatey_push.pack_and_push( + package_id="codereview-cli", + working_dir=work, + api_key="key", + command_runner=_forbidden_push_runner(tmp_path, calls=calls), + http_get=_http_get({"/package/codereview-cli": (200, "nupkg"), "/Packages()?": (200, EMPTY_FEED)}), + summary_path=str(summary), + ) + + assert rc == 0 + assert calls == [ + (["choco", "pack"], choco_dir), + ( + [ + "choco", + "push", + "codereview-cli.1.0.0.nupkg", + "--source", + "https://push.chocolatey.org/", + "--key", + "key", + ], + choco_dir, + ), + ] + assert "was not accepted for this release" in summary.read_text() + + +@pytest.mark.parametrize( + "stderr", + [ + "403 (Forbidden): Invalid API Key", + "403 (Forbidden): invalid apikey", + "403 (Forbidden): API key is invalid", + "403 (Forbidden): unauthorized", + "403 (Forbidden): not authorized to push package codereview-cli", + "403 (Forbidden): package is not owned by this user", + "403 (Forbidden): not owned by this account", + "403 (Forbidden): not the owner of package codereview-cli", + "403 (Forbidden): package owner mismatch", + ], +) +def test_forbidden_credential_or_owner_output_fails_before_probe(tmp_path, stderr): + calls = [] + + def http_get(url): + calls.append(url) + return chocolatey_push.HttpResponse(200, EMPTY_FEED) + + with pytest.raises(chocolatey_push.PushError, match="credential"): + chocolatey_push.pack_and_push( + package_id="codereview-cli", + working_dir=_working_dir(tmp_path), + api_key="key", + command_runner=_forbidden_push_runner(tmp_path, stderr=stderr), + http_get=http_get, + ) + assert calls == [] + + +def test_forbidden_visible_package_fails_release(tmp_path): + with pytest.raises(chocolatey_push.PushError, match="pending first-submission"): + chocolatey_push.pack_and_push( + package_id="codereview-cli", + working_dir=_working_dir(tmp_path), + api_key="key", + command_runner=_forbidden_push_runner(tmp_path), + http_get=_http_get( + {"/package/codereview-cli": (200, "nupkg"), "/Packages()?": (200, ENTRY_FEED)} + ), + ) + + +def test_forbidden_package_not_found_fails_release(tmp_path): + with pytest.raises(chocolatey_push.PushError, match="pending first-submission"): + chocolatey_push.pack_and_push( + package_id="codereview-cli", + working_dir=_working_dir(tmp_path), + api_key="key", + command_runner=_forbidden_push_runner(tmp_path), + http_get=_http_get({"/package/codereview-cli": (404, "")}), + ) + + +@pytest.mark.parametrize( + "responses, error", + [ + ({"/package/codereview-cli": (503, "")}, "package endpoint"), + ({"/package/codereview-cli": (200, "nupkg"), "/Packages()?": (503, "")}, "listing"), + ( + {"/package/codereview-cli": (200, "nupkg"), "/Packages()?": (200, "not xml")}, + "malformed", + ), + ( + {"/package/codereview-cli": (200, "nupkg"), "/Packages()?": (200, "")}, + "Atom feed", + ), + ], +) +def test_forbidden_probe_uncertainty_fails_closed(tmp_path, responses, error): + with pytest.raises(chocolatey_push.ProbeError, match=error): + chocolatey_push.pack_and_push( + package_id="codereview-cli", + working_dir=_working_dir(tmp_path), + api_key="key", + command_runner=_forbidden_push_runner(tmp_path), + http_get=_http_get(responses), + ) + + +def test_probe_package_state_builds_expected_urls(): + seen = [] + + def http_get(url): + seen.append(url) + if "/package/codereview-cli" in url: + return chocolatey_push.HttpResponse(200, "nupkg") + return chocolatey_push.HttpResponse(200, EMPTY_FEED) + + state = chocolatey_push.probe_package_state("codereview-cli", http_get=http_get) + + assert state.pending_first_submission is True + assert seen[0] == "https://community.chocolatey.org/api/v2/package/codereview-cli" + assert "%24filter=Id+eq+%27codereview-cli%27" in seen[1] + + +def test_probe_package_state_escapes_odata_string_quotes(): + seen = [] + + def http_get(url): + seen.append(url) + if "/package/code%27review-cli" in url: + return chocolatey_push.HttpResponse(200, "nupkg") + return chocolatey_push.HttpResponse(200, EMPTY_FEED) + + state = chocolatey_push.probe_package_state("code'review-cli", http_get=http_get) + + assert state.pending_first_submission is True + assert "%24filter=Id+eq+%27code%27%27review-cli%27" in seen[1] + + +def test_request_text_transport_failure_fails_closed(monkeypatch): + def fail(request, timeout): + raise URLError("network down") + + monkeypatch.setattr(chocolatey_push, "urlopen", fail) + + with pytest.raises(chocolatey_push.ProbeError, match="request failed"): + chocolatey_push.request_text("https://community.chocolatey.org/api/v2/package/codereview-cli") + + +def _working_dir(tmp_path): + work = tmp_path / "repo" + (work / "packaging" / "chocolatey").mkdir(parents=True) + return work + + +def _forbidden_push_runner( + tmp_path, + stderr="Response status code does not indicate success: 403 (Forbidden).", + calls=None, +): + def runner(command, cwd, text, capture_output): + if calls is not None: + calls.append((command, cwd)) + if command == ["choco", "pack"]: + (cwd / "codereview-cli.1.0.0.nupkg").write_text("pkg") + return _result(0, stdout="packed\n") + return _result(1, stderr=stderr) + + return runner + + +def _http_get(responses): + def http_get(url): + for needle, (status, body) in responses.items(): + if needle in url: + return chocolatey_push.HttpResponse(status, body) + raise AssertionError(f"unexpected URL: {url}") + + return http_get + + +def _result(returncode, stdout="", stderr=""): + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)