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)