From 888cbf3c6788389277341b7a0cf8730fa2de49db Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 4 Jun 2026 10:32:08 -0400 Subject: [PATCH 1/4] fix(chocolatey): handle first-submission moderation Closes #31 --- .github/workflows/release.yml | 16 +- .github/workflows/test-actions.yml | 15 ++ actions/chocolatey-push/action.yml | 27 +++ actions/chocolatey-push/chocolatey_push.py | 203 ++++++++++++++++++ .../chocolatey-push/test_chocolatey_push.py | 193 +++++++++++++++++ 5 files changed, 443 insertions(+), 11 deletions(-) create mode 100644 actions/chocolatey-push/action.yml create mode 100644 actions/chocolatey-push/chocolatey_push.py create mode 100644 actions/chocolatey-push/test_chocolatey_push.py 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..8a23018 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" + 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..2c3c84b --- /dev/null +++ b/actions/chocolatey-push/action.yml @@ -0,0 +1,27 @@ +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 }} + run: | + python "$GITHUB_ACTION_PATH/chocolatey_push.py" push \ + --package-id "${{ inputs.package-id }}" \ + --working-directory "${{ inputs.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..f031a5c --- /dev/null +++ b/actions/chocolatey-push/chocolatey_push.py @@ -0,0 +1,203 @@ +#!/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 +import xml.etree.ElementTree as ET +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 + +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}") + + 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 '{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 _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..ea008de --- /dev/null +++ b/actions/chocolatey-push/test_chocolatey_push.py @@ -0,0 +1,193 @@ +from types import SimpleNamespace + +import pytest + +import chocolatey_push + + +EMPTY_FEED = """\ + + + Packages + +""" + +ENTRY_FEED = """\ + + + codereview-cli + +""" + + +def test_pack_and_push_success(tmp_path): + calls = [] + + def runner(command, cwd, text, capture_output): + calls.append(command) + 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=_working_dir(tmp_path), + api_key="key", + command_runner=runner, + ) + + assert rc == 0 + assert calls[0] == ["choco", "pack"] + assert calls[1][:3] == ["choco", "push", "codereview-cli.1.0.0.nupkg"] + + +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" + + rc = 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, EMPTY_FEED)}), + summary_path=str(summary), + ) + + assert rc == 0 + assert "was not accepted for this release" in summary.read_text() + + +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 _working_dir(tmp_path): + work = tmp_path / "repo" + (work / "packaging" / "chocolatey").mkdir(parents=True) + return work + + +def _forbidden_push_runner(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, stdout="packed\n") + return _result(1, stderr="Response status code does not indicate success: 403 (Forbidden).") + + 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) From 1cff6c9a3d7d13522842282df646f7c841756f75 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 4 Jun 2026 10:35:20 -0400 Subject: [PATCH 2/4] fix(chocolatey): fail bad credentials before moderation skip --- actions/chocolatey-push/chocolatey_push.py | 17 +++++++++++++ .../chocolatey-push/test_chocolatey_push.py | 25 +++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/actions/chocolatey-push/chocolatey_push.py b/actions/chocolatey-push/chocolatey_push.py index f031a5c..7129391 100644 --- a/actions/chocolatey-push/chocolatey_push.py +++ b/actions/chocolatey-push/chocolatey_push.py @@ -94,6 +94,8 @@ def pack_and_push( 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: @@ -163,6 +165,21 @@ def _looks_like_forbidden(output: str) -> bool: return "403" in output and "Forbidden" in output +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") diff --git a/actions/chocolatey-push/test_chocolatey_push.py b/actions/chocolatey-push/test_chocolatey_push.py index ea008de..4b4d65d 100644 --- a/actions/chocolatey-push/test_chocolatey_push.py +++ b/actions/chocolatey-push/test_chocolatey_push.py @@ -97,6 +97,24 @@ def test_forbidden_pending_first_submission_succeeds_with_warning(tmp_path): assert "was not accepted for this release" in summary.read_text() +def test_forbidden_invalid_key_output_fails_before_probe(tmp_path): + 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="403 (Forbidden): Invalid API Key"), + 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( @@ -169,12 +187,15 @@ def _working_dir(tmp_path): return work -def _forbidden_push_runner(tmp_path): +def _forbidden_push_runner( + tmp_path, + stderr="Response status code does not indicate success: 403 (Forbidden).", +): def runner(command, cwd, text, capture_output): if command == ["choco", "pack"]: (cwd / "codereview-cli.1.0.0.nupkg").write_text("pkg") return _result(0, stdout="packed\n") - return _result(1, stderr="Response status code does not indicate success: 403 (Forbidden).") + return _result(1, stderr=stderr) return runner From e837cbde4204a487b6165a4984bd8005989e73a4 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 4 Jun 2026 10:38:54 -0400 Subject: [PATCH 3/4] test(chocolatey): cover push wiring and probe failures --- .../chocolatey-push/test_chocolatey_push.py | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/actions/chocolatey-push/test_chocolatey_push.py b/actions/chocolatey-push/test_chocolatey_push.py index 4b4d65d..f223416 100644 --- a/actions/chocolatey-push/test_chocolatey_push.py +++ b/actions/chocolatey-push/test_chocolatey_push.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from urllib.error import URLError import pytest @@ -22,9 +23,11 @@ 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) + calls.append((command, cwd)) if command == ["choco", "pack"]: (cwd / "codereview-cli.1.0.0.nupkg").write_text("pkg") return _result(0, stdout="packed\n") @@ -32,14 +35,27 @@ def runner(command, cwd, text, capture_output): rc = chocolatey_push.pack_and_push( package_id="codereview-cli", - working_dir=_working_dir(tmp_path), + working_dir=work, api_key="key", command_runner=runner, ) assert rc == 0 - assert calls[0] == ["choco", "pack"] - assert calls[1][:3] == ["choco", "push", "codereview-cli.1.0.0.nupkg"] + 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): @@ -97,7 +113,17 @@ def test_forbidden_pending_first_submission_succeeds_with_warning(tmp_path): assert "was not accepted for this release" in summary.read_text() -def test_forbidden_invalid_key_output_fails_before_probe(tmp_path): +@pytest.mark.parametrize( + "stderr", + [ + "403 (Forbidden): Invalid API Key", + "403 (Forbidden): unauthorized", + "403 (Forbidden): package is not owned by this user", + "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): @@ -109,7 +135,7 @@ def http_get(url): package_id="codereview-cli", working_dir=_working_dir(tmp_path), api_key="key", - command_runner=_forbidden_push_runner(tmp_path, stderr="403 (Forbidden): Invalid API Key"), + command_runner=_forbidden_push_runner(tmp_path, stderr=stderr), http_get=http_get, ) assert calls == [] @@ -181,6 +207,16 @@ def http_get(url): assert "%24filter=Id+eq+%27codereview-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) From 956cee5b66a2db2bc6ec4a3633f0e382c58019e2 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 4 Jun 2026 10:49:25 -0400 Subject: [PATCH 4/4] fix(chocolatey): tighten moderation push guard --- .github/workflows/test-actions.yml | 2 +- actions/chocolatey-push/action.yml | 7 ++- actions/chocolatey-push/chocolatey_push.py | 9 +++- .../chocolatey-push/test_chocolatey_push.py | 44 ++++++++++++++++++- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 8a23018..c468cc4 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -180,7 +180,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install "pytest>=8,<9" + - run: pip install "pytest>=8,<9" "defusedxml==0.7.1" shell: bash - run: python -m pytest -q shell: bash diff --git a/actions/chocolatey-push/action.yml b/actions/chocolatey-push/action.yml index 2c3c84b..0f4778f 100644 --- a/actions/chocolatey-push/action.yml +++ b/actions/chocolatey-push/action.yml @@ -20,8 +20,11 @@ runs: - 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 "${{ inputs.package-id }}" \ - --working-directory "${{ inputs.working-directory }}" \ + --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 index 7129391..7864e95 100644 --- a/actions/chocolatey-push/chocolatey_push.py +++ b/actions/chocolatey-push/chocolatey_push.py @@ -6,13 +6,14 @@ import os import subprocess import sys -import xml.etree.ElementTree as ET 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" @@ -132,7 +133,7 @@ def probe_package_state(package_id: str, http_get=None) -> PackageState: if package_response.status != 200: raise ProbeError(f"Chocolatey package endpoint returned HTTP {package_response.status}") - query = urlencode({"$filter": f"Id eq '{package_id}'", "$orderby": "Version desc"}) + 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}") @@ -165,6 +166,10 @@ 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 = ( diff --git a/actions/chocolatey-push/test_chocolatey_push.py b/actions/chocolatey-push/test_chocolatey_push.py index f223416..b5b766f 100644 --- a/actions/chocolatey-push/test_chocolatey_push.py +++ b/actions/chocolatey-push/test_chocolatey_push.py @@ -99,17 +99,35 @@ def runner(command, cwd, text, capture_output): 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=_working_dir(tmp_path), + working_dir=work, api_key="key", - command_runner=_forbidden_push_runner(tmp_path), + 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() @@ -117,8 +135,12 @@ def test_forbidden_pending_first_submission_succeeds_with_warning(tmp_path): "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", ], @@ -207,6 +229,21 @@ def http_get(url): 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") @@ -226,8 +263,11 @@ def _working_dir(tmp_path): 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")