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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/test-actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions actions/chocolatey-push/action.yml
Original file line number Diff line number Diff line change
@@ -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 \
Comment thread
monit-reviewer marked this conversation as resolved.
--package-id "$PACKAGE_ID" \
--working-directory "$WORKING_DIRECTORY" \
--api-key-env CHOCO_API_KEY
225 changes: 225 additions & 0 deletions actions/chocolatey-push/chocolatey_push.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
monit-reviewer marked this conversation as resolved.
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(
Comment thread
monit-reviewer marked this conversation as resolved.
[
"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 "",
Comment thread
monit-reviewer marked this conversation as resolved.
)


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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low (harness-engineering:harness-self-documenting-code-reviewer): _parse_atom_entries only catches ET.ParseError, but defusedxml raises a separate exception hierarchy (DefusedXmlException and subclasses like DTDForbidden, EntitiesForbidden) that does NOT inherit from xml.etree.ElementTree.ParseError. If the Chocolatey API returns XML with a DTD or entity references, those exceptions escape uncaught, bypass the except (PushError, ProbeError) handler in main, and surface as a raw traceback. The release still fails correctly, but the error output is unhelpful. Broaden the catch to include the defusedxml base exception class and re-raise as ProbeError.

Reply to this thread when addressed.

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())
Loading
Loading