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
39 changes: 19 additions & 20 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,8 @@ jobs:
CHOCO_DIR: ${{ matrix.dir }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- name: Get checksums from release
- id: checksums
name: Get checksums from release
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -365,32 +366,30 @@ jobs:
if (-not $arm64Hash) { Write-Error "no checksum for $env:ARM64_ASSET in checksums.txt"; exit 1 }
echo "X64_HASH=$x64Hash" >> $env:GITHUB_ENV
echo "ARM64_HASH=$arm64Hash" >> $env:GITHUB_ENV
- name: Update version and checksums
shell: pwsh
# paths are tool-local: working-directory-relative so a monorepo's
# tools/<tool>/packaging/chocolatey resolves (flat repos pass ".").
working-directory: ${{ inputs.working-directory }}
run: |
$nuspec = "$env:CHOCO_DIR/$env:CHOCO_ID.nuspec"
$content = Get-Content $nuspec -Raw
# fail loud if the placeholder isn't present — a silent no-op would pack the wrong version
if ($content -notmatch '<version>0\.0\.0</version>') { Write-Error "nuspec $nuspec has no <version>0.0.0</version> placeholder"; exit 1 }
$content -replace '<version>0.0.0</version>', "<version>$env:VERSION</version>" | Set-Content $nuspec
$script = "$env:CHOCO_DIR/tools/chocolateyInstall.ps1"
$content = Get-Content $script -Raw
# fail loud if a placeholder is absent — a silent no-op would push literal placeholders
if ($content -notmatch 'CHECKSUM_AMD64_PLACEHOLDER') { Write-Error "$script has no CHECKSUM_AMD64_PLACEHOLDER"; exit 1 }
if ($content -notmatch 'CHECKSUM_ARM64_PLACEHOLDER') { Write-Error "$script has no CHECKSUM_ARM64_PLACEHOLDER"; exit 1 }
$content = $content -replace 'CHECKSUM_AMD64_PLACEHOLDER', $env:X64_HASH
$content = $content -replace 'CHECKSUM_ARM64_PLACEHOLDER', $env:ARM64_HASH
Set-Content $script $content
echo "x64-hash=$x64Hash" >> $env:GITHUB_OUTPUT
echo "arm64-hash=$arm64Hash" >> $env:GITHUB_OUTPUT
- id: render
name: Update version, checksums, and download URLs
uses: $/actions/chocolatey-render
with:
working-directory: ${{ inputs.working-directory }}
package-directory: ${{ matrix.dir }}
package-id: ${{ matrix.id }}
version: ${{ needs.goreleaser.outputs.version }}
repository: ${{ github.repository }}
final-tag: ${{ needs.goreleaser.outputs.final-tag }}
x64-asset: ${{ matrix.x64_asset }}
arm64-asset: ${{ matrix.arm64_asset }}
x64-hash: ${{ steps.checksums.outputs.x64-hash }}
arm64-hash: ${{ steps.checksums.outputs.arm64-hash }}
- name: Pack and push
uses: $/actions/chocolatey-push
with:
package-id: ${{ matrix.id }}
working-directory: ${{ inputs.working-directory }}
package-directory: ${{ matrix.dir }}
api-key: ${{ secrets.chocolatey-api-key }}
require-static-urls: ${{ steps.render.outputs.static-urls }}

# winget/linux are best-effort: Microsoft-hosted flakiness must not gate the
# binary/Homebrew artifacts. Failure surfaces in the step summary.
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/test-actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,15 @@ jobs:
shell: bash
working-directory: actions/chocolatey-push

# Chocolatey renderer: exact per-package asset URLs, migration opt-in, and
# legacy-template compatibility on the same PowerShell script used in release.yml.
chocolatey-render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- run: ./actions/chocolatey-render/test_render.ps1
shell: pwsh

# identity.py unit tests — PASS + every drift rule + missing-manifest, in tmp dirs.
identity-unit:
runs-on: ubuntu-latest
Expand Down
12 changes: 11 additions & 1 deletion actions/chocolatey-push/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ inputs:
api-key:
description: Chocolatey API key.
required: true
require-static-urls:
description: Reject packages whose install scripts retain URL placeholders or runtime version expressions.
required: false
default: "false"
runs:
using: composite
steps:
Expand All @@ -27,10 +31,16 @@ runs:
PACKAGE_ID: ${{ inputs.package-id }}
WORKING_DIRECTORY: ${{ inputs.working-directory }}
PACKAGE_DIRECTORY: ${{ inputs.package-directory }}
REQUIRE_STATIC_URLS: ${{ inputs.require-static-urls }}
run: |
python -m pip install "defusedxml==0.7.1"
static_args=()
if [ "$REQUIRE_STATIC_URLS" = "true" ]; then
static_args+=(--require-static-urls)
fi
python "$GITHUB_ACTION_PATH/chocolatey_push.py" push \
--package-id "$PACKAGE_ID" \
--working-directory "$WORKING_DIRECTORY" \
--package-directory "$PACKAGE_DIRECTORY" \
--api-key-env CHOCO_API_KEY
--api-key-env CHOCO_API_KEY \
"${static_args[@]}"
32 changes: 32 additions & 0 deletions actions/chocolatey-push/chocolatey_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

import argparse
import os
import re
import subprocess
import sys
import zipfile
from dataclasses import dataclass
from pathlib import Path
from urllib.error import HTTPError, URLError
Expand All @@ -18,6 +20,9 @@
CHOCO_SOURCE = "https://push.chocolatey.org/"
COMMUNITY_API = "https://community.chocolatey.org/api/v2"
ATOM_NS = "{http://www.w3.org/2005/Atom}"
URL_PLACEHOLDERS = ("URL_AMD64_PLACEHOLDER", "URL_ARM64_PLACEHOLDER")
CHECKSUM_PLACEHOLDERS = ("CHECKSUM_AMD64_PLACEHOLDER", "CHECKSUM_ARM64_PLACEHOLDER")
RUNTIME_VERSION_EXPRESSION = re.compile(r"(?i)ChocolateyPackageVersion|\$\{\s*version\s*\}")


class PushError(Exception):
Expand Down Expand Up @@ -64,6 +69,7 @@ def pack_and_push(
command_runner=None,
http_get=None,
summary_path: str | None = None,
require_static_urls: bool = False,
) -> int:
if not api_key:
raise PushError("chocolatey-api-key secret is required")
Expand All @@ -77,6 +83,8 @@ def pack_and_push(
packages = sorted(choco_dir.glob("*.nupkg"))
if len(packages) != 1:
raise PushError(f"expected exactly one .nupkg, found {len(packages)}")
if require_static_urls:
validate_static_package(packages[0])

push = run_command(
[
Expand Down Expand Up @@ -115,6 +123,28 @@ def pack_and_push(
return 0


def validate_static_package(package: Path) -> None:
"""Reject migrated packages that still defer release URLs to install time."""
try:
with zipfile.ZipFile(package) as archive:
scripts = [name for name in archive.namelist() if name.lower().endswith((".ps1", ".nuspec"))]
if not scripts:
raise PushError(f"{package.name} contains no Chocolatey scripts or nuspec")
if not any(name.replace("\\", "/").rsplit("/", 1)[-1].lower() == "chocolateyinstall.ps1" for name in scripts):
raise PushError(f"{package.name} does not contain tools/chocolateyInstall.ps1")
for name in scripts:
# Windows PowerShell 5.1 may pack UTF-16 scripts; removing its
# NUL padding keeps the token checks encoding-independent.
text = archive.read(name).decode("utf-8", errors="replace").replace("\x00", "")
leftovers = [token for token in URL_PLACEHOLDERS + CHECKSUM_PLACEHOLDERS if token in text]
if leftovers:
raise PushError(f"{package.name}/{name} retains placeholders: {', '.join(leftovers)}")
if RUNTIME_VERSION_EXPRESSION.search(text):
raise PushError(f"{package.name}/{name} retains a runtime package-version expression")
except zipfile.BadZipFile as exc:
raise PushError(f"cannot inspect invalid Chocolatey package {package.name}") from exc


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)
Expand Down Expand Up @@ -211,6 +241,7 @@ def main(argv: list[str] | None = None) -> int:
push.add_argument("--working-directory", default=".")
push.add_argument("--package-directory", default="packaging/chocolatey")
push.add_argument("--api-key-env", default="CHOCO_API_KEY")
push.add_argument("--require-static-urls", action="store_true")
args = parser.parse_args(argv)

try:
Expand All @@ -220,6 +251,7 @@ def main(argv: list[str] | None = None) -> int:
working_dir=args.working_directory,
package_dir=args.package_directory,
api_key=os.environ.get(args.api_key_env, ""),
require_static_urls=args.require_static_urls,
)
except (PushError, ProbeError) as exc:
print(f"::error::{exc}", file=sys.stderr)
Expand Down
59 changes: 59 additions & 0 deletions actions/chocolatey-push/test_chocolatey_push.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import zipfile
from types import SimpleNamespace
from urllib.error import URLError

Expand Down Expand Up @@ -101,6 +102,64 @@ def runner(command, cwd, text, capture_output):
)


@pytest.mark.parametrize(
"script, encoding, error",
[
("$url = 'URL_AMD64_PLACEHOLDER'", "utf-8", "retains placeholders"),
("$url = 'https://example.test/$env:ChocolateyPackageVersion'", "utf-16", "runtime package-version"),
],
)
def test_static_package_validation_rejects_unrendered_install_script(tmp_path, script, encoding, error):
work = _working_dir(tmp_path)
calls = []

def runner(command, cwd, **_kwargs):
calls.append(command)
if command == ["choco", "pack"]:
with zipfile.ZipFile(cwd / "codereview-cli.1.0.0.nupkg", "w") as archive:
archive.writestr("tools/chocolateyInstall.ps1", script.encode(encoding))
return _result(0)
raise AssertionError("static package validation must run before choco push")

with pytest.raises(chocolatey_push.PushError, match=error):
chocolatey_push.pack_and_push(
package_id="codereview-cli",
working_dir=work,
api_key="key",
command_runner=runner,
require_static_urls=True,
)
assert calls == [["choco", "pack"]]


def test_static_package_validation_accepts_rendered_package(tmp_path):
package = tmp_path / "package.nupkg"
with zipfile.ZipFile(package, "w") as archive:
archive.writestr(
"tools/chocolateyInstall.ps1",
"\n".join(
[
"$url64 = 'https://github.com/open-cli-collective/example/releases/download/v1.2.3/example-amd64.zip'",
"$urlArm64 = 'https://github.com/open-cli-collective/example/releases/download/v1.2.3/example-arm64.zip'",
"$checksum64 = 'a' * 64",
"$checksumArm64 = 'b' * 64",
]
),
)
archive.writestr("example.nuspec", "<version>1.2.3</version>")

assert chocolatey_push.validate_static_package(package) is None


def test_static_package_validation_requires_install_script(tmp_path):
package = tmp_path / "package.nupkg"
with zipfile.ZipFile(package, "w") as archive:
archive.writestr("example.nuspec", "<version>1.2.3</version>")

with pytest.raises(chocolatey_push.PushError, match="chocolateyInstall.ps1"):
chocolatey_push.validate_static_package(package)


def test_non_forbidden_push_failure_fails_release(tmp_path):
def runner(command, cwd, text, capture_output):
if command == ["choco", "pack"]:
Expand Down
68 changes: 68 additions & 0 deletions actions/chocolatey-render/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Render Chocolatey package
description: Inject the release version, checksums, and optional static asset URLs into a Chocolatey package.
inputs:
working-directory:
description: Repository-relative tool working directory.
required: false
default: "."
package-directory:
description: Chocolatey package directory relative to working-directory.
required: true
package-id:
description: Chocolatey package id.
required: true
version:
description: Release version for the nuspec.
required: true
repository:
description: GitHub repository containing the release assets, owner/name.
required: true
final-tag:
description: GitHub release tag containing the published assets.
required: true
x64-asset:
description: Published AMD64 Windows asset name.
required: true
arm64-asset:
description: Published ARM64 Windows asset name.
required: true
x64-hash:
description: SHA256 for the AMD64 Windows asset.
required: true
arm64-hash:
description: SHA256 for the ARM64 Windows asset.
required: true
outputs:
static-urls:
description: true when both URL placeholders were rendered into literal release URLs.
value: ${{ steps.render.outputs.static-urls }}
runs:
using: composite
steps:
- id: render
shell: pwsh
env:
ACTION_PATH: ${{ github.action_path }}
WORKING_DIRECTORY: ${{ inputs.working-directory }}
PACKAGE_DIRECTORY: ${{ inputs.package-directory }}
PACKAGE_ID: ${{ inputs.package-id }}
VERSION: ${{ inputs.version }}
GITHUB_REPOSITORY: ${{ inputs.repository }}
FINAL_TAG: ${{ inputs.final-tag }}
X64_ASSET: ${{ inputs.x64-asset }}
ARM64_ASSET: ${{ inputs.arm64-asset }}
X64_HASH: ${{ inputs.x64-hash }}
ARM64_HASH: ${{ inputs.arm64-hash }}
run: |
& "$env:ACTION_PATH/render.ps1" `
-WorkingDirectory "$env:WORKING_DIRECTORY" `
-PackageDirectory "$env:PACKAGE_DIRECTORY" `
-PackageId "$env:PACKAGE_ID" `
-Version "$env:VERSION" `
-Repository "$env:GITHUB_REPOSITORY" `
-FinalTag "$env:FINAL_TAG" `
-X64Asset "$env:X64_ASSET" `
-Arm64Asset "$env:ARM64_ASSET" `
-X64Hash "$env:X64_HASH" `
-Arm64Hash "$env:ARM64_HASH" `
-GitHubOutput "$env:GITHUB_OUTPUT"
Loading
Loading