From f3864331256403124226f15af551d2c517c96c86 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 25 Sep 2026 11:54:55 -0400 Subject: [PATCH 1/2] fix(release): resolve Windows assets by canonical binary name --- .github/workflows/release.yml | 35 +++------------ .github/workflows/test-actions.yml | 9 ++++ actions/release-assets/action.yml | 40 +++++++++++++++++ actions/release-assets/resolve.sh | 42 ++++++++++++++++++ actions/release-assets/test_resolve.sh | 59 ++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 28 deletions(-) create mode 100644 actions/release-assets/action.yml create mode 100755 actions/release-assets/resolve.sh create mode 100755 actions/release-assets/test_resolve.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efc1d3f..ebce723 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -186,34 +186,13 @@ jobs: - id: assets name: Resolve per-binary Windows archives - shell: bash - env: - CHOCOLATEY: ${{ steps.meta.outputs.chocolatey-base-matrix }} - WINGET: ${{ steps.meta.outputs.winget-base-matrix }} - VERSION: ${{ steps.meta.outputs.version }} - run: | - set -euo pipefail - # The snapshot build names its archives with the snapshot version - # (e.g. 1.2.1-SNAPSHOT-abc1234); the published release uses the final - # version, so rewrite that substring before handing names to the - # Windows channels. - snapshot_version="$(jq -r '.version' dist/metadata.json)" - [ -n "$snapshot_version" ] && [ "$snapshot_version" != null ] || { echo "::error::dist/metadata.json has no version; the snapshot goreleaser step above must write it (check GORELEASER_CURRENT_TAG and the goreleaser config)"; exit 1; } - enrich() { - printf '%s' "$1" | jq -c --slurpfile artifacts dist/artifacts.json --arg snap "$snapshot_version" --arg final "$VERSION" ' - def owns($name): - (.extra.ID // "") == $name or - ((.extra.Binaries // []) | index($name) != null); - def finalize: split($snap) | join($final); - def asset($name; $arch): - [$artifacts[0][] | select(.type == "Archive" and .goos == "windows" and .goarch == $arch and owns($name)) | .name | finalize] - | if length == 1 then .[0] else error("expected exactly one windows/" + $arch + " archive for " + $name + "; got " + (length|tostring)) end; - map(. + {x64_asset: asset(.name; "amd64"), arm64_asset: asset(.name; "arm64")})' - } - { - echo "chocolatey-matrix=$(enrich "$CHOCOLATEY")" - echo "winget-matrix=$(enrich "$WINGET")" - } >> "$GITHUB_OUTPUT" + uses: $/actions/release-assets + with: + artifacts-path: dist/artifacts.json + metadata-path: dist/metadata.json + chocolatey-matrix: ${{ steps.meta.outputs.chocolatey-base-matrix }} + winget-matrix: ${{ steps.meta.outputs.winget-base-matrix }} + version: ${{ steps.meta.outputs.version }} # CGO-darwin pre-publish gate — prove the Keychain backend is present on # the snapshot artifacts BEFORE we publish. Skipped for tools that declare diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 1f2b20c..9829706 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -204,6 +204,15 @@ jobs: - run: ./actions/chocolatey-render/test_render.ps1 shell: pwsh + # Release asset resolution: exercise the real cfl GoReleaser metadata shape + # and verify missing architecture artifacts fail before release fan-out. + release-assets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - run: bash actions/release-assets/test_resolve.sh + shell: bash + # identity.py unit tests — PASS + every drift rule + missing-manifest, in tmp dirs. identity-unit: runs-on: ubuntu-latest diff --git a/actions/release-assets/action.yml b/actions/release-assets/action.yml new file mode 100644 index 0000000..232dd6d --- /dev/null +++ b/actions/release-assets/action.yml @@ -0,0 +1,40 @@ +name: Resolve release assets +description: Resolve exact Windows archive names for release channels. +inputs: + artifacts-path: + description: Path to the GoReleaser artifacts.json file. + required: false + default: dist/artifacts.json + metadata-path: + description: Path to the GoReleaser metadata.json file. + required: false + default: dist/metadata.json + chocolatey-matrix: + description: JSON array of Chocolatey binary entries from identity metadata. + required: true + winget-matrix: + description: JSON array of WinGet binary entries from identity metadata. + required: true + version: + description: Final release version used to rename snapshot asset names. + required: true +outputs: + chocolatey-matrix: + description: Chocolatey entries enriched with x64_asset and arm64_asset. + value: ${{ steps.resolve.outputs.chocolatey-matrix }} + winget-matrix: + description: WinGet entries enriched with x64_asset and arm64_asset. + value: ${{ steps.resolve.outputs.winget-matrix }} +runs: + using: composite + steps: + - id: resolve + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + ARTIFACTS_PATH: ${{ inputs.artifacts-path }} + METADATA_PATH: ${{ inputs.metadata-path }} + CHOCOLATEY: ${{ inputs.chocolatey-matrix }} + WINGET: ${{ inputs.winget-matrix }} + VERSION: ${{ inputs.version }} + run: bash "$ACTION_PATH/resolve.sh" diff --git a/actions/release-assets/resolve.sh b/actions/release-assets/resolve.sh new file mode 100755 index 0000000..ec1764a --- /dev/null +++ b/actions/release-assets/resolve.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${ARTIFACTS_PATH:?ARTIFACTS_PATH is required}" +: "${METADATA_PATH:?METADATA_PATH is required}" +: "${CHOCOLATEY:?CHOCOLATEY is required}" +: "${WINGET:?WINGET is required}" +: "${VERSION:?VERSION is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +[ -f "$ARTIFACTS_PATH" ] || { echo "::error::artifacts.json not found: $ARTIFACTS_PATH" >&2; exit 1; } +[ -f "$METADATA_PATH" ] || { echo "::error::metadata.json not found: $METADATA_PATH" >&2; exit 1; } + +# The snapshot build names its archives with the snapshot version (for example, +# 1.2.1-SNAPSHOT-abc1234); the published release uses the final version, so +# rewrite that substring before handing names to the Windows channels. +snapshot_version="$(jq -r '.version // empty' "$METADATA_PATH")" +[ -n "$snapshot_version" ] || { + echo "::error::$METADATA_PATH has no version; the snapshot goreleaser step must write it" >&2 + exit 1 +} + +enrich() { + printf '%s' "$1" | jq -c --slurpfile artifacts "$ARTIFACTS_PATH" --arg snap "$snapshot_version" --arg final "$VERSION" ' + # GoReleaser records Windows executables with their .exe suffix while + # identity manifests use the canonical binary name (for example cfl). + def canonical($value): + ($value // "" | ascii_downcase | sub("\\.exe$"; "")); + def owns($name): + (canonical(.extra.ID) == canonical($name)) or + ((.extra.Binaries // []) | map(canonical(.)) | index(canonical($name)) != null); + def finalize: split($snap) | join($final); + def asset($name; $arch): + [$artifacts[0][] | select(.type == "Archive" and .goos == "windows" and .goarch == $arch and owns($name)) | .name | finalize] + | if length == 1 then .[0] else error("expected exactly one windows/" + $arch + " archive for " + $name + "; got " + (length|tostring)) end; + map(. + {x64_asset: asset(.name; "amd64"), arm64_asset: asset(.name; "arm64")})' +} + +chocolatey_matrix="$(enrich "$CHOCOLATEY")" +winget_matrix="$(enrich "$WINGET")" +printf 'chocolatey-matrix=%s\n' "$chocolatey_matrix" >> "$GITHUB_OUTPUT" +printf 'winget-matrix=%s\n' "$winget_matrix" >> "$GITHUB_OUTPUT" diff --git a/actions/release-assets/test_resolve.sh b/actions/release-assets/test_resolve.sh new file mode 100755 index 0000000..a788efb --- /dev/null +++ b/actions/release-assets/test_resolve.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Exercise the same release asset resolver used by release.yml. +set -uo pipefail +cd "$(dirname "$0")" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +fails=0 +ok() { echo "ok $1"; } +bad() { echo "FAIL $1"; fails=$((fails+1)); } + +cat > "$tmp/metadata.json" <<'JSON' +{"version":"1.3.97-SNAPSHOT-abc1234"} +JSON + +# This is the cfl 1.3.97 GoReleaser shape: the manifest name is cfl, the +# archive owner ID is default, and Binaries contains cfl.exe. +cat > "$tmp/artifacts.json" <<'JSON' +[ + {"type":"Archive","goos":"windows","goarch":"amd64","name":"cfl_1.3.97-SNAPSHOT-abc1234_windows_amd64.zip","extra":{"Binaries":["cfl.exe"],"ID":"default"}}, + {"type":"Archive","goos":"windows","goarch":"arm64","name":"cfl_1.3.97-SNAPSHOT-abc1234_windows_arm64.zip","extra":{"Binaries":["cfl.exe"],"ID":"default"}} +] +JSON + +output="$tmp/output" +GITHUB_OUTPUT="$output" \ +ARTIFACTS_PATH="$tmp/artifacts.json" \ +METADATA_PATH="$tmp/metadata.json" \ +CHOCOLATEY='[{"name":"cfl","id":"confluence-cli","dir":"packaging/chocolatey"}]' \ +WINGET='[]' \ +VERSION='1.3.97' \ +bash resolve.sh >/dev/null + +matrix=$(sed -n 's/^chocolatey-matrix=//p' "$output") +printf '%s' "$matrix" | jq -e ' + length == 1 and + .[0].name == "cfl" and + .[0].x64_asset == "cfl_1.3.97_windows_amd64.zip" and + .[0].arm64_asset == "cfl_1.3.97_windows_arm64.zip" +' >/dev/null \ + && ok "cfl .exe artifacts resolve to exact per-arch assets" \ + || bad "cfl .exe artifacts resolve to exact per-arch assets" + +jq 'map(select(.goarch != "amd64"))' "$tmp/artifacts.json" > "$tmp/missing-amd64.json" +if GITHUB_OUTPUT="$tmp/missing-output" \ + ARTIFACTS_PATH="$tmp/missing-amd64.json" \ + METADATA_PATH="$tmp/metadata.json" \ + CHOCOLATEY='[{"name":"cfl","id":"confluence-cli","dir":"packaging/chocolatey"}]' \ + WINGET='[]' \ + VERSION='1.3.97' \ + bash resolve.sh > /dev/null 2>"$tmp/missing-error"; then + bad "missing amd64 artifact fails" +elif grep -Fq 'expected exactly one windows/amd64 archive for cfl; got 0' "$tmp/missing-error"; then + ok "missing amd64 artifact fails loudly" +else + bad "missing amd64 artifact reports the expected error" +fi + +echo "----" +if [ "$fails" -eq 0 ]; then echo "all release asset tests passed"; else echo "$fails failed"; exit 1; fi From 446715fe42b01f321a5f8c97c892b39b1ab050ea Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 25 Sep 2026 12:01:18 -0400 Subject: [PATCH 2/2] test(release): exercise both asset matrix outputs --- .github/workflows/test-actions.yml | 26 +++++++++++++++++++ actions/release-assets/test_resolve.sh | 22 ++++------------ .../release-assets/testdata/artifacts.json | 4 +++ actions/release-assets/testdata/metadata.json | 1 + 4 files changed, 36 insertions(+), 17 deletions(-) create mode 100644 actions/release-assets/testdata/artifacts.json create mode 100644 actions/release-assets/testdata/metadata.json diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 9829706..e525b0f 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -212,6 +212,32 @@ jobs: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - run: bash actions/release-assets/test_resolve.sh shell: bash + - id: resolve + uses: ./actions/release-assets + with: + artifacts-path: actions/release-assets/testdata/artifacts.json + metadata-path: actions/release-assets/testdata/metadata.json + chocolatey-matrix: '[{"name":"cfl","id":"confluence-cli","dir":"packaging/chocolatey"}]' + winget-matrix: '[{"name":"cfl","id":"OpenCLICollective.cfl","bootstrap":false}]' + version: 1.3.97 + - name: Assert both release matrix outputs + shell: bash + env: + CHOCOLATEY: ${{ steps.resolve.outputs.chocolatey-matrix }} + WINGET: ${{ steps.resolve.outputs.winget-matrix }} + run: | + set -euo pipefail + assert_assets() { + printf '%s' "$1" | jq -e ' + length == 1 and + .[0].name == "cfl" and + .[0].x64_asset == "cfl_1.3.97_windows_amd64.zip" and + .[0].arm64_asset == "cfl_1.3.97_windows_arm64.zip" + ' >/dev/null + } + assert_assets "$CHOCOLATEY" + assert_assets "$WINGET" + echo "ok: both action outputs contain the cfl amd64 and arm64 assets" # identity.py unit tests — PASS + every drift rule + missing-manifest, in tmp dirs. identity-unit: diff --git a/actions/release-assets/test_resolve.sh b/actions/release-assets/test_resolve.sh index a788efb..8bb1917 100755 --- a/actions/release-assets/test_resolve.sh +++ b/actions/release-assets/test_resolve.sh @@ -2,29 +2,17 @@ # Exercise the same release asset resolver used by release.yml. set -uo pipefail cd "$(dirname "$0")" +fixture_dir="$PWD/testdata" tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT fails=0 ok() { echo "ok $1"; } bad() { echo "FAIL $1"; fails=$((fails+1)); } -cat > "$tmp/metadata.json" <<'JSON' -{"version":"1.3.97-SNAPSHOT-abc1234"} -JSON - -# This is the cfl 1.3.97 GoReleaser shape: the manifest name is cfl, the -# archive owner ID is default, and Binaries contains cfl.exe. -cat > "$tmp/artifacts.json" <<'JSON' -[ - {"type":"Archive","goos":"windows","goarch":"amd64","name":"cfl_1.3.97-SNAPSHOT-abc1234_windows_amd64.zip","extra":{"Binaries":["cfl.exe"],"ID":"default"}}, - {"type":"Archive","goos":"windows","goarch":"arm64","name":"cfl_1.3.97-SNAPSHOT-abc1234_windows_arm64.zip","extra":{"Binaries":["cfl.exe"],"ID":"default"}} -] -JSON - output="$tmp/output" GITHUB_OUTPUT="$output" \ -ARTIFACTS_PATH="$tmp/artifacts.json" \ -METADATA_PATH="$tmp/metadata.json" \ +ARTIFACTS_PATH="$fixture_dir/artifacts.json" \ +METADATA_PATH="$fixture_dir/metadata.json" \ CHOCOLATEY='[{"name":"cfl","id":"confluence-cli","dir":"packaging/chocolatey"}]' \ WINGET='[]' \ VERSION='1.3.97' \ @@ -40,10 +28,10 @@ printf '%s' "$matrix" | jq -e ' && ok "cfl .exe artifacts resolve to exact per-arch assets" \ || bad "cfl .exe artifacts resolve to exact per-arch assets" -jq 'map(select(.goarch != "amd64"))' "$tmp/artifacts.json" > "$tmp/missing-amd64.json" +jq 'map(select(.goarch != "amd64"))' "$fixture_dir/artifacts.json" > "$tmp/missing-amd64.json" if GITHUB_OUTPUT="$tmp/missing-output" \ ARTIFACTS_PATH="$tmp/missing-amd64.json" \ - METADATA_PATH="$tmp/metadata.json" \ + METADATA_PATH="$fixture_dir/metadata.json" \ CHOCOLATEY='[{"name":"cfl","id":"confluence-cli","dir":"packaging/chocolatey"}]' \ WINGET='[]' \ VERSION='1.3.97' \ diff --git a/actions/release-assets/testdata/artifacts.json b/actions/release-assets/testdata/artifacts.json new file mode 100644 index 0000000..15ad26f --- /dev/null +++ b/actions/release-assets/testdata/artifacts.json @@ -0,0 +1,4 @@ +[ + {"type":"Archive","goos":"windows","goarch":"amd64","name":"cfl_1.3.97-SNAPSHOT-abc1234_windows_amd64.zip","extra":{"Binaries":["cfl.exe"],"ID":"default"}}, + {"type":"Archive","goos":"windows","goarch":"arm64","name":"cfl_1.3.97-SNAPSHOT-abc1234_windows_arm64.zip","extra":{"Binaries":["cfl.exe"],"ID":"default"}} +] diff --git a/actions/release-assets/testdata/metadata.json b/actions/release-assets/testdata/metadata.json new file mode 100644 index 0000000..e25dca7 --- /dev/null +++ b/actions/release-assets/testdata/metadata.json @@ -0,0 +1 @@ +{"version":"1.3.97-SNAPSHOT-abc1234"}