From b681dcba964ba5a1adcfcedf57a01ae28f44611b Mon Sep 17 00:00:00 2001 From: mike Date: Sat, 25 Jul 2026 14:31:11 -0400 Subject: [PATCH 01/11] fix(ci): reject unsafe release dispatch tags --- .github/workflows/release.yml | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbc7f852..f574b889 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,12 +86,18 @@ jobs: run: | set -euo pipefail TAG="${DISPATCH_TAG:-$RELEASE_TAG}" + case "$TAG" in + ""|*[!A-Za-z0-9._-]*) + echo "::error::release tag is empty or contains invalid characters" + exit 1 + ;; + esac case "$TAG" in v[0-9]*) ;; *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; esac - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + printf '%s\n' "tag=$TAG" >> "$GITHUB_OUTPUT" + printf '%s\n' "version=${TAG#v}" >> "$GITHUB_OUTPUT" # ghcr requires a lowercase repository path, and unlike metadata-action, # buildx's `--output name=` does no lowercasing — a mixed-case owner # makes the digest push fail with "invalid reference format". @@ -183,12 +189,18 @@ jobs: run: | set -euo pipefail TAG="${DISPATCH_TAG:-$RELEASE_TAG}" + case "$TAG" in + ""|*[!A-Za-z0-9._-]*) + echo "::error::release tag is empty or contains invalid characters" + exit 1 + ;; + esac case "$TAG" in v[0-9]*) ;; *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; esac - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + printf '%s\n' "tag=$TAG" >> "$GITHUB_OUTPUT" + printf '%s\n' "version=${TAG#v}" >> "$GITHUB_OUTPUT" - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -412,12 +424,18 @@ jobs: run: | set -euo pipefail TAG="${DISPATCH_TAG:-$RELEASE_TAG}" + case "$TAG" in + ""|*[!A-Za-z0-9._-]*) + echo "::error::release tag is empty or contains invalid characters" + exit 1 + ;; + esac case "$TAG" in v[0-9]*) ;; *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; esac - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + printf '%s\n' "tag=$TAG" >> "$GITHUB_OUTPUT" + printf '%s\n' "version=${TAG#v}" >> "$GITHUB_OUTPUT" - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 From 64e228f26d0ad1031eb0f3462b73b1974992cdd7 Mon Sep 17 00:00:00 2001 From: mike Date: Sat, 25 Jul 2026 23:31:56 -0400 Subject: [PATCH 02/11] fix(ci): add tested release tag resolver --- .github/workflows/pr-checks.yml | 13 +++++++ .github/workflows/release.yml | 58 ++++++++++------------------- scripts/resolve-release-tag.sh | 23 ++++++++++++ scripts/test-resolve-release-tag.sh | 36 ++++++++++++++++++ 4 files changed, 91 insertions(+), 39 deletions(-) create mode 100755 scripts/resolve-release-tag.sh create mode 100755 scripts/test-resolve-release-tag.sh diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d08c163e..6fbd17dc 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -48,6 +48,19 @@ jobs: - name: cargo clippy run: cargo clippy --workspace --all-targets -- -D warnings + release-tag-validation: + name: release tag validation + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Test release tag resolver + run: scripts/test-resolve-release-tag.sh + test: name: test (${{ matrix.toolchain }}) runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f574b889..0d8293e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,11 @@ jobs: contents: read packages: write steps: + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Resolve release tag id: rel env: @@ -85,19 +90,7 @@ jobs: RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail - TAG="${DISPATCH_TAG:-$RELEASE_TAG}" - case "$TAG" in - ""|*[!A-Za-z0-9._-]*) - echo "::error::release tag is empty or contains invalid characters" - exit 1 - ;; - esac - case "$TAG" in - v[0-9]*) ;; - *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; - esac - printf '%s\n' "tag=$TAG" >> "$GITHUB_OUTPUT" - printf '%s\n' "version=${TAG#v}" >> "$GITHUB_OUTPUT" + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" # ghcr requires a lowercase repository path, and unlike metadata-action, # buildx's `--output name=` does no lowercasing — a mixed-case owner # makes the digest push fail with "invalid reference format". @@ -179,8 +172,14 @@ jobs: if: ${{ !cancelled() && needs.docker.result == 'success' }} runs-on: ubuntu-latest permissions: + contents: read packages: write steps: + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Resolve release tag id: rel env: @@ -188,19 +187,7 @@ jobs: RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail - TAG="${DISPATCH_TAG:-$RELEASE_TAG}" - case "$TAG" in - ""|*[!A-Za-z0-9._-]*) - echo "::error::release tag is empty or contains invalid characters" - exit 1 - ;; - esac - case "$TAG" in - v[0-9]*) ;; - *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; - esac - printf '%s\n' "tag=$TAG" >> "$GITHUB_OUTPUT" - printf '%s\n' "version=${TAG#v}" >> "$GITHUB_OUTPUT" + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -416,6 +403,11 @@ jobs: contents: read id-token: write # npm trusted publishing (OIDC) + provenance steps: + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Resolve release tag id: rel env: @@ -423,19 +415,7 @@ jobs: RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail - TAG="${DISPATCH_TAG:-$RELEASE_TAG}" - case "$TAG" in - ""|*[!A-Za-z0-9._-]*) - echo "::error::release tag is empty or contains invalid characters" - exit 1 - ;; - esac - case "$TAG" in - v[0-9]*) ;; - *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; - esac - printf '%s\n' "tag=$TAG" >> "$GITHUB_OUTPUT" - printf '%s\n' "version=${TAG#v}" >> "$GITHUB_OUTPUT" + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh new file mode 100755 index 00000000..11996ffc --- /dev/null +++ b/scripts/resolve-release-tag.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag="${1-}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" + +case "$tag" in + ""|*[!A-Za-z0-9._-]*) + printf '%s\n' "::error::release tag is empty or contains invalid characters" >&2 + exit 1 + ;; +esac + +case "$tag" in + v[0-9]*) ;; + *) + printf '%s\n' "::error::'$tag' does not look like a release tag (vX.Y.Z)" >&2 + exit 1 + ;; +esac + +printf '%s\n' "tag=$tag" >> "$GITHUB_OUTPUT" +printf '%s\n' "version=${tag#v}" >> "$GITHUB_OUTPUT" diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh new file mode 100755 index 00000000..cbf8c784 --- /dev/null +++ b/scripts/test-resolve-release-tag.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +resolver="$repo_root/scripts/resolve-release-tag.sh" +test_tmp="$(mktemp -d)" +trap 'rm -r -- "$test_tmp"' EXIT + +valid_output="$test_tmp/valid-output" +GITHUB_OUTPUT="$valid_output" "$resolver" "v1.2.3" + +expected_output="$test_tmp/expected-output" +printf '%s\n' "tag=v1.2.3" "version=1.2.3" > "$expected_output" +cmp "$expected_output" "$valid_output" + +newline_output="$test_tmp/newline-output" +if GITHUB_OUTPUT="$newline_output" "$resolver" $'v1.2.3\nname=owned'; then + printf '%s\n' "newline-containing release tag unexpectedly passed" >&2 + exit 1 +fi +test ! -s "$newline_output" + +empty_output="$test_tmp/empty-output" +if GITHUB_OUTPUT="$empty_output" "$resolver" ""; then + printf '%s\n' "empty release tag unexpectedly passed" >&2 + exit 1 +fi +test ! -s "$empty_output" + +resolver_count="$(grep -c 'scripts/resolve-release-tag.sh' "$repo_root/.github/workflows/release.yml")" +if [[ "$resolver_count" -ne 3 ]]; then + printf '%s\n' "expected all 3 release tag steps to use the tested resolver; found $resolver_count" >&2 + exit 1 +fi + +printf '%s\n' "release tag resolver tests passed" From a6480bdc3854d9cd524e6571659208b697dc9369 Mon Sep 17 00:00:00 2001 From: mike Date: Sat, 25 Jul 2026 23:36:00 -0400 Subject: [PATCH 03/11] test(ci): cover remaining invalid release tags --- scripts/test-resolve-release-tag.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index cbf8c784..de4204db 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -27,6 +27,16 @@ if GITHUB_OUTPUT="$empty_output" "$resolver" ""; then fi test ! -s "$empty_output" +invalid_output="$test_tmp/invalid-output" +for invalid_tag in "v1.2.3 tag" "v1.2.3;name=owned" "release-1.2.3"; do + : > "$invalid_output" + if GITHUB_OUTPUT="$invalid_output" "$resolver" "$invalid_tag"; then + printf '%s\n' "invalid release tag unexpectedly passed: $invalid_tag" >&2 + exit 1 + fi + test ! -s "$invalid_output" +done + resolver_count="$(grep -c 'scripts/resolve-release-tag.sh' "$repo_root/.github/workflows/release.yml")" if [[ "$resolver_count" -ne 3 ]]; then printf '%s\n' "expected all 3 release tag steps to use the tested resolver; found $resolver_count" >&2 From c00eaa20be3bd88e2270b4995e01ab2d57b849eb Mon Sep 17 00:00:00 2001 From: mike Date: Sun, 26 Jul 2026 17:59:37 -0400 Subject: [PATCH 04/11] test(ci): enforce release tag invariants --- scripts/resolve-release-tag.sh | 6 ++-- scripts/test-resolve-release-tag.sh | 44 +++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index 11996ffc..b14aebc7 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -5,8 +5,8 @@ tag="${1-}" : "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" case "$tag" in - ""|*[!A-Za-z0-9._-]*) - printf '%s\n' "::error::release tag is empty or contains invalid characters" >&2 + ""|*[!A-Za-z0-9._+-]*) + printf '%s\n' "::error::release tag is empty or contains invalid characters" exit 1 ;; esac @@ -14,7 +14,7 @@ esac case "$tag" in v[0-9]*) ;; *) - printf '%s\n' "::error::'$tag' does not look like a release tag (vX.Y.Z)" >&2 + printf '%s\n' "::error::'$tag' does not look like a release tag (vX.Y.Z)" exit 1 ;; esac diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index de4204db..d23acd21 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -13,12 +13,25 @@ expected_output="$test_tmp/expected-output" printf '%s\n' "tag=v1.2.3" "version=1.2.3" > "$expected_output" cmp "$expected_output" "$valid_output" +build_output="$test_tmp/build-output" +GITHUB_OUTPUT="$build_output" "$resolver" "v1.2.3+build.5" + +expected_build_output="$test_tmp/expected-build-output" +printf '%s\n' "tag=v1.2.3+build.5" "version=1.2.3+build.5" > "$expected_build_output" +cmp "$expected_build_output" "$build_output" + newline_output="$test_tmp/newline-output" -if GITHUB_OUTPUT="$newline_output" "$resolver" $'v1.2.3\nname=owned'; then +newline_stdout="$test_tmp/newline-stdout" +newline_stderr="$test_tmp/newline-stderr" +if GITHUB_OUTPUT="$newline_output" "$resolver" $'v1.2.3\nname=owned' \ + > "$newline_stdout" 2> "$newline_stderr" +then printf '%s\n' "newline-containing release tag unexpectedly passed" >&2 exit 1 fi test ! -s "$newline_output" +grep -qxF "::error::release tag is empty or contains invalid characters" "$newline_stdout" +test ! -s "$newline_stderr" empty_output="$test_tmp/empty-output" if GITHUB_OUTPUT="$empty_output" "$resolver" ""; then @@ -28,7 +41,16 @@ fi test ! -s "$empty_output" invalid_output="$test_tmp/invalid-output" -for invalid_tag in "v1.2.3 tag" "v1.2.3;name=owned" "release-1.2.3"; do +for invalid_tag in \ + "v1.2.3 tag" \ + "v1.2.3;name=owned" \ + "release-1.2.3" \ + "v-1" \ + "vlatest" \ + "v" \ + "v1.2.3/../../x" \ + "V1.2.3" +do : > "$invalid_output" if GITHUB_OUTPUT="$invalid_output" "$resolver" "$invalid_tag"; then printf '%s\n' "invalid release tag unexpectedly passed: $invalid_tag" >&2 @@ -37,9 +59,21 @@ for invalid_tag in "v1.2.3 tag" "v1.2.3;name=owned" "release-1.2.3"; do test ! -s "$invalid_output" done -resolver_count="$(grep -c 'scripts/resolve-release-tag.sh' "$repo_root/.github/workflows/release.yml")" -if [[ "$resolver_count" -ne 3 ]]; then - printf '%s\n' "expected all 3 release tag steps to use the tested resolver; found $resolver_count" >&2 +workflows="$repo_root/.github/workflows" +steps="$(grep -c 'name: Resolve release tag' "$workflows/release.yml" || true)" +calls="$(grep -cE '^[[:space:]]*scripts/resolve-release-tag\.sh ' "$workflows/release.yml" || true)" + +if [[ "$steps" -lt 1 || "$steps" -ne "$calls" ]]; then + printf '%s\n' \ + "every 'Resolve release tag' step must call the tested resolver; steps=$steps calls=$calls" \ + >&2 + exit 1 +fi + +if grep -rqE '(echo|printf)[^|]*(tag|version)=.*>>[[:space:]]*"?\$GITHUB_OUTPUT' "$workflows"; then + printf '%s\n' "a workflow writes a tag/version output without the tested resolver:" >&2 + grep -rnE '(echo|printf)[^|]*(tag|version)=.*>>[[:space:]]*"?\$GITHUB_OUTPUT' \ + "$workflows" >&2 exit 1 fi From 1975d29953080aa4e100331936b8447739e0533c Mon Sep 17 00:00:00 2001 From: mike Date: Mon, 27 Jul 2026 07:37:45 -0400 Subject: [PATCH 05/11] test(ci): harden release tag drift guards --- scripts/resolve-release-tag.sh | 2 +- scripts/test-resolve-release-tag.sh | 142 ++++++++++++++++++++++++---- 2 files changed, 125 insertions(+), 19 deletions(-) diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index b14aebc7..3d321da5 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -5,7 +5,7 @@ tag="${1-}" : "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" case "$tag" in - ""|*[!A-Za-z0-9._+-]*) + ""|*[!A-Za-z0-9._-]*) printf '%s\n' "::error::release tag is empty or contains invalid characters" exit 1 ;; diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index d23acd21..6f5c0f21 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -13,13 +13,6 @@ expected_output="$test_tmp/expected-output" printf '%s\n' "tag=v1.2.3" "version=1.2.3" > "$expected_output" cmp "$expected_output" "$valid_output" -build_output="$test_tmp/build-output" -GITHUB_OUTPUT="$build_output" "$resolver" "v1.2.3+build.5" - -expected_build_output="$test_tmp/expected-build-output" -printf '%s\n' "tag=v1.2.3+build.5" "version=1.2.3+build.5" > "$expected_build_output" -cmp "$expected_build_output" "$build_output" - newline_output="$test_tmp/newline-output" newline_stdout="$test_tmp/newline-stdout" newline_stderr="$test_tmp/newline-stderr" @@ -44,6 +37,7 @@ invalid_output="$test_tmp/invalid-output" for invalid_tag in \ "v1.2.3 tag" \ "v1.2.3;name=owned" \ + "v1.2.3+build.5" \ "release-1.2.3" \ "v-1" \ "vlatest" \ @@ -59,21 +53,133 @@ do test ! -s "$invalid_output" done -workflows="$repo_root/.github/workflows" -steps="$(grep -c 'name: Resolve release tag' "$workflows/release.yml" || true)" -calls="$(grep -cE '^[[:space:]]*scripts/resolve-release-tag\.sh ' "$workflows/release.yml" || true)" +release_workflow="$repo_root/.github/workflows/release.yml" +resolver_steps="$test_tmp/resolver-steps" + +check_resolver_steps() { + local workflow_file="$1" + local steps_file="$2" + local resolver_call_re='(^|[[:space:]])scripts/resolve-release-tag\.sh([[:space:]]|$)' + local direct_output_re='(echo|printf).*(tag|version)=.*>>?[[:space:]]*"?\$(GITHUB_OUTPUT|GITHUB_ENV)' + local resolver_line + local resolver_step + local resolver_step_count=0 + + awk ' + function indentation(line, spaces) { + spaces = line + sub(/[^ ].*$/, "", spaces) + return length(spaces) + } + + function emit_step() { + if (!in_resolver_step) { + return + } + + gsub(/[[:space:]]+/, " ", resolver_step) + sub(/^ /, "", resolver_step) + sub(/ $/, "", resolver_step) + printf "%d\t%s\n", resolver_line, resolver_step + in_resolver_step = 0 + resolver_step = "" + } + + /^[[:space:]]*- name:[[:space:]]*Resolve release tag[[:space:]]*$/ { + emit_step() + in_resolver_step = 1 + resolver_indent = indentation($0) + resolver_line = NR + } + + { + if (in_resolver_step && NR != resolver_line && + $0 ~ /^[[:space:]]*-[[:space:]]/ && + indentation($0) == resolver_indent) { + emit_step() + } + + if (in_resolver_step) { + resolver_step = resolver_step " " $0 + } + } + + END { + emit_step() + } + ' "$workflow_file" > "$steps_file" + + while IFS=$'\t' read -r resolver_line resolver_step; do + resolver_step_count=$((resolver_step_count + 1)) -if [[ "$steps" -lt 1 || "$steps" -ne "$calls" ]]; then - printf '%s\n' \ - "every 'Resolve release tag' step must call the tested resolver; steps=$steps calls=$calls" \ - >&2 + if [[ ! "$resolver_step" =~ $resolver_call_re ]]; then + printf '%s\n' \ + "'Resolve release tag' step at ${workflow_file##*/}:$resolver_line does not call the tested resolver" \ + >&2 + return 1 + fi + + if [[ "$resolver_step" =~ $direct_output_re ]]; then + printf '%s\n' \ + "'Resolve release tag' step at ${workflow_file##*/}:$resolver_line writes tag/version outputs directly" \ + >&2 + return 1 + fi + done < "$steps_file" + + if [[ "$resolver_step_count" -lt 1 ]]; then + printf '%s\n' "${workflow_file##*/} has no 'Resolve release tag' steps" >&2 + return 1 + fi +} + +check_resolver_steps "$release_workflow" "$resolver_steps" + +bypass_workflow="$test_tmp/release-bypass.yml" +awk ' + /scripts\/resolve-release-tag\.sh/ { + resolver_calls++ + if (resolver_calls == 2) { + print " {" + print " echo \"tag=ghcr.io/attacker/evil\"" + print " echo \"version=9.9.9\"" + print " } >> \"$GITHUB_OUTPUT\"" + next + } + } + + /^[[:space:]]*- name: Download digests[[:space:]]*$/ { + print " - name: Decoy resolver call" + print " run: scripts/resolve-release-tag.sh \"$TAG\"" + print "" + } + + { print } +' "$release_workflow" > "$bypass_workflow" + +if check_resolver_steps "$bypass_workflow" "$test_tmp/bypass-steps" 2> /dev/null; then + printf '%s\n' "per-step workflow guard missed a decoy-call output-injection bypass" >&2 exit 1 fi -if grep -rqE '(echo|printf)[^|]*(tag|version)=.*>>[[:space:]]*"?\$GITHUB_OUTPUT' "$workflows"; then - printf '%s\n' "a workflow writes a tag/version output without the tested resolver:" >&2 - grep -rnE '(echo|printf)[^|]*(tag|version)=.*>>[[:space:]]*"?\$GITHUB_OUTPUT' \ - "$workflows" >&2 +direct_output_workflow="$test_tmp/release-direct-output.yml" +awk ' + { + print + if (!injected && $0 ~ /scripts\/resolve-release-tag\.sh/) { + print " {" + print " echo \"tag=ghcr.io/attacker/evil\"" + print " echo \"version=9.9.9\"" + print " } >> \"$GITHUB_OUTPUT\"" + injected = 1 + } + } +' "$release_workflow" > "$direct_output_workflow" + +if check_resolver_steps \ + "$direct_output_workflow" "$test_tmp/direct-output-steps" 2> /dev/null +then + printf '%s\n' "per-step workflow guard missed a multiline direct output write" >&2 exit 1 fi From 73a6a0ac6da814a09a1efea293c457c5d9a1c62f Mon Sep 17 00:00:00 2001 From: mike Date: Mon, 27 Jul 2026 07:47:25 -0400 Subject: [PATCH 06/11] test(ci): stabilize release bypass fixture --- scripts/test-resolve-release-tag.sh | 31 +++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 6f5c0f21..6ec2bec4 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -137,24 +137,35 @@ check_resolver_steps "$release_workflow" "$resolver_steps" bypass_workflow="$test_tmp/release-bypass.yml" awk ' - /scripts\/resolve-release-tag\.sh/ { - resolver_calls++ - if (resolver_calls == 2) { - print " {" - print " echo \"tag=ghcr.io/attacker/evil\"" - print " echo \"version=9.9.9\"" - print " } >> \"$GITHUB_OUTPUT\"" - next - } + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + in_docker_manifest = ($0 ~ /^ docker-manifest:/) } - /^[[:space:]]*- name: Download digests[[:space:]]*$/ { + in_docker_manifest && !replaced && /scripts\/resolve-release-tag\.sh/ { + print " {" + print " echo \"tag=ghcr.io/attacker/evil\"" + print " echo \"version=9.9.9\"" + print " } >> \"$GITHUB_OUTPUT\"" + replaced = 1 + next + } + + in_docker_manifest && replaced && !inserted_decoy && + /^ -[[:space:]]/ { print " - name: Decoy resolver call" print " run: scripts/resolve-release-tag.sh \"$TAG\"" print "" + inserted_decoy = 1 } { print } + + END { + if (!replaced || !inserted_decoy) { + print "failed to build docker-manifest bypass fixture" > "/dev/stderr" + exit 1 + } + } ' "$release_workflow" > "$bypass_workflow" if check_resolver_steps "$bypass_workflow" "$test_tmp/bypass-steps" 2> /dev/null; then From 509f79fc78c3c889893b92102630b415bd969ab0 Mon Sep 17 00:00:00 2001 From: mike Date: Tue, 28 Jul 2026 09:02:22 -0400 Subject: [PATCH 07/11] test(ci): harden release workflow guard --- scripts/test-resolve-release-tag.sh | 303 +++++++++++++++++++++++----- 1 file changed, 251 insertions(+), 52 deletions(-) diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 6ec2bec4..587d2901 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -59,48 +59,72 @@ resolver_steps="$test_tmp/resolver-steps" check_resolver_steps() { local workflow_file="$1" local steps_file="$2" - local resolver_call_re='(^|[[:space:]])scripts/resolve-release-tag\.sh([[:space:]]|$)' - local direct_output_re='(echo|printf).*(tag|version)=.*>>?[[:space:]]*"?\$(GITHUB_OUTPUT|GITHUB_ENV)' - local resolver_line - local resolver_step - local resolver_step_count=0 + local job + local step_line + local step_name + local has_resolver_call + local has_output_sink + local has_tag_assignment + local expected_job + local is_expected_job + local -a expected_jobs=(docker docker-manifest npm-publish) + local -A resolver_step_counts=() awk ' - function indentation(line, spaces) { - spaces = line - sub(/[^ ].*$/, "", spaces) - return length(spaces) - } - function emit_step() { - if (!in_resolver_step) { + if (!in_step) { return } - gsub(/[[:space:]]+/, " ", resolver_step) - sub(/^ /, "", resolver_step) - sub(/ $/, "", resolver_step) - printf "%d\t%s\n", resolver_line, resolver_step - in_resolver_step = 0 - resolver_step = "" + printf "%s\t%d\t%s\t%d\t%d\t%d\n", + job, + step_line, + step_name, + has_resolver_call, + has_output_sink, + has_tag_assignment + in_step = 0 } - /^[[:space:]]*- name:[[:space:]]*Resolve release tag[[:space:]]*$/ { + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { emit_step() - in_resolver_step = 1 - resolver_indent = indentation($0) - resolver_line = NR + job = $0 + sub(/^ /, "", job) + sub(/:[[:space:]]*$/, "", job) + } + + /^ - / { + emit_step() + in_step = 1 + step_line = NR + step_name = "" + has_resolver_call = 0 + has_output_sink = 0 + has_tag_assignment = 0 + + if ($0 ~ /^ - name:[[:space:]]*/) { + step_name = $0 + sub(/^ - name:[[:space:]]*/, "", step_name) + sub(/[[:space:]]*$/, "", step_name) + } } - { - if (in_resolver_step && NR != resolver_line && - $0 ~ /^[[:space:]]*-[[:space:]]/ && - indentation($0) == resolver_indent) { - emit_step() + in_step { + code = $0 + sub(/^[[:space:]]*/, "", code) + + if (code !~ /^#/ && + code ~ /(^|[;&|({][[:space:]]*)scripts\/resolve-release-tag\.sh([[:space:];&|)}]|$)/) { + has_resolver_call = 1 + } + + if (code !~ /^#/ && code ~ /\$(GITHUB_OUTPUT|GITHUB_ENV)/) { + has_output_sink = 1 } - if (in_resolver_step) { - resolver_step = resolver_step " " $0 + if (code !~ /^#/ && + code ~ /(^|[^A-Za-z0-9_])(tag|version)=/) { + has_tag_assignment = 1 } } @@ -109,32 +133,74 @@ check_resolver_steps() { } ' "$workflow_file" > "$steps_file" - while IFS=$'\t' read -r resolver_line resolver_step; do - resolver_step_count=$((resolver_step_count + 1)) + while IFS=$'\t' read -r \ + job \ + step_line \ + step_name \ + has_resolver_call \ + has_output_sink \ + has_tag_assignment + do + is_expected_job=0 + for expected_job in "${expected_jobs[@]}"; do + if [[ "$job" == "$expected_job" ]]; then + is_expected_job=1 + break + fi + done + + if [[ "$step_name" == "Resolve release tag" ]]; then + if [[ "$is_expected_job" -ne 1 ]]; then + printf '%s\n' \ + "unexpected 'Resolve release tag' step in job '$job' at ${workflow_file##*/}:$step_line" \ + >&2 + return 1 + fi + + resolver_step_counts["$job"]=$(( ${resolver_step_counts["$job"]:-0} + 1 )) + + if [[ "$has_resolver_call" -ne 1 ]]; then + printf '%s\n' \ + "'Resolve release tag' step in job '$job' at ${workflow_file##*/}:$step_line does not invoke the tested resolver" \ + >&2 + return 1 + fi + fi - if [[ ! "$resolver_step" =~ $resolver_call_re ]]; then + if [[ "$is_expected_job" -eq 1 && + "$has_output_sink" -eq 1 && + "$has_tag_assignment" -eq 1 ]] + then printf '%s\n' \ - "'Resolve release tag' step at ${workflow_file##*/}:$resolver_line does not call the tested resolver" \ + "step '$step_name' in job '$job' at ${workflow_file##*/}:$step_line writes tag/version outputs directly" \ >&2 return 1 fi + done < "$steps_file" - if [[ "$resolver_step" =~ $direct_output_re ]]; then + for expected_job in "${expected_jobs[@]}"; do + if [[ "${resolver_step_counts["$expected_job"]:-0}" -ne 1 ]]; then printf '%s\n' \ - "'Resolve release tag' step at ${workflow_file##*/}:$resolver_line writes tag/version outputs directly" \ + "job '$expected_job' must contain exactly one guarded 'Resolve release tag' step; found ${resolver_step_counts["$expected_job"]:-0}" \ >&2 return 1 fi - done < "$steps_file" - - if [[ "$resolver_step_count" -lt 1 ]]; then - printf '%s\n' "${workflow_file##*/} has no 'Resolve release tag' steps" >&2 - return 1 - fi + done } check_resolver_steps "$release_workflow" "$resolver_steps" +assert_guard_rejects() { + local workflow_file="$1" + local steps_file="$2" + local failure_message="$3" + + if check_resolver_steps "$workflow_file" "$steps_file" 2> /dev/null; then + printf '%s\n' "$failure_message" >&2 + exit 1 + fi +} + bypass_workflow="$test_tmp/release-bypass.yml" awk ' /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { @@ -168,16 +234,21 @@ awk ' } ' "$release_workflow" > "$bypass_workflow" -if check_resolver_steps "$bypass_workflow" "$test_tmp/bypass-steps" 2> /dev/null; then - printf '%s\n' "per-step workflow guard missed a decoy-call output-injection bypass" >&2 - exit 1 -fi +assert_guard_rejects \ + "$bypass_workflow" \ + "$test_tmp/bypass-steps" \ + "workflow guard missed a decoy-call output-injection bypass" direct_output_workflow="$test_tmp/release-direct-output.yml" awk ' + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + in_docker_manifest = ($0 ~ /^ docker-manifest:/) + } + { print - if (!injected && $0 ~ /scripts\/resolve-release-tag\.sh/) { + if (in_docker_manifest && !injected && + /scripts\/resolve-release-tag\.sh/) { print " {" print " echo \"tag=ghcr.io/attacker/evil\"" print " echo \"version=9.9.9\"" @@ -185,13 +256,141 @@ awk ' injected = 1 } } + + END { + if (!injected) { + print "failed to build direct-output fixture" > "/dev/stderr" + exit 1 + } + } ' "$release_workflow" > "$direct_output_workflow" -if check_resolver_steps \ - "$direct_output_workflow" "$test_tmp/direct-output-steps" 2> /dev/null -then - printf '%s\n' "per-step workflow guard missed a multiline direct output write" >&2 - exit 1 -fi +assert_guard_rejects \ + "$direct_output_workflow" \ + "$test_tmp/direct-output-steps" \ + "workflow guard missed a multiline direct output write" + +commented_call_workflow="$test_tmp/release-commented-call.yml" +awk ' + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + in_docker_manifest = ($0 ~ /^ docker-manifest:/) + } + + in_docker_manifest && !commented && /scripts\/resolve-release-tag\.sh/ { + sub(/scripts\/resolve-release-tag\.sh/, "# scripts/resolve-release-tag.sh") + commented = 1 + } + + { print } + + END { + if (!commented) { + print "failed to build commented-call fixture" > "/dev/stderr" + exit 1 + } + } +' "$release_workflow" > "$commented_call_workflow" + +assert_guard_rejects \ + "$commented_call_workflow" \ + "$test_tmp/commented-call-steps" \ + "workflow guard counted a commented resolver reference as an invocation" + +tee_output_workflow="$test_tmp/release-tee-output.yml" +awk ' + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + in_docker_manifest = ($0 ~ /^ docker-manifest:/) + } + + { + print + if (in_docker_manifest && !injected && + /scripts\/resolve-release-tag\.sh/) { + print " printf \"%s\\n\" \"tag=ghcr.io/attacker/evil\" | tee -a \"$GITHUB_OUTPUT\" > /dev/null" + injected = 1 + } + } + + END { + if (!injected) { + print "failed to build tee-output fixture" > "/dev/stderr" + exit 1 + } + } +' "$release_workflow" > "$tee_output_workflow" + +assert_guard_rejects \ + "$tee_output_workflow" \ + "$test_tmp/tee-output-steps" \ + "workflow guard missed a tag output written through tee" + +heredoc_output_workflow="$test_tmp/release-heredoc-output.yml" +awk ' + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + in_docker_manifest = ($0 ~ /^ docker-manifest:/) + } + + { + print + if (in_docker_manifest && !injected && + /scripts\/resolve-release-tag\.sh/) { + print " cat >> \"$GITHUB_OUTPUT\" <<'\''EOF'\''" + print " tag=ghcr.io/attacker/evil" + print " version=9.9.9" + print " EOF" + injected = 1 + } + } + + END { + if (!injected) { + print "failed to build heredoc-output fixture" > "/dev/stderr" + exit 1 + } + } +' "$release_workflow" > "$heredoc_output_workflow" + +assert_guard_rejects \ + "$heredoc_output_workflow" \ + "$test_tmp/heredoc-output-steps" \ + "workflow guard missed tag/version outputs written through a heredoc" + +renamed_step_workflow="$test_tmp/release-renamed-step.yml" +awk ' + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + in_docker = ($0 ~ /^ docker:/) + in_resolver_step = 0 + } + + in_docker && !renamed && + /^ - name:[[:space:]]*Resolve release tag[[:space:]]*$/ { + print " - name: Inline release metadata" + renamed = 1 + in_resolver_step = 1 + next + } + + in_docker && in_resolver_step && !replaced && + /scripts\/resolve-release-tag\.sh/ { + print " echo \"tag=$TAG\" >> \"$GITHUB_OUTPUT\"" + print " echo \"version=${TAG#v}\" >> \"$GITHUB_OUTPUT\"" + replaced = 1 + next + } + + { print } + + END { + if (!renamed || !replaced) { + print "failed to build renamed-step fixture" > "/dev/stderr" + exit 1 + } + } +' "$release_workflow" > "$renamed_step_workflow" + +assert_guard_rejects \ + "$renamed_step_workflow" \ + "$test_tmp/renamed-step-steps" \ + "workflow guard missed a renamed and reverted resolver step" printf '%s\n' "release tag resolver tests passed" From 1fe33c7b9b94be5662b9cded703e169141e29a78 Mon Sep 17 00:00:00 2001 From: mike Date: Tue, 28 Jul 2026 09:07:01 -0400 Subject: [PATCH 08/11] test(ci): bind resolver job mutation --- scripts/test-resolve-release-tag.sh | 36 +++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 587d2901..67cf6bd7 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -359,19 +359,37 @@ renamed_step_workflow="$test_tmp/release-renamed-step.yml" awk ' /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { in_docker = ($0 ~ /^ docker:/) - in_resolver_step = 0 } in_docker && !renamed && /^ - name:[[:space:]]*Resolve release tag[[:space:]]*$/ { print " - name: Inline release metadata" renamed = 1 - in_resolver_step = 1 next } - in_docker && in_resolver_step && !replaced && - /scripts\/resolve-release-tag\.sh/ { + { print } + + END { + if (!renamed) { + print "failed to build renamed-step fixture" > "/dev/stderr" + exit 1 + } + } +' "$release_workflow" > "$renamed_step_workflow" + +assert_guard_rejects \ + "$renamed_step_workflow" \ + "$test_tmp/renamed-step-steps" \ + "workflow guard is not bound to every expected release job" + +renamed_reverted_workflow="$test_tmp/release-renamed-reverted.yml" +awk ' + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + in_docker = ($0 ~ /^ docker:/) + } + + in_docker && !replaced && /scripts\/resolve-release-tag\.sh/ { print " echo \"tag=$TAG\" >> \"$GITHUB_OUTPUT\"" print " echo \"version=${TAG#v}\" >> \"$GITHUB_OUTPUT\"" replaced = 1 @@ -381,16 +399,16 @@ awk ' { print } END { - if (!renamed || !replaced) { - print "failed to build renamed-step fixture" > "/dev/stderr" + if (!replaced) { + print "failed to build renamed-and-reverted fixture" > "/dev/stderr" exit 1 } } -' "$release_workflow" > "$renamed_step_workflow" +' "$renamed_step_workflow" > "$renamed_reverted_workflow" assert_guard_rejects \ - "$renamed_step_workflow" \ - "$test_tmp/renamed-step-steps" \ + "$renamed_reverted_workflow" \ + "$test_tmp/renamed-reverted-steps" \ "workflow guard missed a renamed and reverted resolver step" printf '%s\n' "release tag resolver tests passed" From d4ba70b68a5833598e4d0cb4c785d2fa4e2fe5c0 Mon Sep 17 00:00:00 2001 From: mike Date: Tue, 28 Jul 2026 18:57:38 -0400 Subject: [PATCH 09/11] test: pin release resolver steps --- scripts/test-resolve-release-tag.sh | 410 +++++----------------------- 1 file changed, 68 insertions(+), 342 deletions(-) diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 67cf6bd7..b3719ce2 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -54,361 +54,87 @@ do done release_workflow="$repo_root/.github/workflows/release.yml" -resolver_steps="$test_tmp/resolver-steps" +actual_resolver_steps="$test_tmp/actual-resolver-steps" +expected_resolver_steps="$test_tmp/expected-resolver-steps" -check_resolver_steps() { - local workflow_file="$1" - local steps_file="$2" - local job - local step_line - local step_name - local has_resolver_call - local has_output_sink - local has_tag_assignment - local expected_job - local is_expected_job - local -a expected_jobs=(docker docker-manifest npm-publish) - local -A resolver_step_counts=() - - awk ' - function emit_step() { - if (!in_step) { - return - } - - printf "%s\t%d\t%s\t%d\t%d\t%d\n", - job, - step_line, - step_name, - has_resolver_call, - has_output_sink, - has_tag_assignment - in_step = 0 - } - - /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { - emit_step() - job = $0 - sub(/^ /, "", job) - sub(/:[[:space:]]*$/, "", job) - } - - /^ - / { - emit_step() - in_step = 1 - step_line = NR - step_name = "" - has_resolver_call = 0 - has_output_sink = 0 - has_tag_assignment = 0 - - if ($0 ~ /^ - name:[[:space:]]*/) { - step_name = $0 - sub(/^ - name:[[:space:]]*/, "", step_name) - sub(/[[:space:]]*$/, "", step_name) - } - } - - in_step { - code = $0 - sub(/^[[:space:]]*/, "", code) - - if (code !~ /^#/ && - code ~ /(^|[;&|({][[:space:]]*)scripts\/resolve-release-tag\.sh([[:space:];&|)}]|$)/) { - has_resolver_call = 1 - } - - if (code !~ /^#/ && code ~ /\$(GITHUB_OUTPUT|GITHUB_ENV)/) { - has_output_sink = 1 - } - - if (code !~ /^#/ && - code ~ /(^|[^A-Za-z0-9_])(tag|version)=/) { - has_tag_assignment = 1 - } - } - - END { - emit_step() - } - ' "$workflow_file" > "$steps_file" - - while IFS=$'\t' read -r \ - job \ - step_line \ - step_name \ - has_resolver_call \ - has_output_sink \ - has_tag_assignment - do - is_expected_job=0 - for expected_job in "${expected_jobs[@]}"; do - if [[ "$job" == "$expected_job" ]]; then - is_expected_job=1 - break - fi - done - - if [[ "$step_name" == "Resolve release tag" ]]; then - if [[ "$is_expected_job" -ne 1 ]]; then - printf '%s\n' \ - "unexpected 'Resolve release tag' step in job '$job' at ${workflow_file##*/}:$step_line" \ - >&2 - return 1 - fi - - resolver_step_counts["$job"]=$(( ${resolver_step_counts["$job"]:-0} + 1 )) - - if [[ "$has_resolver_call" -ne 1 ]]; then - printf '%s\n' \ - "'Resolve release tag' step in job '$job' at ${workflow_file##*/}:$step_line does not invoke the tested resolver" \ - >&2 - return 1 - fi - fi - - if [[ "$is_expected_job" -eq 1 && - "$has_output_sink" -eq 1 && - "$has_tag_assignment" -eq 1 ]] - then - printf '%s\n' \ - "step '$step_name' in job '$job' at ${workflow_file##*/}:$step_line writes tag/version outputs directly" \ - >&2 - return 1 - fi - done < "$steps_file" - - for expected_job in "${expected_jobs[@]}"; do - if [[ "${resolver_step_counts["$expected_job"]:-0}" -ne 1 ]]; then - printf '%s\n' \ - "job '$expected_job' must contain exactly one guarded 'Resolve release tag' step; found ${resolver_step_counts["$expected_job"]:-0}" \ - >&2 - return 1 - fi - done -} - -check_resolver_steps "$release_workflow" "$resolver_steps" - -assert_guard_rejects() { - local workflow_file="$1" - local steps_file="$2" - local failure_message="$3" - - if check_resolver_steps "$workflow_file" "$steps_file" 2> /dev/null; then - printf '%s\n' "$failure_message" >&2 - exit 1 - fi -} - -bypass_workflow="$test_tmp/release-bypass.yml" -awk ' - /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { - in_docker_manifest = ($0 ~ /^ docker-manifest:/) - } - - in_docker_manifest && !replaced && /scripts\/resolve-release-tag\.sh/ { - print " {" - print " echo \"tag=ghcr.io/attacker/evil\"" - print " echo \"version=9.9.9\"" - print " } >> \"$GITHUB_OUTPUT\"" - replaced = 1 - next - } - - in_docker_manifest && replaced && !inserted_decoy && - /^ -[[:space:]]/ { - print " - name: Decoy resolver call" - print " run: scripts/resolve-release-tag.sh \"$TAG\"" - print "" - inserted_decoy = 1 - } - - { print } - - END { - if (!replaced || !inserted_decoy) { - print "failed to build docker-manifest bypass fixture" > "/dev/stderr" - exit 1 - } - } -' "$release_workflow" > "$bypass_workflow" - -assert_guard_rejects \ - "$bypass_workflow" \ - "$test_tmp/bypass-steps" \ - "workflow guard missed a decoy-call output-injection bypass" - -direct_output_workflow="$test_tmp/release-direct-output.yml" -awk ' - /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { - in_docker_manifest = ($0 ~ /^ docker-manifest:/) - } - - { - print - if (in_docker_manifest && !injected && - /scripts\/resolve-release-tag\.sh/) { - print " {" - print " echo \"tag=ghcr.io/attacker/evil\"" - print " echo \"version=9.9.9\"" - print " } >> \"$GITHUB_OUTPUT\"" - injected = 1 - } - } - - END { - if (!injected) { - print "failed to build direct-output fixture" > "/dev/stderr" - exit 1 - } - } -' "$release_workflow" > "$direct_output_workflow" - -assert_guard_rejects \ - "$direct_output_workflow" \ - "$test_tmp/direct-output-steps" \ - "workflow guard missed a multiline direct output write" - -commented_call_workflow="$test_tmp/release-commented-call.yml" -awk ' - /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { - in_docker_manifest = ($0 ~ /^ docker-manifest:/) - } - - in_docker_manifest && !commented && /scripts\/resolve-release-tag\.sh/ { - sub(/scripts\/resolve-release-tag\.sh/, "# scripts/resolve-release-tag.sh") - commented = 1 - } - - { print } - - END { - if (!commented) { - print "failed to build commented-call fixture" > "/dev/stderr" - exit 1 - } - } -' "$release_workflow" > "$commented_call_workflow" - -assert_guard_rejects \ - "$commented_call_workflow" \ - "$test_tmp/commented-call-steps" \ - "workflow guard counted a commented resolver reference as an invocation" - -tee_output_workflow="$test_tmp/release-tee-output.yml" -awk ' - /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { - in_docker_manifest = ($0 ~ /^ docker-manifest:/) - } - - { - print - if (in_docker_manifest && !injected && - /scripts\/resolve-release-tag\.sh/) { - print " printf \"%s\\n\" \"tag=ghcr.io/attacker/evil\" | tee -a \"$GITHUB_OUTPUT\" > /dev/null" - injected = 1 - } - } - - END { - if (!injected) { - print "failed to build tee-output fixture" > "/dev/stderr" - exit 1 - } - } -' "$release_workflow" > "$tee_output_workflow" - -assert_guard_rejects \ - "$tee_output_workflow" \ - "$test_tmp/tee-output-steps" \ - "workflow guard missed a tag output written through tee" - -heredoc_output_workflow="$test_tmp/release-heredoc-output.yml" +# Pin the complete `id: rel` step in each release job. Any change to one of +# these three blocks must be reviewed and reflected here deliberately. awk ' - /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { - in_docker_manifest = ($0 ~ /^ docker-manifest:/) - } - - { - print - if (in_docker_manifest && !injected && - /scripts\/resolve-release-tag\.sh/) { - print " cat >> \"$GITHUB_OUTPUT\" <<'\''EOF'\''" - print " tag=ghcr.io/attacker/evil" - print " version=9.9.9" - print " EOF" - injected = 1 - } - } - - END { - if (!injected) { - print "failed to build heredoc-output fixture" > "/dev/stderr" - exit 1 + function emit_step() { + if (in_step && is_rel) { + printf "job=%s\n%s", job, step } + in_step = 0 + is_rel = 0 + step = "" } -' "$release_workflow" > "$heredoc_output_workflow" -assert_guard_rejects \ - "$heredoc_output_workflow" \ - "$test_tmp/heredoc-output-steps" \ - "workflow guard missed tag/version outputs written through a heredoc" - -renamed_step_workflow="$test_tmp/release-renamed-step.yml" -awk ' /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { - in_docker = ($0 ~ /^ docker:/) - } - - in_docker && !renamed && - /^ - name:[[:space:]]*Resolve release tag[[:space:]]*$/ { - print " - name: Inline release metadata" - renamed = 1 + emit_step() + job = $0 + sub(/^ /, "", job) + sub(/:[[:space:]]*$/, "", job) next } - { print } - - END { - if (!renamed) { - print "failed to build renamed-step fixture" > "/dev/stderr" - exit 1 - } - } -' "$release_workflow" > "$renamed_step_workflow" - -assert_guard_rejects \ - "$renamed_step_workflow" \ - "$test_tmp/renamed-step-steps" \ - "workflow guard is not bound to every expected release job" - -renamed_reverted_workflow="$test_tmp/release-renamed-reverted.yml" -awk ' - /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { - in_docker = ($0 ~ /^ docker:/) - } - - in_docker && !replaced && /scripts\/resolve-release-tag\.sh/ { - print " echo \"tag=$TAG\" >> \"$GITHUB_OUTPUT\"" - print " echo \"version=${TAG#v}\" >> \"$GITHUB_OUTPUT\"" - replaced = 1 + /^ - / { + emit_step() + in_step = 1 + step = $0 ORS next } - { print } - - END { - if (!replaced) { - print "failed to build renamed-and-reverted fixture" > "/dev/stderr" - exit 1 + in_step && !/^[[:space:]]*$/ { + step = step $0 ORS + if ($0 ~ /^ id:[[:space:]]*rel[[:space:]]*$/) { + is_rel = 1 } } -' "$renamed_step_workflow" > "$renamed_reverted_workflow" -assert_guard_rejects \ - "$renamed_reverted_workflow" \ - "$test_tmp/renamed-reverted-steps" \ - "workflow guard missed a renamed and reverted resolver step" + END { + emit_step() + } +' "$release_workflow" > "$actual_resolver_steps" + +cat > "$expected_resolver_steps" <<'EOF' +job=docker + - name: Resolve release tag + id: rel + env: + DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" + # ghcr requires a lowercase repository path, and unlike metadata-action, + # buildx's `--output name=` does no lowercasing — a mixed-case owner + # makes the digest push fail with "invalid reference format". + echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" +job=docker-manifest + - name: Resolve release tag + id: rel + env: + DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" +job=npm-publish + - name: Resolve release tag + id: rel + env: + DISPATCH_TAG: ${{ inputs.npm_backfill_tag }} + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" +EOF + +if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then + printf '%s\n' \ + "release workflow resolver steps differ from the three reviewed blocks" >&2 + diff -u "$expected_resolver_steps" "$actual_resolver_steps" >&2 || true + exit 1 +fi -printf '%s\n' "release tag resolver tests passed" +printf '%s\n' "release tag validation tests passed" From b0c185fc7b37e1ec0933f17345c60bd4b5b1b9ef Mon Sep 17 00:00:00 2001 From: mike Date: Tue, 28 Jul 2026 19:01:27 -0400 Subject: [PATCH 10/11] test: snapshot resolver whitespace --- scripts/test-resolve-release-tag.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index b3719ce2..9f51b850 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -84,7 +84,7 @@ awk ' next } - in_step && !/^[[:space:]]*$/ { + in_step { step = step $0 ORS if ($0 ~ /^ id:[[:space:]]*rel[[:space:]]*$/) { is_rel = 1 @@ -110,6 +110,7 @@ job=docker # buildx's `--output name=` does no lowercasing — a mixed-case owner # makes the digest push fail with "invalid reference format". echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + job=docker-manifest - name: Resolve release tag id: rel @@ -119,6 +120,7 @@ job=docker-manifest run: | set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" + job=npm-publish - name: Resolve release tag id: rel @@ -128,6 +130,7 @@ job=npm-publish run: | set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" + EOF if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then From 89e17e05ae7740368cbe9d66456cc5d5bd32a654 Mon Sep 17 00:00:00 2001 From: mike Date: Tue, 28 Jul 2026 22:24:00 -0400 Subject: [PATCH 11/11] test(ci): pin release resolver checkouts --- scripts/test-resolve-release-tag.sh | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 9f51b850..fc8b34ba 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -57,15 +57,16 @@ release_workflow="$repo_root/.github/workflows/release.yml" actual_resolver_steps="$test_tmp/actual-resolver-steps" expected_resolver_steps="$test_tmp/expected-resolver-steps" -# Pin the complete `id: rel` step in each release job. Any change to one of -# these three blocks must be reviewed and reflected here deliberately. +# Pin each resolver step and the checkout step that supplies its script. Any +# change to one of these reviewed blocks must be reflected here deliberately. awk ' function emit_step() { - if (in_step && is_rel) { + if (in_step && (is_rel || is_workflow_scripts_checkout)) { printf "job=%s\n%s", job, step } in_step = 0 is_rel = 0 + is_workflow_scripts_checkout = 0 step = "" } @@ -80,6 +81,7 @@ awk ' /^ - / { emit_step() in_step = 1 + is_workflow_scripts_checkout = ($0 ~ /^ - name:[[:space:]]*Check out workflow scripts[[:space:]]*$/) step = $0 ORS next } @@ -97,6 +99,12 @@ awk ' ' "$release_workflow" > "$actual_resolver_steps" cat > "$expected_resolver_steps" <<'EOF' +job=docker + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + job=docker - name: Resolve release tag id: rel @@ -111,6 +119,12 @@ job=docker # makes the digest push fail with "invalid reference format". echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" +job=docker-manifest + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + job=docker-manifest - name: Resolve release tag id: rel @@ -121,6 +135,12 @@ job=docker-manifest set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" +job=npm-publish + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + job=npm-publish - name: Resolve release tag id: rel