From 65140370c3b78d4e1f52db638163ad81d97bab1e Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:29:05 -0500 Subject: [PATCH 01/23] ci(release): gate publish jobs on the release environment, anchor the tag check, pin npm Closes #234. workflow_dispatch runs the selected ref's YAML and npm trusted publishing matches on the workflow filename, not the ref, so a dispatched non-main ref could reach the OIDC publish with no approval. Declare environment: release on all three publish jobs (npm-publish, docker, docker-manifest) so the environment's deployment-branch rule gates every publish surface, not just the OIDC one. Also close the two residuals the issue carries: the release-tag case glob was right-unanchored (accepted v1.2.3.4.5, v1_x, v1-any-branch-name, bare v1), now an extglob v+([0-9]).+([0-9]).+([0-9]); and the OIDC publish ran npm@^11.5.1, a floating range, now pinned to npm@11.19.1. The environment itself must exist with a deployment-branch rule limited to main (and required reviewers if desired) for the gate to bite; npmjs.com can also scope each package's trusted publisher to environment "release" for a registry-side check that survives a workflow edit. --- .github/workflows/release.yml | 5 ++- scripts/resolve-release-tag.sh | 6 +++- scripts/test-resolve-release-tag.sh | 56 ++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32875dc8..e8aa9288 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -215,6 +215,7 @@ jobs: permissions: contents: read packages: write + environment: release steps: - name: Check out workflow scripts uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -312,6 +313,7 @@ jobs: permissions: contents: read packages: write + environment: release steps: - name: Check out workflow scripts uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -540,6 +542,7 @@ jobs: permissions: contents: read id-token: write # npm trusted publishing (OIDC) + provenance + environment: release steps: - name: Check out workflow scripts uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -571,7 +574,7 @@ jobs: - name: Ensure npm supports trusted publishing run: | set -euo pipefail - npm install -g npm@^11.5.1 + npm install -g npm@11.19.1 npm --version - name: Lay in release binaries diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index 3d321da5..98a219d4 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +shopt -s extglob tag="${1-}" : "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" @@ -11,8 +12,11 @@ case "$tag" in ;; esac +# Right-anchored on purpose: v[0-9]* leaves the tail unbounded and accepts +# v1.2.3.4.5, v1_x, v1-any-branch-name, and bare v1. Every existing tag is +# plain vX.Y.Z (release-please, release-type: simple, no prerelease channel). case "$tag" in - v[0-9]*) ;; + v+([0-9]).+([0-9]).+([0-9])) ;; *) printf '%s\n' "::error::'$tag' does not look like a release tag (vX.Y.Z)" exit 1 diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index fc8b34ba..36615225 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -43,7 +43,14 @@ for invalid_tag in \ "vlatest" \ "v" \ "v1.2.3/../../x" \ - "V1.2.3" + "V1.2.3" \ + "v1atest" \ + "v1.2.3.4.5" \ + "v1_x" \ + "v1-any-branch-name" \ + "v1" \ + "v1.2." \ + "v1..3" do : > "$invalid_output" if GITHUB_OUTPUT="$invalid_output" "$resolver" "$invalid_tag"; then @@ -160,4 +167,51 @@ if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then exit 1 fi +# Every job that publishes to an external registry must declare the protected +# environment that gates it. workflow_dispatch runs the selected ref's YAML, so +# the environment's deployment-branch rule is the control that keeps a dispatch +# from a non-main ref out of npm and ghcr. +actual_job_environments="$test_tmp/actual-job-environments" +awk ' + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + job = $0 + sub(/^ /, "", job) + sub(/:[[:space:]]*$/, "", job) + } + /^ environment:[[:space:]]*/ { + env = $0 + sub(/^ environment:[[:space:]]*/, "", env) + gsub(/[[:space:]]/, "", env) + print job "=" env + } +' "$release_workflow" > "$actual_job_environments" + +expected_job_environments="$test_tmp/expected-job-environments" +cat > "$expected_job_environments" <<'EOF' +docker=release +docker-manifest=release +npm-publish=release +EOF + +if ! cmp "$expected_job_environments" "$actual_job_environments"; then + printf '%s\n' \ + "release workflow publish jobs differ on their environment gate" >&2 + diff -u "$expected_job_environments" "$actual_job_environments" >&2 || true + exit 1 +fi + +# The OIDC publish path must run on an exact npm version, not a range. A range +# operator resolves to whatever the registry serves that day, which is the same +# mutable-dependency shape the action pins exist to prevent. +npm_install_specs="$test_tmp/npm-install-specs" +grep -o 'npm install -g npm@[^ "]*' "$release_workflow" | sort -u \ + > "$npm_install_specs" +while IFS= read -r spec; do + if ! grep -qE '^npm install -g npm@[0-9]+\.[0-9]+\.[0-9]+$' <<<"$spec"; then + printf '%s\n' \ + "npm install spec is not an exact pinned version: $spec" >&2 + exit 1 + fi +done < "$npm_install_specs" + printf '%s\n' "release tag validation tests passed" From 7ef1b9cb8d8d7257ecae819cf26410acf4c405b1 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:40:20 -0500 Subject: [PATCH 02/23] ci(release): qualify tag checkouts and keep moving tags forward-only Closes #271, closes #238. actions/checkout resolves an unqualified ref as a branch before a tag, so a branch named v0.7.1 shadows the tag and the release builds unreviewed content. Qualify all four release-tag checkouts as refs/tags/..., which takes checkout's unconditional tag path. docker-manifest applied :latest and :X.Y unconditionally, so a backfill of an older tag repointed them at older code. The manifest now always publishes the immutable :X.Y.Z and applies the moving tags only when the resolved version is not older than the registry's latest release. The same shape existed on the npm side: publish moves the latest dist-tag to whatever it publishes, so a backfilled older version would have stolen it; older backfills now publish under the backfill dist-tag instead. The manifest also asserted nothing about the digest set it stitches, so a wrong-arity or misnamed artifact would ship under :latest. Assert two digests of 64 lowercase hex chars before imagetools create. Extend the resolver test to pin both publish steps byte-for-byte and to reject any unqualified tag-checkout ref, so these guards cannot regress silently. --- .github/workflows/release.yml | 64 +++++++++++++---- scripts/test-resolve-release-tag.sh | 103 +++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8aa9288..555abbf7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -238,7 +238,7 @@ jobs: - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ steps.rel.outputs.tag }} + ref: refs/tags/${{ steps.rel.outputs.tag }} persist-credentials: false - name: Set up Docker Buildx @@ -349,21 +349,51 @@ jobs: - name: Create and push multi-arch manifest env: VERSION: ${{ steps.rel.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail # ghcr requires a lowercase repository path. IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" MAJOR_MINOR="${VERSION%.*}" + # The docker matrix pushes one digest per arch leg; anything else is + # a broken set, not a smaller multi-arch image. + count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)" + if [ "$count" -ne 2 ]; then + echo "::error::expected 2 arch digests in /tmp/digests, found $count" + exit 1 + fi digests="" for f in /tmp/digests/*; do - digests="$digests $IMAGE@sha256:$(basename "$f")" + d="$(basename "$f")" + case "$d" in + *[!0-9a-f]*) + echo "::error::digest filename is not lowercase sha256 hex: $d" + exit 1 + ;; + esac + if [ "${#d}" -ne 64 ]; then + echo "::error::digest is not 64 hex chars: $d" + exit 1 + fi + digests="$digests $IMAGE@sha256:$d" done + # The immutable tag always publishes. The moving tags only advance: + # a backfill of anything but the newest release leaves :latest and + # :X.Y pointing at the newer image. # shellcheck disable=SC2086 - docker buildx imagetools create \ - -t "$IMAGE:$VERSION" \ - -t "$IMAGE:$MAJOR_MINOR" \ - -t "$IMAGE:latest" \ - $digests + docker buildx imagetools create -t "$IMAGE:$VERSION" $digests + latest_tag="$(gh release view --json tagName -q .tagName)" + latest_version="${latest_tag#v}" + newest="$(printf '%s\n%s\n' "$latest_version" "$VERSION" | sort -V | tail -1)" + if [ "$newest" = "$VERSION" ]; then + # shellcheck disable=SC2086 + docker buildx imagetools create \ + -t "$IMAGE:$MAJOR_MINOR" \ + -t "$IMAGE:latest" \ + $digests + else + echo "::notice::$VERSION is older than $latest_tag; not moving :$MAJOR_MINOR or :latest" + fi docker buildx imagetools inspect "$IMAGE:$VERSION" - name: Release summary @@ -410,7 +440,7 @@ jobs: - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ needs.release-please.outputs.tag_name }} + ref: refs/tags/${{ needs.release-please.outputs.tag_name }} persist-credentials: false - name: Set up Rust toolchain @@ -561,7 +591,7 @@ jobs: - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ steps.rel.outputs.tag }} + ref: refs/tags/${{ steps.rel.outputs.tag }} persist-credentials: false # npm >= 11.5.1 performs the OIDC token exchange automatically when the @@ -638,14 +668,24 @@ jobs: # Platform packages first, then the wrapper (so its optionalDependencies resolve). # Skip versions already on the registry so a rerun after a partial publish # is idempotent instead of erroring on the first existing package. + # npm publish moves the latest dist-tag to whatever it publishes, so a + # backfilled older version would hand :latest to stale code. Publish + # those under the backfill dist-tag instead. + registry_latest="$(npm view @gitlawb/gl dist-tags.latest 2>/dev/null || true)" + dist_tag="latest" + if [ -n "$registry_latest" ] && \ + [ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then + dist_tag="backfill" + echo "::notice::$VERSION is older than registry latest $registry_latest; publishing under dist-tag backfill" + fi for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do name="@gitlawb/$pkg" if npm view "$name@$VERSION" version >/dev/null 2>&1; then echo "==> $name@$VERSION already published, skipping" continue fi - echo "==> npm publish $name@$VERSION" - npm publish "npm/packages/$pkg" --provenance --access public + echo "==> npm publish $name@$VERSION (dist-tag $dist_tag)" + npm publish "npm/packages/$pkg" --provenance --access public --tag "$dist_tag" done homebrew-bump: @@ -789,7 +829,7 @@ jobs: if: ${{ steps.guard.outputs.enabled == 'true' }} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ needs.release-please.outputs.tag_name }} + ref: refs/tags/${{ needs.release-please.outputs.tag_name }} path: node persist-credentials: false diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 36615225..414c2d71 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -64,16 +64,19 @@ 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 each resolver step and the checkout step that supplies its script. Any -# change to one of these reviewed blocks must be reflected here deliberately. +# Pin each resolver step, the checkout step that supplies its script, and the +# two steps that publish to an external registry. Any change to one of these +# reviewed blocks must be reflected here deliberately. awk ' function emit_step() { - if (in_step && (is_rel || is_workflow_scripts_checkout)) { + if (in_step && (is_rel || is_workflow_scripts_checkout || is_manifest || is_npm_publish)) { printf "job=%s\n%s", job, step } in_step = 0 is_rel = 0 is_workflow_scripts_checkout = 0 + is_manifest = 0 + is_npm_publish = 0 step = "" } @@ -89,6 +92,8 @@ awk ' emit_step() in_step = 1 is_workflow_scripts_checkout = ($0 ~ /^ - name:[[:space:]]*Check out workflow scripts[[:space:]]*$/) + is_manifest = ($0 ~ /^ - name:[[:space:]]*Create and push multi-arch manifest[[:space:]]*$/) + is_npm_publish = ($0 ~ /^ - name:[[:space:]]*Publish[[:space:]]*$/) step = $0 ORS next } @@ -142,6 +147,57 @@ job=docker-manifest set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" +job=docker-manifest + - name: Create and push multi-arch manifest + env: + VERSION: ${{ steps.rel.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # ghcr requires a lowercase repository path. + IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" + MAJOR_MINOR="${VERSION%.*}" + # The docker matrix pushes one digest per arch leg; anything else is + # a broken set, not a smaller multi-arch image. + count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)" + if [ "$count" -ne 2 ]; then + echo "::error::expected 2 arch digests in /tmp/digests, found $count" + exit 1 + fi + digests="" + for f in /tmp/digests/*; do + d="$(basename "$f")" + case "$d" in + *[!0-9a-f]*) + echo "::error::digest filename is not lowercase sha256 hex: $d" + exit 1 + ;; + esac + if [ "${#d}" -ne 64 ]; then + echo "::error::digest is not 64 hex chars: $d" + exit 1 + fi + digests="$digests $IMAGE@sha256:$d" + done + # The immutable tag always publishes. The moving tags only advance: + # a backfill of anything but the newest release leaves :latest and + # :X.Y pointing at the newer image. + # shellcheck disable=SC2086 + docker buildx imagetools create -t "$IMAGE:$VERSION" $digests + latest_tag="$(gh release view --json tagName -q .tagName)" + latest_version="${latest_tag#v}" + newest="$(printf '%s\n%s\n' "$latest_version" "$VERSION" | sort -V | tail -1)" + if [ "$newest" = "$VERSION" ]; then + # shellcheck disable=SC2086 + docker buildx imagetools create \ + -t "$IMAGE:$MAJOR_MINOR" \ + -t "$IMAGE:latest" \ + $digests + else + echo "::notice::$VERSION is older than $latest_tag; not moving :$MAJOR_MINOR or :latest" + fi + docker buildx imagetools inspect "$IMAGE:$VERSION" + job=npm-publish - name: Check out workflow scripts uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -158,6 +214,37 @@ job=npm-publish set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" +job=npm-publish + - name: Publish + env: + VERSION: ${{ steps.rel.outputs.version }} + run: | + set -euo pipefail + # No token: npm exchanges this job's GitHub OIDC identity with the + # registry (trusted publishing); provenance is attested automatically. + # Platform packages first, then the wrapper (so its optionalDependencies resolve). + # Skip versions already on the registry so a rerun after a partial publish + # is idempotent instead of erroring on the first existing package. + # npm publish moves the latest dist-tag to whatever it publishes, so a + # backfilled older version would hand :latest to stale code. Publish + # those under the backfill dist-tag instead. + registry_latest="$(npm view @gitlawb/gl dist-tags.latest 2>/dev/null || true)" + dist_tag="latest" + if [ -n "$registry_latest" ] && \ + [ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then + dist_tag="backfill" + echo "::notice::$VERSION is older than registry latest $registry_latest; publishing under dist-tag backfill" + fi + for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do + name="@gitlawb/$pkg" + if npm view "$name@$VERSION" version >/dev/null 2>&1; then + echo "==> $name@$VERSION already published, skipping" + continue + fi + echo "==> npm publish $name@$VERSION (dist-tag $dist_tag)" + npm publish "npm/packages/$pkg" --provenance --access public --tag "$dist_tag" + done + EOF if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then @@ -200,6 +287,16 @@ if ! cmp "$expected_job_environments" "$actual_job_environments"; then exit 1 fi +# Every checkout of a release tag must qualify the ref as refs/tags/... : +# actions/checkout resolves an unqualified ref as a branch before a tag, so a +# same-named branch would shadow the release tag and the release would build +# from unreviewed branch content. +if grep -nE 'ref:[[:space:]]*\$\{\{[^}]*tag[^}]*\}\}' "$release_workflow" \ + | grep -v 'refs/tags/'; then + printf '%s\n' "unqualified release-tag checkout ref (branch shadows tag)" >&2 + exit 1 +fi + # The OIDC publish path must run on an exact npm version, not a range. A range # operator resolves to whatever the registry serves that day, which is the same # mutable-dependency shape the action pins exist to prevent. From 7b8e18219c325437eb43907a784af3705ede24f1 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:48:02 -0500 Subject: [PATCH 03/23] ci(release): gate every job on the release environment and verify tag provenance Two residuals from review of the dispatch-publish surface: - environment: release only covered the three registry-publish jobs. Declaring it on all eight jobs means a dispatch of unmodified YAML on a non-main ref is denied by the environment's deployment rules before any step runs. - The resolver shape-checked vX.Y.Z but did not prove the tag was produced by the release flow. A write-access user could plant a v99.99.99 tag on unreviewed content and dispatch a backfill against it. In CI (GH_TOKEN present) the resolver now requires a GitHub release for the tag and a main...tag compare status of identical or behind, so the tag's commit must already be reachable from main. --- .github/workflows/release.yml | 8 ++++ scripts/resolve-release-tag.sh | 20 +++++++++ scripts/test-resolve-release-tag.sh | 64 +++++++++++++++++++++++++++-- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 555abbf7..56687e2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,7 @@ jobs: permissions: contents: write pull-requests: write + environment: release outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} @@ -78,6 +79,7 @@ jobs: # dispatch the release PR's head carries no check runs and the --locked # gate never validates it. actions: write + environment: release steps: # Prefer the fresh `pr` output when this run just created/updated the # release PR; otherwise fall back to querying for an open release-please @@ -227,6 +229,7 @@ jobs: env: DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" @@ -325,6 +328,7 @@ jobs: env: DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" @@ -418,6 +422,7 @@ jobs: continue-on-error: ${{ startsWith(matrix.target, 'x86_64-pc-windows') }} permissions: contents: write + environment: release strategy: fail-fast: false matrix: @@ -584,6 +589,7 @@ jobs: env: DISPATCH_TAG: ${{ inputs.npm_backfill_tag }} RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" @@ -695,6 +701,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read # GITHUB_TOKEN downloads release assets; the tap push uses HOMEBREW_TAP_PAT + environment: release steps: - name: Guard on secret id: guard @@ -812,6 +819,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read # GITHUB_TOKEN checks out node; the web PR uses WEB_SYNC_PAT + environment: release steps: - name: Guard on secret id: guard diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index 98a219d4..aa9902d4 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -23,5 +23,25 @@ case "$tag" in ;; esac +# Provenance, enforced only in CI where a token is present. The environment +# gate restricts which REF a dispatch may run from; this restricts which TAG +# the input may name: it must be a tag the release flow already published, +# pointing at a commit main already contains. Otherwise a write-access user +# could plant a v99.99.99 tag on unreviewed content and backfill-publish it. +if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + printf '%s\n' "::error::no GitHub release exists for tag $tag" + exit 1 + fi + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$tag" -q .status)" + case "$status" in + identical|behind) ;; + *) + printf '%s\n' "::error::tag $tag is not reachable from main (compare: $status)" + exit 1 + ;; + esac +fi + 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 index 414c2d71..ae788f5f 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -60,6 +60,54 @@ do test ! -s "$invalid_output" done +# The resolver's provenance branch, exercised against a stubbed gh. With a +# token present the tag must name an existing GitHub release and its commit +# must already be reachable from main (compare status identical|behind). +# ahead/diverged or a missing release must fail closed: a write-access user +# could otherwise plant a vX.Y.Z tag on unreviewed content and backfill it. +stub_bin="$test_tmp/stub-bin" +mkdir -p "$stub_bin" +cat > "$stub_bin/gh" <<'STUB' +#!/usr/bin/env bash +case "$1" in + api) + printf '%s\n' "${STUB_STATUS:?STUB_STATUS unset}" + ;; + release) + [ "${STUB_RELEASE_EXISTS:-0}" = "1" ] + ;; +esac +STUB +chmod +x "$stub_bin/gh" + +run_resolver_ci() { + PATH="$stub_bin:$PATH" \ + GH_TOKEN=test-token \ + GITHUB_REPOSITORY=Gitlawb/node \ + STUB_STATUS="$1" STUB_RELEASE_EXISTS="$2" \ + GITHUB_OUTPUT="$test_tmp/prov-output" \ + "$resolver" "$3" >/dev/null 2>&1 +} + +if ! run_resolver_ci behind 1 v9.9.9; then + printf '%s\n' "provenance: release tag reachable from main rejected" >&2 + exit 1 +fi +if ! run_resolver_ci identical 1 v9.9.9; then + printf '%s\n' "provenance: release tag at main tip rejected" >&2 + exit 1 +fi +for bad_status in ahead diverged; do + if run_resolver_ci "$bad_status" 1 v9.9.9; then + printf '%s\n' "provenance: $bad_status tag unexpectedly passed" >&2 + exit 1 + fi +done +if run_resolver_ci behind 0 v9.9.9; then + printf '%s\n' "provenance: tag with no GitHub release unexpectedly passed" >&2 + exit 1 +fi + release_workflow="$repo_root/.github/workflows/release.yml" actual_resolver_steps="$test_tmp/actual-resolver-steps" expected_resolver_steps="$test_tmp/expected-resolver-steps" @@ -123,6 +171,7 @@ job=docker env: DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" @@ -143,6 +192,7 @@ job=docker-manifest env: DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" @@ -210,6 +260,7 @@ job=npm-publish env: DISPATCH_TAG: ${{ inputs.npm_backfill_tag }} RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" @@ -254,10 +305,10 @@ if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then exit 1 fi -# Every job that publishes to an external registry must declare the protected -# environment that gates it. workflow_dispatch runs the selected ref's YAML, so -# the environment's deployment-branch rule is the control that keeps a dispatch -# from a non-main ref out of npm and ghcr. +# Every job declares the protected environment, not only the registry publish +# jobs: workflow_dispatch runs the selected ref's YAML, so the environment's +# deployment-branch rule is the control that keeps a dispatch of unmodified +# YAML on a non-main ref from reaching any step in this workflow. actual_job_environments="$test_tmp/actual-job-environments" awk ' /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { @@ -275,9 +326,14 @@ awk ' expected_job_environments="$test_tmp/expected-job-environments" cat > "$expected_job_environments" <<'EOF' +release-please=release +sync-release-lock=release docker=release docker-manifest=release +release-binaries=release npm-publish=release +homebrew-bump=release +web-sync=release EOF if ! cmp "$expected_job_environments" "$actual_job_environments"; then From a219ed11b8b718a3468ba4472a8be5d14c71349b Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:51:51 -0500 Subject: [PATCH 04/23] ci(release): qualify the provenance compare and fail closed on npm lookup errors Second adversarial pass found two residuals: - The provenance check compared main... with an unqualified refname, the same branch-shadows-tag ambiguity the refs/tags/ checkout prefix was added to avoid. Resolve the tag through commits/refs/tags/ to a commit SHA and compare that instead. - npm view ... || true mapped every lookup failure to "no latest", so a registry outage during an older backfill would publish under --tag latest and move the dist-tag backward. E404 still means first publish; any other failure now aborts before a dist-tag is chosen. --- .github/workflows/release.yml | 12 +++++++++++- scripts/resolve-release-tag.sh | 6 +++++- scripts/test-resolve-release-tag.sh | 21 +++++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56687e2a..a39a9515 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -677,7 +677,17 @@ jobs: # npm publish moves the latest dist-tag to whatever it publishes, so a # backfilled older version would hand :latest to stale code. Publish # those under the backfill dist-tag instead. - registry_latest="$(npm view @gitlawb/gl dist-tags.latest 2>/dev/null || true)" + # E404 means the package has never been published, so this release + # gets latest; any other lookup failure must fail closed rather than + # guess at the dist-tag and risk moving latest backward. + registry_latest="$(npm view @gitlawb/gl dist-tags.latest 2>&1)" || { + if ! grep -q E404 <<<"$registry_latest"; then + printf '%s\n' "$registry_latest" >&2 + echo "::error::npm dist-tags lookup failed; not guessing the dist-tag" + exit 1 + fi + registry_latest="" + } dist_tag="latest" if [ -n "$registry_latest" ] && \ [ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index aa9902d4..cd7010ef 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -33,7 +33,11 @@ if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then printf '%s\n' "::error::no GitHub release exists for tag $tag" exit 1 fi - status="$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$tag" -q .status)" + # Resolve through the fully-qualified tag ref to a commit SHA: an + # unqualified name can resolve to a same-named branch and vouch for the + # wrong commit, the same shadowing the refs/tags/ checkout prefix avoids. + tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$tag" -q .sha)" + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$tag_commit" -q .status)" case "$status" in identical|behind) ;; *) diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index ae788f5f..875ae598 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -71,7 +71,14 @@ cat > "$stub_bin/gh" <<'STUB' #!/usr/bin/env bash case "$1" in api) - printf '%s\n' "${STUB_STATUS:?STUB_STATUS unset}" + case "$2" in + */commits/*) + printf '%s\n' "0000000000000000000000000000000000000000" + ;; + */compare/*) + printf '%s\n' "${STUB_STATUS:?STUB_STATUS unset}" + ;; + esac ;; release) [ "${STUB_RELEASE_EXISTS:-0}" = "1" ] @@ -279,7 +286,17 @@ job=npm-publish # npm publish moves the latest dist-tag to whatever it publishes, so a # backfilled older version would hand :latest to stale code. Publish # those under the backfill dist-tag instead. - registry_latest="$(npm view @gitlawb/gl dist-tags.latest 2>/dev/null || true)" + # E404 means the package has never been published, so this release + # gets latest; any other lookup failure must fail closed rather than + # guess at the dist-tag and risk moving latest backward. + registry_latest="$(npm view @gitlawb/gl dist-tags.latest 2>&1)" || { + if ! grep -q E404 <<<"$registry_latest"; then + printf '%s\n' "$registry_latest" >&2 + echo "::error::npm dist-tags lookup failed; not guessing the dist-tag" + exit 1 + fi + registry_latest="" + } dist_tag="latest" if [ -n "$registry_latest" ] && \ [ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then From 0742c671ec3f2143ac4fb3d1b8fad13ced90d022 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:56:38 -0500 Subject: [PATCH 05/23] ci(release): check release author, decide npm dist-tag per package, close the test gap Third adversarial pass: - Provenance now also requires the release to be authored by the release automation account, so a hand-created release on a valid tag does not qualify for backfill. - The npm dist-tag decision is per package. A partial publish can leave platform packages ahead of the wrapper, and a single lookup on @gitlawb/gl would then downgrade an ahead package's latest tag. - The environment gate test now emits one line per job and marks jobs with no declaration MISSING, so a job added without environment: release can no longer pass silently. --- .github/workflows/release.yml | 39 +++++++++-------- scripts/resolve-release-tag.sh | 10 ++++- scripts/test-resolve-release-tag.sh | 67 +++++++++++++++++++---------- 3 files changed, 73 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a39a9515..6e85c1d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -675,31 +675,32 @@ jobs: # Skip versions already on the registry so a rerun after a partial publish # is idempotent instead of erroring on the first existing package. # npm publish moves the latest dist-tag to whatever it publishes, so a - # backfilled older version would hand :latest to stale code. Publish - # those under the backfill dist-tag instead. - # E404 means the package has never been published, so this release - # gets latest; any other lookup failure must fail closed rather than - # guess at the dist-tag and risk moving latest backward. - registry_latest="$(npm view @gitlawb/gl dist-tags.latest 2>&1)" || { - if ! grep -q E404 <<<"$registry_latest"; then - printf '%s\n' "$registry_latest" >&2 - echo "::error::npm dist-tags lookup failed; not guessing the dist-tag" - exit 1 - fi - registry_latest="" - } - dist_tag="latest" - if [ -n "$registry_latest" ] && \ - [ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then - dist_tag="backfill" - echo "::notice::$VERSION is older than registry latest $registry_latest; publishing under dist-tag backfill" - fi + # backfilled older version would hand :latest to stale code. Decide + # the dist-tag per package from that package's own registry latest: + # a partial publish can leave the platform packages ahead of the + # wrapper. E404 means the package has never been published, so this + # release gets latest; any other lookup failure must fail closed + # rather than guess at the dist-tag and risk moving latest backward. for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do name="@gitlawb/$pkg" if npm view "$name@$VERSION" version >/dev/null 2>&1; then echo "==> $name@$VERSION already published, skipping" continue fi + registry_latest="$(npm view "$name" dist-tags.latest 2>&1)" || { + if ! grep -q E404 <<<"$registry_latest"; then + printf '%s\n' "$registry_latest" >&2 + echo "::error::npm dist-tags lookup failed for $name; not guessing the dist-tag" + exit 1 + fi + registry_latest="" + } + dist_tag="latest" + if [ -n "$registry_latest" ] && \ + [ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then + dist_tag="backfill" + echo "::notice::$VERSION is older than $name@$registry_latest; publishing under dist-tag backfill" + fi echo "==> npm publish $name@$VERSION (dist-tag $dist_tag)" npm publish "npm/packages/$pkg" --provenance --access public --tag "$dist_tag" done diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index cd7010ef..b7d0f356 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -29,9 +29,17 @@ esac # pointing at a commit main already contains. Otherwise a write-access user # could plant a v99.99.99 tag on unreviewed content and backfill-publish it. if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + release_author="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" \ + --json author -q .author.login 2>/dev/null)" || { printf '%s\n' "::error::no GitHub release exists for tag $tag" exit 1 + } + # A hand-created release on a valid tag does not qualify. Not a hard bound + # (a workflow run can mint a bot-authored release), but it removes the + # cheapest path to publishing attacker-uploaded release assets. + if [ "$release_author" != "github-actions[bot]" ]; then + printf '%s\n' "::error::release $tag was authored by $release_author, not the release automation" + exit 1 fi # Resolve through the fully-qualified tag ref to a commit SHA: an # unqualified name can resolve to a same-named branch and vouch for the diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 875ae598..363573c4 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -81,7 +81,8 @@ case "$1" in esac ;; release) - [ "${STUB_RELEASE_EXISTS:-0}" = "1" ] + [ "${STUB_RELEASE_EXISTS:-0}" = "1" ] || exit 1 + printf '%s\n' "${STUB_RELEASE_AUTHOR:-github-actions[bot]}" ;; esac STUB @@ -92,6 +93,7 @@ run_resolver_ci() { GH_TOKEN=test-token \ GITHUB_REPOSITORY=Gitlawb/node \ STUB_STATUS="$1" STUB_RELEASE_EXISTS="$2" \ + STUB_RELEASE_AUTHOR="${4:-github-actions[bot]}" \ GITHUB_OUTPUT="$test_tmp/prov-output" \ "$resolver" "$3" >/dev/null 2>&1 } @@ -114,6 +116,10 @@ if run_resolver_ci behind 0 v9.9.9; then printf '%s\n' "provenance: tag with no GitHub release unexpectedly passed" >&2 exit 1 fi +if run_resolver_ci behind 1 v9.9.9 collaborator; then + printf '%s\n' "provenance: hand-created release unexpectedly passed" >&2 + exit 1 +fi release_workflow="$repo_root/.github/workflows/release.yml" actual_resolver_steps="$test_tmp/actual-resolver-steps" @@ -284,31 +290,32 @@ job=npm-publish # Skip versions already on the registry so a rerun after a partial publish # is idempotent instead of erroring on the first existing package. # npm publish moves the latest dist-tag to whatever it publishes, so a - # backfilled older version would hand :latest to stale code. Publish - # those under the backfill dist-tag instead. - # E404 means the package has never been published, so this release - # gets latest; any other lookup failure must fail closed rather than - # guess at the dist-tag and risk moving latest backward. - registry_latest="$(npm view @gitlawb/gl dist-tags.latest 2>&1)" || { - if ! grep -q E404 <<<"$registry_latest"; then - printf '%s\n' "$registry_latest" >&2 - echo "::error::npm dist-tags lookup failed; not guessing the dist-tag" - exit 1 - fi - registry_latest="" - } - dist_tag="latest" - if [ -n "$registry_latest" ] && \ - [ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then - dist_tag="backfill" - echo "::notice::$VERSION is older than registry latest $registry_latest; publishing under dist-tag backfill" - fi + # backfilled older version would hand :latest to stale code. Decide + # the dist-tag per package from that package's own registry latest: + # a partial publish can leave the platform packages ahead of the + # wrapper. E404 means the package has never been published, so this + # release gets latest; any other lookup failure must fail closed + # rather than guess at the dist-tag and risk moving latest backward. for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do name="@gitlawb/$pkg" if npm view "$name@$VERSION" version >/dev/null 2>&1; then echo "==> $name@$VERSION already published, skipping" continue fi + registry_latest="$(npm view "$name" dist-tags.latest 2>&1)" || { + if ! grep -q E404 <<<"$registry_latest"; then + printf '%s\n' "$registry_latest" >&2 + echo "::error::npm dist-tags lookup failed for $name; not guessing the dist-tag" + exit 1 + fi + registry_latest="" + } + dist_tag="latest" + if [ -n "$registry_latest" ] && \ + [ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then + dist_tag="backfill" + echo "::notice::$VERSION is older than $name@$registry_latest; publishing under dist-tag backfill" + fi echo "==> npm publish $name@$VERSION (dist-tag $dist_tag)" npm publish "npm/packages/$pkg" --provenance --access public --tag "$dist_tag" done @@ -327,17 +334,31 @@ fi # deployment-branch rule is the control that keeps a dispatch of unmodified # YAML on a non-main ref from reaching any step in this workflow. actual_job_environments="$test_tmp/actual-job-environments" +# Emit one line per job whether or not it declares an environment: a fixed +# list of environment lines would stay green if a new job were added without +# one, so completeness must come from the job set, not the declarations. awk ' - /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + /^jobs:[[:space:]]*$/ { + in_jobs = 1 + next + } + in_jobs && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { job = $0 sub(/^ /, "", job) sub(/:[[:space:]]*$/, "", job) + jobs[++n] = job } - /^ environment:[[:space:]]*/ { + in_jobs && /^ environment:[[:space:]]*/ { env = $0 sub(/^ environment:[[:space:]]*/, "", env) gsub(/[[:space:]]/, "", env) - print job "=" env + envs[job] = env + } + END { + for (i = 1; i <= n; i++) { + j = jobs[i] + print j "=" ((j in envs) ? envs[j] : "MISSING") + } } ' "$release_workflow" > "$actual_job_environments" From 7d9b6e1ad8188b8c376f4437bfb41ebdac7f0f61 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:01:06 -0500 Subject: [PATCH 06/23] ci(release): anchor docker moving tags to the registry floor, not the release pointer GitHub's latest-release pointer is operator-mutable and is not a bound on what :latest serves. List the package's versions on ghcr and refuse to move :X.Y or :latest below the newest X.Y.Z tag there. A 404 means the package does not exist yet, so the first publish applies all tags; any other lookup failure aborts. The version filter is grouped with || true so an empty list does not trip pipefail. --- .github/workflows/release.yml | 28 ++++++++++++++++++++++------ scripts/test-resolve-release-tag.sh | 28 ++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e85c1d8..707b571a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -382,13 +382,29 @@ jobs: digests="$digests $IMAGE@sha256:$d" done # The immutable tag always publishes. The moving tags only advance: - # a backfill of anything but the newest release leaves :latest and - # :X.Y pointing at the newer image. + # the floor is the newest X.Y.Z tag already on the registry, not + # GitHub's latest-release pointer, which is operator-mutable. A 404 + # means the package does not exist yet (first publish); any other + # lookup failure aborts rather than move :latest on a guess. # shellcheck disable=SC2086 docker buildx imagetools create -t "$IMAGE:$VERSION" $digests - latest_tag="$(gh release view --json tagName -q .tagName)" - latest_version="${latest_tag#v}" - newest="$(printf '%s\n%s\n' "$latest_version" "$VERSION" | sort -V | tail -1)" + versions_err="$(mktemp)" + registry_versions="$(gh api --paginate \ + "orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${GITHUB_REPOSITORY##*/}/versions?per_page=100" \ + -q '.[].metadata.container.tags[]' 2>"$versions_err")" || { + if grep -q 'HTTP 404' "$versions_err"; then + registry_versions="" + else + cat "$versions_err" >&2 + echo "::error::could not list ghcr versions; not moving tags blindly" + exit 1 + fi + } + rm -f "$versions_err" + current_max="$(printf '%s\n' "$registry_versions" \ + | { grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' || true; } \ + | sort -V | tail -1)" + newest="$(printf '%s\n%s\n' "${current_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" if [ "$newest" = "$VERSION" ]; then # shellcheck disable=SC2086 docker buildx imagetools create \ @@ -396,7 +412,7 @@ jobs: -t "$IMAGE:latest" \ $digests else - echo "::notice::$VERSION is older than $latest_tag; not moving :$MAJOR_MINOR or :latest" + echo "::notice::$VERSION is older than registry newest $current_max; not moving :$MAJOR_MINOR or :latest" fi docker buildx imagetools inspect "$IMAGE:$VERSION" diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 363573c4..52f32b79 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -243,13 +243,29 @@ job=docker-manifest digests="$digests $IMAGE@sha256:$d" done # The immutable tag always publishes. The moving tags only advance: - # a backfill of anything but the newest release leaves :latest and - # :X.Y pointing at the newer image. + # the floor is the newest X.Y.Z tag already on the registry, not + # GitHub's latest-release pointer, which is operator-mutable. A 404 + # means the package does not exist yet (first publish); any other + # lookup failure aborts rather than move :latest on a guess. # shellcheck disable=SC2086 docker buildx imagetools create -t "$IMAGE:$VERSION" $digests - latest_tag="$(gh release view --json tagName -q .tagName)" - latest_version="${latest_tag#v}" - newest="$(printf '%s\n%s\n' "$latest_version" "$VERSION" | sort -V | tail -1)" + versions_err="$(mktemp)" + registry_versions="$(gh api --paginate \ + "orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${GITHUB_REPOSITORY##*/}/versions?per_page=100" \ + -q '.[].metadata.container.tags[]' 2>"$versions_err")" || { + if grep -q 'HTTP 404' "$versions_err"; then + registry_versions="" + else + cat "$versions_err" >&2 + echo "::error::could not list ghcr versions; not moving tags blindly" + exit 1 + fi + } + rm -f "$versions_err" + current_max="$(printf '%s\n' "$registry_versions" \ + | { grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' || true; } \ + | sort -V | tail -1)" + newest="$(printf '%s\n%s\n' "${current_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" if [ "$newest" = "$VERSION" ]; then # shellcheck disable=SC2086 docker buildx imagetools create \ @@ -257,7 +273,7 @@ job=docker-manifest -t "$IMAGE:latest" \ $digests else - echo "::notice::$VERSION is older than $latest_tag; not moving :$MAJOR_MINOR or :latest" + echo "::notice::$VERSION is older than registry newest $current_max; not moving :$MAJOR_MINOR or :latest" fi docker buildx imagetools inspect "$IMAGE:$VERSION" From e005dda59c7bf0efdd5b17adaa7425af2a6aafe9 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:03:30 -0500 Subject: [PATCH 07/23] ci(release): require backfill release assets to be automation-uploaded The npm backfill republishes GitHub release assets, which are mutable for anyone with contents: write. Require every asset on the release to have been uploaded by github-actions[bot], matching the release-author check. Not a hard bound, but it removes the direct edit path and leaves a review signal when a hand-uploaded asset blocks a backfill. --- scripts/resolve-release-tag.sh | 8 ++++++++ scripts/test-resolve-release-tag.sh | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index b7d0f356..82dc8e0e 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -41,6 +41,14 @@ if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then printf '%s\n' "::error::release $tag was authored by $release_author, not the release automation" exit 1 fi + # Same boundary for the assets the npm backfill republishes: a release + # asset replaced by a collaborator login is attacker-mutable content. + uploaders="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" \ + -q '.assets[].uploader.login' | sort -u)" + if [ -n "$uploaders" ] && [ "$uploaders" != "github-actions[bot]" ]; then + printf '%s\n' "::error::release $tag has assets not uploaded by the release automation: $uploaders" + exit 1 + fi # Resolve through the fully-qualified tag ref to a commit SHA: an # unqualified name can resolve to a same-named branch and vouch for the # wrong commit, the same shadowing the refs/tags/ checkout prefix avoids. diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 52f32b79..c33619ad 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -78,6 +78,9 @@ case "$1" in */compare/*) printf '%s\n' "${STUB_STATUS:?STUB_STATUS unset}" ;; + */releases/tags/*) + printf '%s\n' "${STUB_UPLOADERS:-github-actions[bot]}" + ;; esac ;; release) @@ -94,6 +97,7 @@ run_resolver_ci() { GITHUB_REPOSITORY=Gitlawb/node \ STUB_STATUS="$1" STUB_RELEASE_EXISTS="$2" \ STUB_RELEASE_AUTHOR="${4:-github-actions[bot]}" \ + STUB_UPLOADERS="${5:-github-actions[bot]}" \ GITHUB_OUTPUT="$test_tmp/prov-output" \ "$resolver" "$3" >/dev/null 2>&1 } @@ -120,6 +124,10 @@ if run_resolver_ci behind 1 v9.9.9 collaborator; then printf '%s\n' "provenance: hand-created release unexpectedly passed" >&2 exit 1 fi +if run_resolver_ci behind 1 v9.9.9 "github-actions[bot]" collaborator; then + printf '%s\n' "provenance: release with collaborator-uploaded assets unexpectedly passed" >&2 + exit 1 +fi release_workflow="$repo_root/.github/workflows/release.yml" actual_resolver_steps="$test_tmp/actual-resolver-steps" From ab0a32ef4ffe1f37b60cac4bb54933c3cc6221c0 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:11:41 -0500 Subject: [PATCH 08/23] ci(release): pin the verified commit and the exact asset bytes end to end The provenance checks ran at resolve time but the consumers re-resolved mutable names afterward, leaving a move-the-tag or replace-the-asset race: - Checkouts now use the commit SHA the resolver emitted (tag_commit), falling back to the qualified tag ref. - The resolver captures the release's name/id asset pairs once, checks all uploaders against them, and the lay-in step downloads by asset id. An asset deleted and reuploaded between resolve and download gets a new id and fails closed. - The npm-publish comment now names the third trusted-publisher component (environment), which is what binds the OIDC token to the gate. --- .github/workflows/release.yml | 25 ++++++-- scripts/resolve-release-tag.sh | 18 +++++- scripts/test-resolve-release-tag.sh | 95 +++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 707b571a..510acd0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -241,7 +241,9 @@ jobs: - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: refs/tags/${{ steps.rel.outputs.tag }} + # Pin the commit the resolver verified; a tag ref re-resolved here + # could have been moved between resolve and checkout. + ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }} persist-credentials: false - name: Set up Docker Buildx @@ -581,7 +583,9 @@ jobs: # (release-binaries is skipped on dispatch, hence !cancelled()). Publishing # auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each # @gitlawb package must have this repo + workflow (release.yml) configured - # as its trusted publisher on npmjs.com, or publish fails loudly here — + # as its trusted publisher on npmjs.com, scoped to the release environment + # so a dispatched copy of this file on another ref cannot mint a token, or + # publish fails loudly here — # deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0 # never reached npm. if: >- @@ -613,7 +617,9 @@ jobs: - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: refs/tags/${{ steps.rel.outputs.tag }} + # Pin the commit the resolver verified; a tag ref re-resolved here + # could have been moved between resolve and checkout. + ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }} persist-credentials: false # npm >= 11.5.1 performs the OIDC token exchange automatically when the @@ -634,6 +640,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.rel.outputs.version }} TAG: ${{ steps.rel.outputs.tag }} + ASSETS: ${{ steps.rel.outputs.assets }} run: | set -euo pipefail # npm platform package -> Rust target triple (unix only; Windows is not @@ -650,8 +657,16 @@ jobs: target="${entry#*:}" archive="gitlawb-node-${VERSION}-${target}.tar.gz" echo "==> $pkg <- $archive" - gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ - --pattern "$archive" --dir _dl --clobber + # Download by the asset id the resolver captured and uploader- + # checked, not by name: release assets are mutable, and an id can + # only ever point at the exact blob captured at resolve time. + asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$archive" '$1 == n {print $2; exit}')" + if [ -z "$asset_id" ]; then + echo "::error::release $TAG has no asset $archive captured at resolve time" + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \ + -H 'Accept: application/octet-stream' > "_dl/$archive" tar -xzf "_dl/$archive" -C _dl src="_dl/gitlawb-node-${VERSION}-${target}" cp "$src/gl" "npm/packages/$pkg/gl" diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index 82dc8e0e..6e32432b 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -42,16 +42,27 @@ if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then exit 1 fi # Same boundary for the assets the npm backfill republishes: a release - # asset replaced by a collaborator login is attacker-mutable content. - uploaders="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" \ - -q '.assets[].uploader.login' | sort -u)" + # asset replaced by a collaborator login is attacker-mutable content. The + # captured name/id pairs go to GITHUB_OUTPUT so the download step fetches + # by immutable asset id; a delete+reupload between here and the download + # produces a new id and fails closed. + assets="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" \ + -q '.assets[] | .name + " " + (.id | tostring) + " " + .uploader.login')" + uploaders="$(printf '%s\n' "$assets" | awk 'NF {print $3}' | sort -u)" if [ -n "$uploaders" ] && [ "$uploaders" != "github-actions[bot]" ]; then printf '%s\n' "::error::release $tag has assets not uploaded by the release automation: $uploaders" exit 1 fi + { + printf '%s\n' 'assets<> "$GITHUB_OUTPUT" # Resolve through the fully-qualified tag ref to a commit SHA: an # unqualified name can resolve to a same-named branch and vouch for the # wrong commit, the same shadowing the refs/tags/ checkout prefix avoids. + # The SHA is emitted so checkouts pin the verified commit instead of + # re-resolving a tag that could be moved between resolve and checkout. tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$tag" -q .sha)" status="$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$tag_commit" -q .status)" case "$status" in @@ -61,6 +72,7 @@ if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then exit 1 ;; esac + printf '%s\n' "tag_commit=$tag_commit" >> "$GITHUB_OUTPUT" fi printf '%s\n' "tag=$tag" >> "$GITHUB_OUTPUT" diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index c33619ad..c13a3269 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -79,7 +79,8 @@ case "$1" in printf '%s\n' "${STUB_STATUS:?STUB_STATUS unset}" ;; */releases/tags/*) - printf '%s\n' "${STUB_UPLOADERS:-github-actions[bot]}" + printf 'gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz 11 %s\n' \ + "${STUB_UPLOADERS:-github-actions[bot]}" ;; esac ;; @@ -106,6 +107,20 @@ if ! run_resolver_ci behind 1 v9.9.9; then printf '%s\n' "provenance: release tag reachable from main rejected" >&2 exit 1 fi +# The resolved SHA and the captured asset name/id map must reach +# GITHUB_OUTPUT: checkouts pin the SHA, and the binary download step fetches +# by immutable asset id. +if ! grep -qx 'tag_commit=0000000000000000000000000000000000000000' \ + "$test_tmp/prov-output"; then + printf '%s\n' "provenance: tag_commit output missing" >&2 + exit 1 +fi +if ! grep -q 'assets<&2 + exit 1 +fi if ! run_resolver_ci identical 1 v9.9.9; then printf '%s\n' "provenance: release tag at main tip rejected" >&2 exit 1 @@ -134,11 +149,11 @@ actual_resolver_steps="$test_tmp/actual-resolver-steps" expected_resolver_steps="$test_tmp/expected-resolver-steps" # Pin each resolver step, the checkout step that supplies its script, and the -# two steps that publish to an external registry. Any change to one of these -# reviewed blocks must be reflected here deliberately. +# steps that publish to an external registry or fetch the publish input. Any +# change to one of these reviewed blocks must be reflected here deliberately. awk ' function emit_step() { - if (in_step && (is_rel || is_workflow_scripts_checkout || is_manifest || is_npm_publish)) { + if (in_step && (is_rel || is_workflow_scripts_checkout || is_manifest || is_npm_publish || is_layin || is_tag_checkout)) { printf "job=%s\n%s", job, step } in_step = 0 @@ -146,6 +161,8 @@ awk ' is_workflow_scripts_checkout = 0 is_manifest = 0 is_npm_publish = 0 + is_layin = 0 + is_tag_checkout = 0 step = "" } @@ -163,6 +180,8 @@ awk ' is_workflow_scripts_checkout = ($0 ~ /^ - name:[[:space:]]*Check out workflow scripts[[:space:]]*$/) is_manifest = ($0 ~ /^ - name:[[:space:]]*Create and push multi-arch manifest[[:space:]]*$/) is_npm_publish = ($0 ~ /^ - name:[[:space:]]*Publish[[:space:]]*$/) + is_layin = ($0 ~ /^ - name:[[:space:]]*Lay in release binaries[[:space:]]*$/) + is_tag_checkout = ($0 ~ /^ - name:[[:space:]]*Checkout release tag[[:space:]]*$/) step = $0 ORS next } @@ -201,6 +220,15 @@ job=docker # makes the digest push fail with "invalid reference format". echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" +job=docker + - name: Checkout release tag + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # Pin the commit the resolver verified; a tag ref re-resolved here + # could have been moved between resolve and checkout. + ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }} + persist-credentials: false + job=docker-manifest - name: Check out workflow scripts uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -285,6 +313,13 @@ job=docker-manifest fi docker buildx imagetools inspect "$IMAGE:$VERSION" +job=release-binaries + - name: Checkout release tag + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: refs/tags/${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + job=npm-publish - name: Check out workflow scripts uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -302,6 +337,58 @@ job=npm-publish set -euo pipefail scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" +job=npm-publish + - name: Checkout release tag + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # Pin the commit the resolver verified; a tag ref re-resolved here + # could have been moved between resolve and checkout. + ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }} + persist-credentials: false + + # npm >= 11.5.1 performs the OIDC token exchange automatically when the + # package has a trusted publisher configured; older npm silently falls + # back to (absent) token auth and fails confusingly. +job=npm-publish + - name: Lay in release binaries + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.rel.outputs.version }} + TAG: ${{ steps.rel.outputs.tag }} + ASSETS: ${{ steps.rel.outputs.assets }} + run: | + set -euo pipefail + # npm platform package -> Rust target triple (unix only; Windows is not + # published to npm). + MAP=" + gl-darwin-arm64:aarch64-apple-darwin + gl-darwin-x64:x86_64-apple-darwin + gl-linux-arm64:aarch64-unknown-linux-musl + gl-linux-x64:x86_64-unknown-linux-musl + " + mkdir -p _dl + for entry in $MAP; do + pkg="${entry%%:*}" + target="${entry#*:}" + archive="gitlawb-node-${VERSION}-${target}.tar.gz" + echo "==> $pkg <- $archive" + # Download by the asset id the resolver captured and uploader- + # checked, not by name: release assets are mutable, and an id can + # only ever point at the exact blob captured at resolve time. + asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$archive" '$1 == n {print $2; exit}')" + if [ -z "$asset_id" ]; then + echo "::error::release $TAG has no asset $archive captured at resolve time" + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \ + -H 'Accept: application/octet-stream' > "_dl/$archive" + tar -xzf "_dl/$archive" -C _dl + src="_dl/gitlawb-node-${VERSION}-${target}" + cp "$src/gl" "npm/packages/$pkg/gl" + cp "$src/git-remote-gitlawb" "npm/packages/$pkg/git-remote-gitlawb" + chmod +x "npm/packages/$pkg/gl" "npm/packages/$pkg/git-remote-gitlawb" + done + job=npm-publish - name: Publish env: From 1a9f51f29094ef1f96bbdb00b6a37c103ccbdcde Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:20:07 -0500 Subject: [PATCH 09/23] ci(release): run the resolver on the producer path and hash homebrew inputs locally - release-binaries and web-sync now resolve the release tag and check out the verified commit SHA, closing the tag-move race on the job that produces the release assets, not just the jobs that consume them. - homebrew-bump resolves the tag, downloads the tarballs by captured asset id, and computes sha256 locally instead of trusting the mutable *.tar.gz.sha256 sidecar assets. - The step pin now covers the lay-in, formula, and release-tag checkout blocks so edits to the trust boundary force a deliberate fixture update. --- .github/workflows/release.yml | 76 ++++++++++++-- scripts/test-resolve-release-tag.sh | 157 +++++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 510acd0e..255555d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -460,10 +460,26 @@ jobs: - target: x86_64-pc-windows-msvc os: windows-latest steps: + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Resolve release tag + id: rel + env: + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "$RELEASE_TAG" + - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: refs/tags/${{ needs.release-please.outputs.tag_name }} + # Pin the commit the resolver verified; a tag ref re-resolved here + # could have been moved between resolve and checkout. + ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }} persist-credentials: false - name: Set up Rust toolchain @@ -757,6 +773,22 @@ jobs: echo "enabled=true" >> "$GITHUB_OUTPUT" fi + - name: Check out workflow scripts + if: ${{ steps.guard.outputs.enabled == 'true' }} + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Resolve release tag + if: ${{ steps.guard.outputs.enabled == 'true' }} + id: rel + env: + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "$RELEASE_TAG" + - name: Checkout tap repo if: ${{ steps.guard.outputs.enabled == 'true' }} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -770,15 +802,27 @@ jobs: if: ${{ steps.guard.outputs.enabled == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.release-please.outputs.version }} - TAG: ${{ needs.release-please.outputs.tag_name }} + VERSION: ${{ steps.rel.outputs.version }} + TAG: ${{ steps.rel.outputs.tag }} + ASSETS: ${{ steps.rel.outputs.assets }} run: | set -euo pipefail base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" mkdir -p _sums - gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ - --pattern '*.tar.gz.sha256' --dir _sums --clobber - sha() { awk '{print $1}' "_sums/gitlawb-node-${VERSION}-$1.tar.gz.sha256"; } + # Hash the tarballs themselves, fetched by the asset id the resolver + # captured and uploader-checked. The *.sha256 sidecars are mutable + # release assets too; trusting them would trust the same surface. + sha() { + archive="gitlawb-node-${VERSION}-$1.tar.gz" + asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$archive" '$1 == n {print $2; exit}')" + if [ -z "$asset_id" ]; then + echo "::error::release $TAG has no asset $archive captured at resolve time" + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \ + -H 'Accept: application/octet-stream' > "_sums/$archive" + sha256sum "_sums/$archive" | awk '{print $1}' + } SHA_MAC_ARM="$(sha aarch64-apple-darwin)" SHA_MAC_X64="$(sha x86_64-apple-darwin)" SHA_LNX_ARM="$(sha aarch64-unknown-linux-musl)" @@ -875,11 +919,29 @@ jobs: echo "enabled=true" >> "$GITHUB_OUTPUT" fi + - name: Check out workflow scripts + if: ${{ steps.guard.outputs.enabled == 'true' }} + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Resolve release tag + if: ${{ steps.guard.outputs.enabled == 'true' }} + id: rel + env: + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "$RELEASE_TAG" + - name: Checkout node (release tag) if: ${{ steps.guard.outputs.enabled == 'true' }} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: refs/tags/${{ needs.release-please.outputs.tag_name }} + # Pin the commit the resolver verified; a tag ref re-resolved here + # could have been moved between resolve and checkout. + ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }} path: node persist-credentials: false diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index c13a3269..6d020f47 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -153,7 +153,7 @@ expected_resolver_steps="$test_tmp/expected-resolver-steps" # change to one of these reviewed blocks must be reflected here deliberately. awk ' function emit_step() { - if (in_step && (is_rel || is_workflow_scripts_checkout || is_manifest || is_npm_publish || is_layin || is_tag_checkout)) { + if (in_step && (is_rel || is_workflow_scripts_checkout || is_manifest || is_npm_publish || is_layin || is_tag_checkout || is_regen)) { printf "job=%s\n%s", job, step } in_step = 0 @@ -163,6 +163,7 @@ awk ' is_npm_publish = 0 is_layin = 0 is_tag_checkout = 0 + is_regen = 0 step = "" } @@ -181,7 +182,8 @@ awk ' is_manifest = ($0 ~ /^ - name:[[:space:]]*Create and push multi-arch manifest[[:space:]]*$/) is_npm_publish = ($0 ~ /^ - name:[[:space:]]*Publish[[:space:]]*$/) is_layin = ($0 ~ /^ - name:[[:space:]]*Lay in release binaries[[:space:]]*$/) - is_tag_checkout = ($0 ~ /^ - name:[[:space:]]*Checkout release tag[[:space:]]*$/) + is_tag_checkout = ($0 ~ /^ - name:[[:space:]]*Checkout release tag[[:space:]]*$/ || $0 ~ /^ - name:[[:space:]]*Checkout node \(release tag\)[[:space:]]*$/) + is_regen = ($0 ~ /^ - name:[[:space:]]*Regenerate formula[[:space:]]*$/) step = $0 ORS next } @@ -198,6 +200,9 @@ awk ' } ' "$release_workflow" > "$actual_resolver_steps" +# The fixture embeds the formula step's own indented heredoc terminator; +# inside this quoted heredoc it is data, not a directive to bash. +# shellcheck disable=SC1039 cat > "$expected_resolver_steps" <<'EOF' job=docker - name: Check out workflow scripts @@ -313,11 +318,29 @@ job=docker-manifest fi docker buildx imagetools inspect "$IMAGE:$VERSION" +job=release-binaries + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + +job=release-binaries + - name: Resolve release tag + id: rel + env: + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "$RELEASE_TAG" + job=release-binaries - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: refs/tags/${{ needs.release-please.outputs.tag_name }} + # Pin the commit the resolver verified; a tag ref re-resolved here + # could have been moved between resolve and checkout. + ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }} persist-credentials: false job=npm-publish @@ -431,6 +454,134 @@ job=npm-publish npm publish "npm/packages/$pkg" --provenance --access public --tag "$dist_tag" done +job=homebrew-bump + - name: Check out workflow scripts + if: ${{ steps.guard.outputs.enabled == 'true' }} + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + +job=homebrew-bump + - name: Resolve release tag + if: ${{ steps.guard.outputs.enabled == 'true' }} + id: rel + env: + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "$RELEASE_TAG" + +job=homebrew-bump + - name: Regenerate formula + if: ${{ steps.guard.outputs.enabled == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.rel.outputs.version }} + TAG: ${{ steps.rel.outputs.tag }} + ASSETS: ${{ steps.rel.outputs.assets }} + run: | + set -euo pipefail + base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" + mkdir -p _sums + # Hash the tarballs themselves, fetched by the asset id the resolver + # captured and uploader-checked. The *.sha256 sidecars are mutable + # release assets too; trusting them would trust the same surface. + sha() { + archive="gitlawb-node-${VERSION}-$1.tar.gz" + asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$archive" '$1 == n {print $2; exit}')" + if [ -z "$asset_id" ]; then + echo "::error::release $TAG has no asset $archive captured at resolve time" + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \ + -H 'Accept: application/octet-stream' > "_sums/$archive" + sha256sum "_sums/$archive" | awk '{print $1}' + } + SHA_MAC_ARM="$(sha aarch64-apple-darwin)" + SHA_MAC_X64="$(sha x86_64-apple-darwin)" + SHA_LNX_ARM="$(sha aarch64-unknown-linux-musl)" + SHA_LNX_X64="$(sha x86_64-unknown-linux-musl)" + + mkdir -p tap/Formula + cat > tap/Formula/gl.rb </dev/null' >> ~/.zshrc && source ~/.zshrc + CAVEATS + end + + test do + assert_match version.to_s, shell_output("#{bin}/gl --version") + end + end + EOF + +job=web-sync + - name: Check out workflow scripts + if: ${{ steps.guard.outputs.enabled == 'true' }} + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + +job=web-sync + - name: Resolve release tag + if: ${{ steps.guard.outputs.enabled == 'true' }} + id: rel + env: + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "$RELEASE_TAG" + +job=web-sync + - name: Checkout node (release tag) + if: ${{ steps.guard.outputs.enabled == 'true' }} + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # Pin the commit the resolver verified; a tag ref re-resolved here + # could have been moved between resolve and checkout. + ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }} + path: node + persist-credentials: false + EOF if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then From 73d560ebfbb4b2864913f50d625059bb8a7b37b2 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:23:03 -0500 Subject: [PATCH 10/23] ci(release): bind the tag to the commit its release was created against Reachable-from-main alone accepts a tag moved to any other main commit. The release object records its creation-time target_commitish, so require tag_commit == release.targetCommitish: a moved tag now fails closed, and the SHA emitted for checkouts is provably the released commit. --- scripts/resolve-release-tag.sh | 13 +++++++++++-- scripts/test-resolve-release-tag.sh | 13 ++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index 6e32432b..f083e636 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -29,11 +29,14 @@ esac # pointing at a commit main already contains. Otherwise a write-access user # could plant a v99.99.99 tag on unreviewed content and backfill-publish it. if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then - release_author="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" \ - --json author -q .author.login 2>/dev/null)" || { + release_meta="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" \ + --json author,targetCommitish -q '.author.login + " " + .targetCommitish' \ + 2>/dev/null)" || { printf '%s\n' "::error::no GitHub release exists for tag $tag" exit 1 } + release_author="${release_meta%% *}" + release_target="${release_meta##* }" # A hand-created release on a valid tag does not qualify. Not a hard bound # (a workflow run can mint a bot-authored release), but it removes the # cheapest path to publishing attacker-uploaded release assets. @@ -64,6 +67,12 @@ if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then # The SHA is emitted so checkouts pin the verified commit instead of # re-resolving a tag that could be moved between resolve and checkout. tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$tag" -q .sha)" + # The tag must still point at the commit the release was created against. + # Reachable-from-main alone accepts a tag moved to any other main commit. + if [ "$tag_commit" != "$release_target" ]; then + printf '%s\n' "::error::tag $tag points at $tag_commit but release $tag was created against $release_target (tag moved)" + exit 1 + fi status="$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$tag_commit" -q .status)" case "$status" in identical|behind) ;; diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 6d020f47..1b8d956f 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -86,7 +86,9 @@ case "$1" in ;; release) [ "${STUB_RELEASE_EXISTS:-0}" = "1" ] || exit 1 - printf '%s\n' "${STUB_RELEASE_AUTHOR:-github-actions[bot]}" + printf '%s %s\n' \ + "${STUB_RELEASE_AUTHOR:-github-actions[bot]}" \ + "${STUB_TARGET:-0000000000000000000000000000000000000000}" ;; esac STUB @@ -99,6 +101,7 @@ run_resolver_ci() { STUB_STATUS="$1" STUB_RELEASE_EXISTS="$2" \ STUB_RELEASE_AUTHOR="${4:-github-actions[bot]}" \ STUB_UPLOADERS="${5:-github-actions[bot]}" \ + STUB_TARGET="${6:-0000000000000000000000000000000000000000}" \ GITHUB_OUTPUT="$test_tmp/prov-output" \ "$resolver" "$3" >/dev/null 2>&1 } @@ -143,6 +146,14 @@ if run_resolver_ci behind 1 v9.9.9 "github-actions[bot]" collaborator; then printf '%s\n' "provenance: release with collaborator-uploaded assets unexpectedly passed" >&2 exit 1 fi +# A tag moved to a different main commit: compare would say behind, but the +# tag no longer matches the commit the release was created against. +if run_resolver_ci behind 1 v9.9.9 \ + "github-actions[bot]" "github-actions[bot]" \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; then + printf '%s\n' "provenance: moved tag unexpectedly passed" >&2 + exit 1 +fi release_workflow="$repo_root/.github/workflows/release.yml" actual_resolver_steps="$test_tmp/actual-resolver-steps" From 64c4ea5f7abafdd8a67f19974efbbbd7aba5ad96 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:24:38 -0500 Subject: [PATCH 11/23] ci(release): force bash for the release-binaries resolver step The matrix includes windows-latest where run: defaults to pwsh, and the resolver body is bash. --- .github/workflows/release.yml | 1 + scripts/test-resolve-release-tag.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 255555d4..71f75d8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -470,6 +470,7 @@ jobs: env: RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash # matrix includes windows-latest, where run: defaults to pwsh run: | set -euo pipefail scripts/resolve-release-tag.sh "$RELEASE_TAG" diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 1b8d956f..d50706a7 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -341,6 +341,7 @@ job=release-binaries env: RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash # matrix includes windows-latest, where run: defaults to pwsh run: | set -euo pipefail scripts/resolve-release-tag.sh "$RELEASE_TAG" From 55a6a2ac2f93108424796c635c8492ce942fa16c Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:29:18 -0500 Subject: [PATCH 12/23] ci(release): base64 release-asset names in the resolver map Release asset names are attacker-controlled text. A crafted name carrying whitespace could smuggle a fake uploader into the third positional field and pass the automation-only check. Emitting names base64-encoded keeps each record in three real fields and makes the name-to-id lookup immune to injection. --- .github/workflows/release.yml | 7 +++++-- scripts/resolve-release-tag.sh | 6 ++++-- scripts/test-resolve-release-tag.sh | 29 ++++++++++++++++++++++++----- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71f75d8b..5e715112 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -677,7 +677,9 @@ jobs: # Download by the asset id the resolver captured and uploader- # checked, not by name: release assets are mutable, and an id can # only ever point at the exact blob captured at resolve time. - asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$archive" '$1 == n {print $2; exit}')" + # The map keys are base64-encoded asset names. + want="$(printf '%s' "$archive" | base64 -w0)" + asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')" if [ -z "$asset_id" ]; then echo "::error::release $TAG has no asset $archive captured at resolve time" exit 1 @@ -815,7 +817,8 @@ jobs: # release assets too; trusting them would trust the same surface. sha() { archive="gitlawb-node-${VERSION}-$1.tar.gz" - asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$archive" '$1 == n {print $2; exit}')" + want="$(printf '%s' "$archive" | base64 -w0)" + asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')" if [ -z "$asset_id" ]; then echo "::error::release $TAG has no asset $archive captured at resolve time" exit 1 diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh index f083e636..9b5d416e 100755 --- a/scripts/resolve-release-tag.sh +++ b/scripts/resolve-release-tag.sh @@ -48,9 +48,11 @@ if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then # asset replaced by a collaborator login is attacker-mutable content. The # captured name/id pairs go to GITHUB_OUTPUT so the download step fetches # by immutable asset id; a delete+reupload between here and the download - # produces a new id and fails closed. + # produces a new id and fails closed. Names are emitted base64-encoded: + # an asset name is attacker-controlled text, and whitespace in it would + # otherwise let a crafted name shift the positional fields. assets="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" \ - -q '.assets[] | .name + " " + (.id | tostring) + " " + .uploader.login')" + -q '.assets[] | (.name | @base64) + " " + (.id | tostring) + " " + .uploader.login')" uploaders="$(printf '%s\n' "$assets" | awk 'NF {print $3}' | sort -u)" if [ -n "$uploaders" ] && [ "$uploaders" != "github-actions[bot]" ]; then printf '%s\n' "::error::release $tag has assets not uploaded by the release automation: $uploaders" diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index d50706a7..472ac46e 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -79,8 +79,17 @@ case "$1" in printf '%s\n' "${STUB_STATUS:?STUB_STATUS unset}" ;; */releases/tags/*) - printf 'gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz 11 %s\n' \ + # Emitted as base64(name) id uploader, matching the resolver's jq. + printf '%s 11 %s\n' \ + "$(printf '%s' 'gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz' | base64 -w0)" \ "${STUB_UPLOADERS:-github-actions[bot]}" + # STUB_EVIL_NAME simulates an attacker-crafted asset name carrying a + # fake uploader inside it; base64 keeps it a single first field and + # the real uploader stays in $3. + if [ "${STUB_EVIL_NAME:-0}" = "1" ]; then + printf '%s 999 collaborator\n' \ + "$(printf '%s' 'gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz 999 github-actions[bot]' | base64 -w0)" + fi ;; esac ;; @@ -102,6 +111,7 @@ run_resolver_ci() { STUB_RELEASE_AUTHOR="${4:-github-actions[bot]}" \ STUB_UPLOADERS="${5:-github-actions[bot]}" \ STUB_TARGET="${6:-0000000000000000000000000000000000000000}" \ + STUB_EVIL_NAME="${STUB_EVIL_NAME:-0}" \ GITHUB_OUTPUT="$test_tmp/prov-output" \ "$resolver" "$3" >/dev/null 2>&1 } @@ -118,9 +128,9 @@ if ! grep -qx 'tag_commit=0000000000000000000000000000000000000000' \ printf '%s\n' "provenance: tag_commit output missing" >&2 exit 1 fi +want_b64="$(printf '%s' 'gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz' | base64 -w0)" if ! grep -q 'assets<&2 exit 1 fi @@ -154,6 +164,12 @@ if run_resolver_ci behind 1 v9.9.9 \ printf '%s\n' "provenance: moved tag unexpectedly passed" >&2 exit 1 fi +# A crafted asset name smuggling a fake uploader string: the real uploader +# stays in the third field once names are base64-encoded. +if STUB_EVIL_NAME=1 run_resolver_ci behind 1 v9.9.9; then + printf '%s\n' "provenance: crafted asset name unexpectedly passed" >&2 + exit 1 +fi release_workflow="$repo_root/.github/workflows/release.yml" actual_resolver_steps="$test_tmp/actual-resolver-steps" @@ -410,7 +426,9 @@ job=npm-publish # Download by the asset id the resolver captured and uploader- # checked, not by name: release assets are mutable, and an id can # only ever point at the exact blob captured at resolve time. - asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$archive" '$1 == n {print $2; exit}')" + # The map keys are base64-encoded asset names. + want="$(printf '%s' "$archive" | base64 -w0)" + asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')" if [ -z "$asset_id" ]; then echo "::error::release $TAG has no asset $archive captured at resolve time" exit 1 @@ -501,7 +519,8 @@ job=homebrew-bump # release assets too; trusting them would trust the same surface. sha() { archive="gitlawb-node-${VERSION}-$1.tar.gz" - asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$archive" '$1 == n {print $2; exit}')" + want="$(printf '%s' "$archive" | base64 -w0)" + asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')" if [ -z "$asset_id" ]; then echo "::error::release $TAG has no asset $archive captured at resolve time" exit 1 From 7c88dc202b5b7d1adb87cc3a76f63ef960544716 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:32:55 -0500 Subject: [PATCH 13/23] Address PR review feedback (#447) - floor the docker minor-line tag at its own X.Y max so an in-line-newest backfill advances :X.Y without moving :latest --- .github/workflows/release.yml | 38 ++++++++++++++++++++--------- scripts/test-resolve-release-tag.sh | 38 ++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e715112..eb994b13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -383,11 +383,14 @@ jobs: fi digests="$digests $IMAGE@sha256:$d" done - # The immutable tag always publishes. The moving tags only advance: - # the floor is the newest X.Y.Z tag already on the registry, not - # GitHub's latest-release pointer, which is operator-mutable. A 404 - # means the package does not exist yet (first publish); any other - # lookup failure aborts rather than move :latest on a guess. + # The immutable tag always publishes. Each moving tag only advances + # past its own floor on the registry, not GitHub's latest-release + # pointer, which is operator-mutable: :latest is floored at the + # newest X.Y.Z tag overall, and :X.Y at the newest tag in its own + # minor line, so a backfill that is newest in its line still moves + # :X.Y without touching :latest. A 404 means the package does not + # exist yet (first publish); any other lookup failure aborts rather + # than move tags on a guess. # shellcheck disable=SC2086 docker buildx imagetools create -t "$IMAGE:$VERSION" $digests versions_err="$(mktemp)" @@ -406,15 +409,28 @@ jobs: current_max="$(printf '%s\n' "$registry_versions" \ | { grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' || true; } \ | sort -V | tail -1)" + # MAJOR_MINOR comes from a strictly validated vX.Y.Z tag, but escape + # it anyway before embedding it in a regex. + mm_re="$(printf '%s' "$MAJOR_MINOR" | sed 's/[^0-9]/\\&/g')" + line_max="$(printf '%s\n' "$registry_versions" \ + | { grep -E "^${mm_re}\\.[0-9]+$" || true; } \ + | sort -V | tail -1)" newest="$(printf '%s\n%s\n' "${current_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" + line_newest="$(printf '%s\n%s\n' "${line_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" + moving=() if [ "$newest" = "$VERSION" ]; then - # shellcheck disable=SC2086 - docker buildx imagetools create \ - -t "$IMAGE:$MAJOR_MINOR" \ - -t "$IMAGE:latest" \ - $digests + moving+=(-t "$IMAGE:latest") + else + echo "::notice::$VERSION is older than registry newest $current_max; not moving :latest" + fi + if [ "$line_newest" = "$VERSION" ]; then + moving+=(-t "$IMAGE:$MAJOR_MINOR") else - echo "::notice::$VERSION is older than registry newest $current_max; not moving :$MAJOR_MINOR or :latest" + echo "::notice::$VERSION is older than $MAJOR_MINOR-line newest $line_max; not moving :$MAJOR_MINOR" + fi + if [ "${#moving[@]}" -gt 0 ]; then + # shellcheck disable=SC2086 + docker buildx imagetools create "${moving[@]}" $digests fi docker buildx imagetools inspect "$IMAGE:$VERSION" diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 472ac46e..c2498db6 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -310,11 +310,14 @@ job=docker-manifest fi digests="$digests $IMAGE@sha256:$d" done - # The immutable tag always publishes. The moving tags only advance: - # the floor is the newest X.Y.Z tag already on the registry, not - # GitHub's latest-release pointer, which is operator-mutable. A 404 - # means the package does not exist yet (first publish); any other - # lookup failure aborts rather than move :latest on a guess. + # The immutable tag always publishes. Each moving tag only advances + # past its own floor on the registry, not GitHub's latest-release + # pointer, which is operator-mutable: :latest is floored at the + # newest X.Y.Z tag overall, and :X.Y at the newest tag in its own + # minor line, so a backfill that is newest in its line still moves + # :X.Y without touching :latest. A 404 means the package does not + # exist yet (first publish); any other lookup failure aborts rather + # than move tags on a guess. # shellcheck disable=SC2086 docker buildx imagetools create -t "$IMAGE:$VERSION" $digests versions_err="$(mktemp)" @@ -333,15 +336,28 @@ job=docker-manifest current_max="$(printf '%s\n' "$registry_versions" \ | { grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' || true; } \ | sort -V | tail -1)" + # MAJOR_MINOR comes from a strictly validated vX.Y.Z tag, but escape + # it anyway before embedding it in a regex. + mm_re="$(printf '%s' "$MAJOR_MINOR" | sed 's/[^0-9]/\\&/g')" + line_max="$(printf '%s\n' "$registry_versions" \ + | { grep -E "^${mm_re}\\.[0-9]+$" || true; } \ + | sort -V | tail -1)" newest="$(printf '%s\n%s\n' "${current_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" + line_newest="$(printf '%s\n%s\n' "${line_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" + moving=() if [ "$newest" = "$VERSION" ]; then - # shellcheck disable=SC2086 - docker buildx imagetools create \ - -t "$IMAGE:$MAJOR_MINOR" \ - -t "$IMAGE:latest" \ - $digests + moving+=(-t "$IMAGE:latest") + else + echo "::notice::$VERSION is older than registry newest $current_max; not moving :latest" + fi + if [ "$line_newest" = "$VERSION" ]; then + moving+=(-t "$IMAGE:$MAJOR_MINOR") else - echo "::notice::$VERSION is older than registry newest $current_max; not moving :$MAJOR_MINOR or :latest" + echo "::notice::$VERSION is older than $MAJOR_MINOR-line newest $line_max; not moving :$MAJOR_MINOR" + fi + if [ "${#moving[@]}" -gt 0 ]; then + # shellcheck disable=SC2086 + docker buildx imagetools create "${moving[@]}" $digests fi docker buildx imagetools inspect "$IMAGE:$VERSION" From 7c33483b04955a06517872aa7f5df7e55319189b Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:44:58 -0500 Subject: [PATCH 14/23] Address PR review feedback (#447) - serialize all release runs under one concurrency group; the ref-keyed group let dispatches from different refs interleave --- .github/workflows/release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb994b13..27decd45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,10 @@ on: type: string concurrency: - group: release-${{ github.ref }} + # Not keyed on the ref: two dispatches from different refs would otherwise + # interleave, and every publish step reads a registry floor it then writes + # past, so runs of this workflow must serialize outright. + group: release cancel-in-progress: false # Least privilege by default; every job opts in to exactly what it needs. From c55fec59a54022fd72caa2e71f5cbacabfb1803c Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:35:50 -0500 Subject: [PATCH 15/23] ci(release): freeze docker moving tags on dispatch, retire the registry floor --- .github/workflows/release.yml | 73 ++++++---------- scripts/test-resolve-release-tag.sh | 125 +++++++++++++++++----------- 2 files changed, 99 insertions(+), 99 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27decd45..35f25aba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -356,14 +356,13 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Create and push multi-arch manifest + id: manifest env: VERSION: ${{ steps.rel.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail # ghcr requires a lowercase repository path. IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" - MAJOR_MINOR="${VERSION%.*}" # The docker matrix pushes one digest per arch leg; anything else is # a broken set, not a smaller multi-arch image. count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)" @@ -386,56 +385,32 @@ jobs: fi digests="$digests $IMAGE@sha256:$d" done - # The immutable tag always publishes. Each moving tag only advances - # past its own floor on the registry, not GitHub's latest-release - # pointer, which is operator-mutable: :latest is floored at the - # newest X.Y.Z tag overall, and :X.Y at the newest tag in its own - # minor line, so a backfill that is newest in its line still moves - # :X.Y without touching :latest. A 404 means the package does not - # exist yet (first publish); any other lookup failure aborts rather - # than move tags on a guess. + # The immutable tag always publishes. The moving tags are applied by + # the gated step below, which never runs on workflow_dispatch. # shellcheck disable=SC2086 docker buildx imagetools create -t "$IMAGE:$VERSION" $digests - versions_err="$(mktemp)" - registry_versions="$(gh api --paginate \ - "orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${GITHUB_REPOSITORY##*/}/versions?per_page=100" \ - -q '.[].metadata.container.tags[]' 2>"$versions_err")" || { - if grep -q 'HTTP 404' "$versions_err"; then - registry_versions="" - else - cat "$versions_err" >&2 - echo "::error::could not list ghcr versions; not moving tags blindly" - exit 1 - fi - } - rm -f "$versions_err" - current_max="$(printf '%s\n' "$registry_versions" \ - | { grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' || true; } \ - | sort -V | tail -1)" - # MAJOR_MINOR comes from a strictly validated vX.Y.Z tag, but escape - # it anyway before embedding it in a regex. - mm_re="$(printf '%s' "$MAJOR_MINOR" | sed 's/[^0-9]/\\&/g')" - line_max="$(printf '%s\n' "$registry_versions" \ - | { grep -E "^${mm_re}\\.[0-9]+$" || true; } \ - | sort -V | tail -1)" - newest="$(printf '%s\n%s\n' "${current_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" - line_newest="$(printf '%s\n%s\n' "${line_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" - moving=() - if [ "$newest" = "$VERSION" ]; then - moving+=(-t "$IMAGE:latest") - else - echo "::notice::$VERSION is older than registry newest $current_max; not moving :latest" - fi - if [ "$line_newest" = "$VERSION" ]; then - moving+=(-t "$IMAGE:$MAJOR_MINOR") - else - echo "::notice::$VERSION is older than $MAJOR_MINOR-line newest $line_max; not moving :$MAJOR_MINOR" - fi - if [ "${#moving[@]}" -gt 0 ]; then - # shellcheck disable=SC2086 - docker buildx imagetools create "${moving[@]}" $digests - fi docker buildx imagetools inspect "$IMAGE:$VERSION" + echo "digests=${digests# }" >> "$GITHUB_OUTPUT" + + - name: Move floating tags + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + env: + VERSION: ${{ steps.rel.outputs.version }} + DIGESTS: ${{ steps.manifest.outputs.digests }} + run: | + set -euo pipefail + # ghcr requires a lowercase repository path. + IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" + MAJOR_MINOR="${VERSION%.*}" + # :latest and :X.Y track the newest release. Only a push to main + # carries a release-please-cut version, and release-please versions + # are monotonic, so on this path the tags only ever advance. A + # workflow_dispatch backfill republishes an existing tag and must + # never move a pointer, so this step skips on dispatch. After a + # backfill that is genuinely newest in its minor line, advance :X.Y + # by hand: docker buildx imagetools create -t "$IMAGE:X.Y" . + # shellcheck disable=SC2086 + docker buildx imagetools create -t "$IMAGE:latest" -t "$IMAGE:$MAJOR_MINOR" $DIGESTS - name: Release summary env: diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index c2498db6..724d7209 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -180,13 +180,14 @@ expected_resolver_steps="$test_tmp/expected-resolver-steps" # change to one of these reviewed blocks must be reflected here deliberately. awk ' function emit_step() { - if (in_step && (is_rel || is_workflow_scripts_checkout || is_manifest || is_npm_publish || is_layin || is_tag_checkout || is_regen)) { + if (in_step && (is_rel || is_workflow_scripts_checkout || is_manifest || is_moving || is_npm_publish || is_layin || is_tag_checkout || is_regen)) { printf "job=%s\n%s", job, step } in_step = 0 is_rel = 0 is_workflow_scripts_checkout = 0 is_manifest = 0 + is_moving = 0 is_npm_publish = 0 is_layin = 0 is_tag_checkout = 0 @@ -207,6 +208,7 @@ awk ' in_step = 1 is_workflow_scripts_checkout = ($0 ~ /^ - name:[[:space:]]*Check out workflow scripts[[:space:]]*$/) is_manifest = ($0 ~ /^ - name:[[:space:]]*Create and push multi-arch manifest[[:space:]]*$/) + is_moving = ($0 ~ /^ - name:[[:space:]]*Move floating tags[[:space:]]*$/) is_npm_publish = ($0 ~ /^ - name:[[:space:]]*Publish[[:space:]]*$/) is_layin = ($0 ~ /^ - name:[[:space:]]*Lay in release binaries[[:space:]]*$/) is_tag_checkout = ($0 ~ /^ - name:[[:space:]]*Checkout release tag[[:space:]]*$/ || $0 ~ /^ - name:[[:space:]]*Checkout node \(release tag\)[[:space:]]*$/) @@ -280,14 +282,13 @@ job=docker-manifest job=docker-manifest - name: Create and push multi-arch manifest + id: manifest env: VERSION: ${{ steps.rel.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail # ghcr requires a lowercase repository path. IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" - MAJOR_MINOR="${VERSION%.*}" # The docker matrix pushes one digest per arch leg; anything else is # a broken set, not a smaller multi-arch image. count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)" @@ -310,56 +311,33 @@ job=docker-manifest fi digests="$digests $IMAGE@sha256:$d" done - # The immutable tag always publishes. Each moving tag only advances - # past its own floor on the registry, not GitHub's latest-release - # pointer, which is operator-mutable: :latest is floored at the - # newest X.Y.Z tag overall, and :X.Y at the newest tag in its own - # minor line, so a backfill that is newest in its line still moves - # :X.Y without touching :latest. A 404 means the package does not - # exist yet (first publish); any other lookup failure aborts rather - # than move tags on a guess. + # The immutable tag always publishes. The moving tags are applied by + # the gated step below, which never runs on workflow_dispatch. # shellcheck disable=SC2086 docker buildx imagetools create -t "$IMAGE:$VERSION" $digests - versions_err="$(mktemp)" - registry_versions="$(gh api --paginate \ - "orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${GITHUB_REPOSITORY##*/}/versions?per_page=100" \ - -q '.[].metadata.container.tags[]' 2>"$versions_err")" || { - if grep -q 'HTTP 404' "$versions_err"; then - registry_versions="" - else - cat "$versions_err" >&2 - echo "::error::could not list ghcr versions; not moving tags blindly" - exit 1 - fi - } - rm -f "$versions_err" - current_max="$(printf '%s\n' "$registry_versions" \ - | { grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' || true; } \ - | sort -V | tail -1)" - # MAJOR_MINOR comes from a strictly validated vX.Y.Z tag, but escape - # it anyway before embedding it in a regex. - mm_re="$(printf '%s' "$MAJOR_MINOR" | sed 's/[^0-9]/\\&/g')" - line_max="$(printf '%s\n' "$registry_versions" \ - | { grep -E "^${mm_re}\\.[0-9]+$" || true; } \ - | sort -V | tail -1)" - newest="$(printf '%s\n%s\n' "${current_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" - line_newest="$(printf '%s\n%s\n' "${line_max:-0.0.0}" "$VERSION" | sort -V | tail -1)" - moving=() - if [ "$newest" = "$VERSION" ]; then - moving+=(-t "$IMAGE:latest") - else - echo "::notice::$VERSION is older than registry newest $current_max; not moving :latest" - fi - if [ "$line_newest" = "$VERSION" ]; then - moving+=(-t "$IMAGE:$MAJOR_MINOR") - else - echo "::notice::$VERSION is older than $MAJOR_MINOR-line newest $line_max; not moving :$MAJOR_MINOR" - fi - if [ "${#moving[@]}" -gt 0 ]; then - # shellcheck disable=SC2086 - docker buildx imagetools create "${moving[@]}" $digests - fi docker buildx imagetools inspect "$IMAGE:$VERSION" + echo "digests=${digests# }" >> "$GITHUB_OUTPUT" + +job=docker-manifest + - name: Move floating tags + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + env: + VERSION: ${{ steps.rel.outputs.version }} + DIGESTS: ${{ steps.manifest.outputs.digests }} + run: | + set -euo pipefail + # ghcr requires a lowercase repository path. + IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" + MAJOR_MINOR="${VERSION%.*}" + # :latest and :X.Y track the newest release. Only a push to main + # carries a release-please-cut version, and release-please versions + # are monotonic, so on this path the tags only ever advance. A + # workflow_dispatch backfill republishes an existing tag and must + # never move a pointer, so this step skips on dispatch. After a + # backfill that is genuinely newest in its minor line, advance :X.Y + # by hand: docker buildx imagetools create -t "$IMAGE:X.Y" . + # shellcheck disable=SC2086 + docker buildx imagetools create -t "$IMAGE:latest" -t "$IMAGE:$MAJOR_MINOR" $DIGESTS job=release-binaries - name: Check out workflow scripts @@ -638,6 +616,53 @@ if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then exit 1 fi +# The moving-tag freeze is a structural property of the whole workflow, so it +# must be checked over EVERY step, not only the pinned set above: an +# imagetools call carrying :latest or :$MAJOR_MINOR relocated into an unpinned +# step would otherwise iterate zero pinned steps and pass vacuously. Exactly +# one step may carry that call, and it must be gated on both the event and the +# ref: the on.push.branches trigger sits outside every pin and assertion, so +# an event-only gate would silently widen with it. No step may read the +# retired packages/container endpoint at all. +if ! awk ' + function flush( has_call, has_guard) { + if (!in_step) return + has_call = (step ~ /imagetools create/ \ + && step ~ /-t[[:space:]]+"\$IMAGE:(latest|\$MAJOR_MINOR)"/) + has_guard = (step ~ /github\.event_name == .push./ \ + && step ~ /github\.ref == .refs\/heads\/main./) + if (has_call) { + calls++ + if (!has_guard) ungated++ + } + if (step ~ /packages\/container/) endpoint++ + } + /^ - / { flush(); in_step = 1; step = $0 ORS; next } + in_step { step = step $0 ORS } + END { + flush() + ok = 1 + if (calls != 1) { + printf "expected exactly one floating-tag imagetools step, found %d\n", \ + calls > "/dev/stderr" + ok = 0 + } + if (ungated) { + printf "%d floating-tag imagetools step(s) lack the push-to-main guard\n", \ + ungated > "/dev/stderr" + ok = 0 + } + if (endpoint) { + print "retired packages/container read present in a workflow step" \ + > "/dev/stderr" + ok = 0 + } + if (!ok) exit 1 + } +' "$release_workflow"; then + exit 1 +fi + # Every job declares the protected environment, not only the registry publish # jobs: workflow_dispatch runs the selected ref's YAML, so the environment's # deployment-branch rule is the control that keeps a dispatch of unmodified From 57968b05d53684f734af0c098858926bc5401a82 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:40:14 -0500 Subject: [PATCH 16/23] ci(release): make the resolver test's gh stub discriminate on the full call --- scripts/test-resolve-release-tag.sh | 80 +++++++++++++++++++---------- 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 724d7209..51249e31 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -69,36 +69,51 @@ stub_bin="$test_tmp/stub-bin" mkdir -p "$stub_bin" cat > "$stub_bin/gh" <<'STUB' #!/usr/bin/env bash -case "$1" in - api) - case "$2" in - */commits/*) - printf '%s\n' "0000000000000000000000000000000000000000" - ;; - */compare/*) - printf '%s\n' "${STUB_STATUS:?STUB_STATUS unset}" - ;; - */releases/tags/*) - # Emitted as base64(name) id uploader, matching the resolver's jq. - printf '%s 11 %s\n' \ - "$(printf '%s' 'gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz' | base64 -w0)" \ - "${STUB_UPLOADERS:-github-actions[bot]}" - # STUB_EVIL_NAME simulates an attacker-crafted asset name carrying a - # fake uploader inside it; base64 keeps it a single first field and - # the real uploader stays in $3. - if [ "${STUB_EVIL_NAME:-0}" = "1" ]; then - printf '%s 999 collaborator\n' \ - "$(printf '%s' 'gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz 999 github-actions[bot]' | base64 -w0)" - fi - ;; - esac - ;; - release) +# Match the full command line, not a path fragment: an unrecognized call exits +# 1 and names itself on stderr, so a new or mistyped gh call site fails loudly +# instead of receiving a silently stubbed answer. +case "$*" in + "release view "*" --repo Gitlawb/node --json author,targetCommitish "*) [ "${STUB_RELEASE_EXISTS:-0}" = "1" ] || exit 1 printf '%s %s\n' \ "${STUB_RELEASE_AUTHOR:-github-actions[bot]}" \ "${STUB_TARGET:-0000000000000000000000000000000000000000}" ;; + "api repos/Gitlawb/node/releases/tags/"*" "*) + # Run the resolver's own -q expression through real jq on a fixture + # response, so a dropped `| @base64` emits raw names and STUB_EVIL_NAME + # shifts the positional fields exactly as it would in production. + expr="" + prev="" + for a in "$@"; do + if [ "$prev" = "-q" ]; then expr="$a"; fi + prev="$a" + done + fixture='{"assets":[{"name":"gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz","id":11,"uploader":{"login":"'"${STUB_UPLOADERS:-github-actions[bot]}"'"}}' + # STUB_EVIL_NAME simulates an attacker-crafted asset name carrying a fake + # uploader inside it; base64 keeps it a single first field and the real + # uploader stays in $3, while a raw name lets the smuggled field through. + if [ "${STUB_EVIL_NAME:-0}" = "1" ]; then + fixture="$fixture"',{"name":"gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz 999 github-actions[bot]","id":999,"uploader":{"login":"collaborator"}}' + fi + printf '%s\n' "$fixture"']}' | jq -r "$expr" + ;; + "api repos/Gitlawb/node/commits/refs/tags/"*" "*) + printf '%s\n' "0000000000000000000000000000000000000000" + ;; + "api repos/Gitlawb/node/commits/"*" "*) + # A bare tag name resolves through refs/heads before refs/tags. Return a + # different SHA so a resolver that dropped the refs/tags/ qualification + # reads the same-named branch's commit and the tag-moved check fires. + printf '%s\n' "1111111111111111111111111111111111111111" + ;; + "api repos/Gitlawb/node/compare/main..."*" "*) + printf '%s\n' "${STUB_STATUS:?STUB_STATUS unset}" + ;; + *) + printf 'unexpected gh call: %s\n' "$*" >&2 + exit 1 + ;; esac STUB chmod +x "$stub_bin/gh" @@ -113,7 +128,7 @@ run_resolver_ci() { STUB_TARGET="${6:-0000000000000000000000000000000000000000}" \ STUB_EVIL_NAME="${STUB_EVIL_NAME:-0}" \ GITHUB_OUTPUT="$test_tmp/prov-output" \ - "$resolver" "$3" >/dev/null 2>&1 + "$resolver" "$3" >"$test_tmp/prov-stdout" 2>"$test_tmp/prov-stderr" } if ! run_resolver_ci behind 1 v9.9.9; then @@ -170,6 +185,19 @@ if STUB_EVIL_NAME=1 run_resolver_ci behind 1 v9.9.9; then printf '%s\n' "provenance: crafted asset name unexpectedly passed" >&2 exit 1 fi +# The stub's contract is loud failure: a call it does not recognize must exit +# non-zero and name the command line on stderr, so a resolver that gains a new +# gh call cannot pass on a silently empty stubbed answer. +if "$stub_bin/gh" api repos/Gitlawb/node/rate_limit >"$test_tmp/unexp-out" \ + 2>"$test_tmp/unexp-err"; then + printf '%s\n' "stub accepted an unrecognized gh call" >&2 + exit 1 +fi +if ! grep -qF 'unexpected gh call: api repos/Gitlawb/node/rate_limit' \ + "$test_tmp/unexp-err"; then + printf '%s\n' "stub's unrecognized-call stderr did not name the command line" >&2 + exit 1 +fi release_workflow="$repo_root/.github/workflows/release.yml" actual_resolver_steps="$test_tmp/actual-resolver-steps" From d02bb320f0c32dad49429d98ad022890cb7f4d1a Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:42:15 -0500 Subject: [PATCH 17/23] ci(release): assert the pinned npm version took effect --- .github/workflows/release.yml | 10 ++++++++- scripts/test-resolve-release-tag.sh | 35 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35f25aba..c9876452 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -644,7 +644,15 @@ jobs: run: | set -euo pipefail npm install -g npm@11.19.1 - npm --version + # Assert the pin took, so an accidental unpin or a drift off 11.19.1 + # fails loudly here instead of silently running a different npm on + # the OIDC path. + installed="$(npm --version)" + echo "npm installed: $installed" + if [ "$installed" != "11.19.1" ]; then + echo "::error::npm is not the pinned 11.19.1 (got: $installed)" + exit 1 + fi - name: Lay in release binaries env: diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 51249e31..76e8b83f 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -767,4 +767,39 @@ while IFS= read -r spec; do fi done < "$npm_install_specs" +# A pinned install alone does not prove the pin took: the install line could +# drift, be shadowed by a PATH entry, or install a resolved-otherwise version +# and nothing would notice. Require, per step that installs npm, that the SAME +# step body runs `npm --version` and compares something against the pinned +# literal: `!= "X.Y.Z"` or `== "X.Y.Z"` outside the install spec itself. A +# comment or error message that merely names the version cannot satisfy it, +# and the required set derives from the workflow's own install lines, so a +# second install elsewhere cannot ride on the first step's assertion. +if ! awk ' + function flush( m, v, ev, cmp, rest) { + if (!in_step) return + m = step + while (match(m, /npm install -g npm@[0-9]+\.[0-9]+\.[0-9]+/)) { + v = substr(m, RSTART, RLENGTH) + sub(/^.*npm@/, "", v) + ev = v + gsub(/\./, "\\.", ev) + cmp = "(!=|==)[[:space:]]*\"?" ev "\"?" + rest = step + gsub(/npm install -g npm@[0-9]+\.[0-9]+\.[0-9]+/, "", rest) + if (index(rest, "npm --version") == 0 || rest !~ cmp) { + printf "step installs npm@%s without asserting the pin took\n", v \ + > "/dev/stderr" + bad = 1 + } + m = substr(m, RSTART + RLENGTH) + } + } + /^ - / { flush(); in_step = 1; step = $0 ORS; next } + in_step { step = step $0 ORS } + END { flush(); if (bad) exit 1 } +' "$release_workflow"; then + exit 1 +fi + printf '%s\n' "release tag validation tests passed" From c5d2dbc0a9866a78785cd53fd09902fc5cfe85ac Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:42:51 -0500 Subject: [PATCH 18/23] ci(release): surface missing-asset errors from the formula sha() subshell --- .github/workflows/release.yml | 2 +- scripts/test-resolve-release-tag.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9876452..fbf38e11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -822,7 +822,7 @@ jobs: want="$(printf '%s' "$archive" | base64 -w0)" asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')" if [ -z "$asset_id" ]; then - echo "::error::release $TAG has no asset $archive captured at resolve time" + echo "::error::release $TAG has no asset $archive captured at resolve time" >&2 exit 1 fi gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \ diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 76e8b83f..c351a85f 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -544,7 +544,7 @@ job=homebrew-bump want="$(printf '%s' "$archive" | base64 -w0)" asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')" if [ -z "$asset_id" ]; then - echo "::error::release $TAG has no asset $archive captured at resolve time" + echo "::error::release $TAG has no asset $archive captured at resolve time" >&2 exit 1 fi gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \ From 13a1fff8d691f8ed20d27d910d7c8132e28152f9 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:44:40 -0500 Subject: [PATCH 19/23] ci(release): consume resolver outputs instead of release-please outputs post-resolve --- .github/workflows/release.yml | 16 +++++----- scripts/test-resolve-release-tag.sh | 49 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbf38e11..f5269a93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -520,7 +520,7 @@ jobs: shell: bash run: | set -euo pipefail - VERSION="${{ needs.release-please.outputs.version }}" + VERSION="${{ steps.rel.outputs.version }}" BIN_DIR="target/${{ matrix.target }}/release" EXE="" case "${{ matrix.target }}" in *windows*) EXE=".exe" ;; esac @@ -540,7 +540,7 @@ jobs: run: | set -euo pipefail TARGET="${{ matrix.target }}" - NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${TARGET}" + NAME="gitlawb-node-${{ steps.rel.outputs.version }}-${TARGET}" BIN_DIR="target/${TARGET}/release" EXE="" case "$TARGET" in *windows*) EXE=".exe" ;; esac @@ -581,7 +581,7 @@ jobs: - name: Attach to release uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 with: - tag_name: ${{ needs.release-please.outputs.tag_name }} + tag_name: ${{ steps.rel.outputs.tag }} files: | dist/*.tar.gz dist/*.zip @@ -889,7 +889,7 @@ jobs: working-directory: tap env: HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }} - VERSION: ${{ needs.release-please.outputs.version }} + VERSION: ${{ steps.rel.outputs.version }} run: | set -euo pipefail git config user.name "github-actions[bot]" @@ -967,8 +967,8 @@ jobs: if: ${{ steps.guard.outputs.enabled == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.release-please.outputs.version }} - TAG: ${{ needs.release-please.outputs.tag_name }} + VERSION: ${{ steps.rel.outputs.version }} + TAG: ${{ steps.rel.outputs.tag }} run: | set -euo pipefail gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \ @@ -986,8 +986,8 @@ jobs: if: ${{ steps.guard.outputs.enabled == 'true' }} env: GH_TOKEN: ${{ secrets.WEB_SYNC_PAT }} - VERSION: ${{ needs.release-please.outputs.version }} - TAG: ${{ needs.release-please.outputs.tag_name }} + VERSION: ${{ steps.rel.outputs.version }} + TAG: ${{ steps.rel.outputs.tag }} run: | set -euo pipefail # Canonical install scripts (source of truth lives in the node repo). diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index c351a85f..20bcd207 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -802,4 +802,53 @@ if ! awk ' exit 1 fi +# Once a job's resolver step (id: rel) has run, later steps must consume the +# resolver's outputs, not needs.release-please.outputs.version/tag_name: the +# resolver is the boundary that proved the tag, and a second derivation of the +# same value can drift from what was verified. The rel step itself may read +# tag_name as its RELEASE_TAG input. The job set derives from which jobs +# actually carry a rel step. +stale_reads="$test_tmp/stale-release-please-reads" +awk ' + function flush( n, L, name, i) { + if (!in_step) return + if (step ~ /\n id:[[:space:]]*rel[[:space:]]*\n/) { + rel_job[job] = 1 + return + } + n = split(step, L, "\n") + name = L[1] + for (i = 2; i <= n; i++) { + if (L[i] ~ /needs\.release-please\.outputs\.(version|tag_name)/) { + flagged[++nf] = job " :: " name " :: " L[i] + flag_job[nf] = job + } + } + } + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + flush() + job = $0 + sub(/^ /, "", job) + sub(/:[[:space:]]*$/, "", job) + in_step = 0 + next + } + /^ - / { flush(); in_step = 1; step = $0 ORS; next } + in_step { step = step $0 ORS } + END { + flush() + for (i = 1; i <= nf; i++) { + if (flag_job[i] in rel_job) print flagged[i] + } + } +' "$release_workflow" > "$stale_reads" + +if [ -s "$stale_reads" ]; then + printf '%s\n' \ + "post-resolve steps read release-please outputs instead of the resolver's:" \ + >&2 + cat "$stale_reads" >&2 + exit 1 +fi + printf '%s\n' "release tag validation tests passed" From e455db10de4034b2ec2c9f82c6c3fe2c97dafe27 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:45:03 -0500 Subject: [PATCH 20/23] ci(release): document the concurrency group's pending-slot replacement --- .github/workflows/release.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5269a93..c4b02b6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,9 +20,14 @@ on: type: string concurrency: - # Not keyed on the ref: two dispatches from different refs would otherwise - # interleave, and every publish step reads a registry floor it then writes - # past, so runs of this workflow must serialize outright. + # Not keyed on the ref: two runs racing `imagetools create` on the same + # moving tags could interleave, so runs of this workflow serialize outright. + # GitHub keeps at most one running and one pending run per group; a newer + # queued run replaces the pending one, and cancel-in-progress: false + # protects only the run already executing. So a backfill dispatched while a + # release is running, followed by a push to main, is replaced silently: the + # remedy is to re-dispatch it once the running release completes, which the + # push-only gate on the moving-tag step makes safe in any ordering. group: release cancel-in-progress: false From ef4d29aee146b8f677a1f5085173a3be63b808b9 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:59:57 -0500 Subject: [PATCH 21/23] ci(release): share the image output and bound the step scanners to jobs The docker-manifest rel step now emits image= the same way the docker job's does, and both manifest steps consume steps.rel.outputs.image instead of recomputing the lowercase ghcr path. The three whole-workflow awk scanners now ignore everything before jobs: and flush at job boundaries, so an `on.push.branches` list item can no longer open a pseudo-step that swallows top-level YAML, and a step record can no longer absorb the next job's header lines. --- .github/workflows/release.yml | 10 +++--- scripts/test-resolve-release-tag.sh | 48 +++++++++++++++++------------ 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4b02b6e..6c9b850f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -342,6 +342,10 @@ jobs: 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" - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -364,10 +368,9 @@ jobs: id: manifest env: VERSION: ${{ steps.rel.outputs.version }} + IMAGE: ${{ steps.rel.outputs.image }} run: | set -euo pipefail - # ghcr requires a lowercase repository path. - IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" # The docker matrix pushes one digest per arch leg; anything else is # a broken set, not a smaller multi-arch image. count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)" @@ -401,11 +404,10 @@ jobs: if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} env: VERSION: ${{ steps.rel.outputs.version }} + IMAGE: ${{ steps.rel.outputs.image }} DIGESTS: ${{ steps.manifest.outputs.digests }} run: | set -euo pipefail - # ghcr requires a lowercase repository path. - IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" MAJOR_MINOR="${VERSION%.*}" # :latest and :X.Y track the newest release. Only a push to main # carries a release-please-cut version, and release-please versions diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 20bcd207..a5120d01 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -307,16 +307,19 @@ job=docker-manifest 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: Create and push multi-arch manifest id: manifest env: VERSION: ${{ steps.rel.outputs.version }} + IMAGE: ${{ steps.rel.outputs.image }} run: | set -euo pipefail - # ghcr requires a lowercase repository path. - IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" # The docker matrix pushes one digest per arch leg; anything else is # a broken set, not a smaller multi-arch image. count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)" @@ -351,11 +354,10 @@ job=docker-manifest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} env: VERSION: ${{ steps.rel.outputs.version }} + IMAGE: ${{ steps.rel.outputs.image }} DIGESTS: ${{ steps.manifest.outputs.digests }} run: | set -euo pipefail - # ghcr requires a lowercase repository path. - IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" MAJOR_MINOR="${VERSION%.*}" # :latest and :X.Y track the newest release. Only a push to main # carries a release-please-cut version, and release-please versions @@ -645,13 +647,14 @@ if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then fi # The moving-tag freeze is a structural property of the whole workflow, so it -# must be checked over EVERY step, not only the pinned set above: an -# imagetools call carrying :latest or :$MAJOR_MINOR relocated into an unpinned -# step would otherwise iterate zero pinned steps and pass vacuously. Exactly -# one step may carry that call, and it must be gated on both the event and the -# ref: the on.push.branches trigger sits outside every pin and assertion, so -# an event-only gate would silently widen with it. No step may read the -# retired packages/container endpoint at all. +# must be checked over every step in `jobs:`, not only the pinned set above: +# an imagetools call carrying :latest or :$MAJOR_MINOR relocated into an +# unpinned step would otherwise iterate zero pinned steps and pass vacuously. +# Exactly one step may carry that call (the step named "Move floating tags"), +# and it must be gated on both the event and the ref: the on.push.branches +# trigger sits outside every pin and assertion, so an event-only gate would +# silently widen with it. No step may read the retired packages/container +# endpoint at all. if ! awk ' function flush( has_call, has_guard) { if (!in_step) return @@ -665,18 +668,20 @@ if ! awk ' } if (step ~ /packages\/container/) endpoint++ } - /^ - / { flush(); in_step = 1; step = $0 ORS; next } - in_step { step = step $0 ORS } + /^jobs:[[:space:]]*$/ { in_jobs = 1; next } + in_jobs && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { flush(); in_step = 0; next } + in_jobs && /^ - / { flush(); in_step = 1; step = $0 ORS; next } + in_jobs && in_step { step = step $0 ORS } END { flush() ok = 1 if (calls != 1) { - printf "expected exactly one floating-tag imagetools step, found %d\n", \ + printf "expected exactly one moving-tag imagetools step, found %d\n", \ calls > "/dev/stderr" ok = 0 } if (ungated) { - printf "%d floating-tag imagetools step(s) lack the push-to-main guard\n", \ + printf "%d moving-tag imagetools step(s) lack the push-to-main guard\n", \ ungated > "/dev/stderr" ok = 0 } @@ -795,8 +800,10 @@ if ! awk ' m = substr(m, RSTART + RLENGTH) } } - /^ - / { flush(); in_step = 1; step = $0 ORS; next } - in_step { step = step $0 ORS } + /^jobs:[[:space:]]*$/ { in_jobs = 1; next } + in_jobs && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { flush(); in_step = 0; next } + in_jobs && /^ - / { flush(); in_step = 1; step = $0 ORS; next } + in_jobs && in_step { step = step $0 ORS } END { flush(); if (bad) exit 1 } ' "$release_workflow"; then exit 1 @@ -825,7 +832,8 @@ awk ' } } } - /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + /^jobs:[[:space:]]*$/ { in_jobs = 1; next } + in_jobs && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { flush() job = $0 sub(/^ /, "", job) @@ -833,8 +841,8 @@ awk ' in_step = 0 next } - /^ - / { flush(); in_step = 1; step = $0 ORS; next } - in_step { step = step $0 ORS } + in_jobs && /^ - / { flush(); in_step = 1; step = $0 ORS; next } + in_jobs && in_step { step = step $0 ORS } END { flush() for (i = 1; i <= nf; i++) { From d2d6660a91c49721145d6bab45659dcbf0d41853 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:42:04 -0500 Subject: [PATCH 22/23] ci(release): close the review-found evasions in the release checks Review of the follow-up diff found the structural assertions could be satisfied or evaded without the behavior they guard: - the gh stub now pipes a fixture response through real jq with the caller's own -q on every arm, so a resolver that hardcodes its expected fields is detected - the moving-tag scan keys on the outcome (a push mechanism plus a mutable-tag reference in decommented step text) and requires the event+ref guard on the carrier's if: line, instead of binding one argv spelling and accepting comment-borne guard text - a new assertion pins the on.push.branches / workflow_dispatch triggers, which no step-level check could see - the npm check detects install/i and -g/--global variants, requires a non-empty install set, and reads the pin comparison from code, not comments - the stale-reads scan covers first step lines, job-level env/if lines, and jobs without a resolver step - the resolver test asserts the GHAE heredoc terminator, clears ambient GH_TOKEN/GITHUB_REPOSITORY on non-stub calls, and prints the captured resolver stderr when a pass-expected case fails - sha() exits on a failed download, since errexit is off inside its command substitution and a truncated file would otherwise be hashed --- .github/workflows/release.yml | 8 +- scripts/test-resolve-release-tag.sh | 293 +++++++++++++++++++--------- 2 files changed, 205 insertions(+), 96 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c9b850f..e81f3ea7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -343,7 +343,7 @@ jobs: 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 + # buildx's `--output name=` does no lowercasing, so a mixed-case owner # makes the digest push fail with "invalid reference format". echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" @@ -832,8 +832,12 @@ jobs: echo "::error::release $TAG has no asset $archive captured at resolve time" >&2 exit 1 fi + # errexit is off inside the $(...) this runs under, so a failed + # download must exit the function itself instead of hashing a + # truncated file. gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \ - -H 'Accept: application/octet-stream' > "_sums/$archive" + -H 'Accept: application/octet-stream' > "_sums/$archive" \ + || exit 1 sha256sum "_sums/$archive" | awk '{print $1}' } SHA_MAC_ARM="$(sha aarch64-apple-darwin)" diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index a5120d01..72e88983 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -6,8 +6,12 @@ resolver="$repo_root/scripts/resolve-release-tag.sh" test_tmp="$(mktemp -d)" trap 'rm -r -- "$test_tmp"' EXIT +# The non-stub invocations must not see ambient CI credentials: with GH_TOKEN +# and GITHUB_REPOSITORY exported, the resolver takes its provenance branch and +# calls the real gh, so the suite's outcome would depend on the environment. valid_output="$test_tmp/valid-output" -GITHUB_OUTPUT="$valid_output" "$resolver" "v1.2.3" +env -u GH_TOKEN -u GITHUB_REPOSITORY 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" @@ -16,7 +20,8 @@ cmp "$expected_output" "$valid_output" newline_output="$test_tmp/newline-output" newline_stdout="$test_tmp/newline-stdout" newline_stderr="$test_tmp/newline-stderr" -if GITHUB_OUTPUT="$newline_output" "$resolver" $'v1.2.3\nname=owned' \ +if env -u GH_TOKEN -u GITHUB_REPOSITORY 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 @@ -27,7 +32,8 @@ grep -qxF "::error::release tag is empty or contains invalid characters" "$newli test ! -s "$newline_stderr" empty_output="$test_tmp/empty-output" -if GITHUB_OUTPUT="$empty_output" "$resolver" ""; then +if env -u GH_TOKEN -u GITHUB_REPOSITORY GITHUB_OUTPUT="$empty_output" \ + "$resolver" ""; then printf '%s\n' "empty release tag unexpectedly passed" >&2 exit 1 fi @@ -53,7 +59,8 @@ for invalid_tag in \ "v1..3" do : > "$invalid_output" - if GITHUB_OUTPUT="$invalid_output" "$resolver" "$invalid_tag"; then + if env -u GH_TOKEN -u GITHUB_REPOSITORY GITHUB_OUTPUT="$invalid_output" \ + "$resolver" "$invalid_tag"; then printf '%s\n' "invalid release tag unexpectedly passed: $invalid_tag" >&2 exit 1 fi @@ -72,43 +79,48 @@ cat > "$stub_bin/gh" <<'STUB' # Match the full command line, not a path fragment: an unrecognized call exits # 1 and names itself on stderr, so a new or mistyped gh call site fails loudly # instead of receiving a silently stubbed answer. +# Every arm runs the resolver's own -q expression through real jq on a +# fixture response: a mutated or dropped field in the expression misparses +# the same way it would against the live API. +run_jq() { + expr="" + prev="" + for a in "$@"; do + if [ "$prev" = "-q" ]; then expr="$a"; fi + prev="$a" + done + jq -r "$expr" +} case "$*" in "release view "*" --repo Gitlawb/node --json author,targetCommitish "*) [ "${STUB_RELEASE_EXISTS:-0}" = "1" ] || exit 1 - printf '%s %s\n' \ - "${STUB_RELEASE_AUTHOR:-github-actions[bot]}" \ - "${STUB_TARGET:-0000000000000000000000000000000000000000}" + printf '%s\n' '{"author":{"login":"'"${STUB_RELEASE_AUTHOR:-github-actions[bot]}"'"},"targetCommitish":"'"${STUB_TARGET:-0000000000000000000000000000000000000000}"'"}' \ + | run_jq "$@" ;; "api repos/Gitlawb/node/releases/tags/"*" "*) - # Run the resolver's own -q expression through real jq on a fixture - # response, so a dropped `| @base64` emits raw names and STUB_EVIL_NAME - # shifts the positional fields exactly as it would in production. - expr="" - prev="" - for a in "$@"; do - if [ "$prev" = "-q" ]; then expr="$a"; fi - prev="$a" - done - fixture='{"assets":[{"name":"gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz","id":11,"uploader":{"login":"'"${STUB_UPLOADERS:-github-actions[bot]}"'"}}' # STUB_EVIL_NAME simulates an attacker-crafted asset name carrying a fake # uploader inside it; base64 keeps it a single first field and the real # uploader stays in $3, while a raw name lets the smuggled field through. + fixture='{"assets":[{"name":"gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz","id":11,"uploader":{"login":"'"${STUB_UPLOADERS:-github-actions[bot]}"'"}}' if [ "${STUB_EVIL_NAME:-0}" = "1" ]; then fixture="$fixture"',{"name":"gitlawb-node-9.9.9-x86_64-unknown-linux-musl.tar.gz 999 github-actions[bot]","id":999,"uploader":{"login":"collaborator"}}' fi - printf '%s\n' "$fixture"']}' | jq -r "$expr" + printf '%s\n' "$fixture"']}' | run_jq "$@" ;; "api repos/Gitlawb/node/commits/refs/tags/"*" "*) - printf '%s\n' "0000000000000000000000000000000000000000" + printf '%s\n' '{"sha":"0000000000000000000000000000000000000000"}' \ + | run_jq "$@" ;; "api repos/Gitlawb/node/commits/"*" "*) # A bare tag name resolves through refs/heads before refs/tags. Return a # different SHA so a resolver that dropped the refs/tags/ qualification # reads the same-named branch's commit and the tag-moved check fires. - printf '%s\n' "1111111111111111111111111111111111111111" + printf '%s\n' '{"sha":"1111111111111111111111111111111111111111"}' \ + | run_jq "$@" ;; "api repos/Gitlawb/node/compare/main..."*" "*) - printf '%s\n' "${STUB_STATUS:?STUB_STATUS unset}" + printf '%s\n' '{"status":"'"${STUB_STATUS:?STUB_STATUS unset}"'"}' \ + | run_jq "$@" ;; *) printf 'unexpected gh call: %s\n' "$*" >&2 @@ -132,6 +144,7 @@ run_resolver_ci() { } if ! run_resolver_ci behind 1 v9.9.9; then + cat "$test_tmp/prov-stderr" >&2 printf '%s\n' "provenance: release tag reachable from main rejected" >&2 exit 1 fi @@ -149,7 +162,19 @@ if ! grep -q 'assets<&2 exit 1 fi +# An unterminated heredoc block would swallow tag_commit= into the multiline +# value and leave the checkout-pinning output empty. +if ! awk ' + /^assets<&2 + exit 1 +fi if ! run_resolver_ci identical 1 v9.9.9; then + cat "$test_tmp/prov-stderr" >&2 printf '%s\n' "provenance: release tag at main tip rejected" >&2 exit 1 fi @@ -308,7 +333,7 @@ job=docker-manifest 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 + # buildx's `--output name=` does no lowercasing, so a mixed-case owner # makes the digest push fail with "invalid reference format". echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" @@ -549,8 +574,12 @@ job=homebrew-bump echo "::error::release $TAG has no asset $archive captured at resolve time" >&2 exit 1 fi + # errexit is off inside the $(...) this runs under, so a failed + # download must exit the function itself instead of hashing a + # truncated file. gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \ - -H 'Accept: application/octet-stream' > "_sums/$archive" + -H 'Accept: application/octet-stream' > "_sums/$archive" \ + || exit 1 sha256sum "_sums/$archive" | awk '{print $1}' } SHA_MAC_ARM="$(sha aarch64-apple-darwin)" @@ -648,25 +677,52 @@ fi # The moving-tag freeze is a structural property of the whole workflow, so it # must be checked over every step in `jobs:`, not only the pinned set above: -# an imagetools call carrying :latest or :$MAJOR_MINOR relocated into an -# unpinned step would otherwise iterate zero pinned steps and pass vacuously. -# Exactly one step may carry that call (the step named "Move floating tags"), -# and it must be gated on both the event and the ref: the on.push.branches -# trigger sits outside every pin and assertion, so an event-only gate would -# silently widen with it. No step may read the retired packages/container -# endpoint at all. +# a floating-tag push relocated into an unpinned step would otherwise iterate +# zero pinned steps and pass vacuously. The check keys on the outcome, not +# one argv spelling: any step whose code pairs a tag-pushing mechanism +# (imagetools create, docker push, a build-push-action tags: input, crane, +# regctl, skopeo, oras) with a mutable-tag reference (latest or MAJOR_MINOR) +# is a carrier, and exactly one may exist (the step named "Move floating +# tags"), gated on both the event and the ref. Matching on code text with +# comments stripped keeps a comment quoting either pattern from counting. +# The guard must sit on the step's if: line as a single && condition, so an +# ||-weakened or comment-borne condition cannot satisfy it. No step may read +# the retired packages/container endpoint at all. if ! awk ' - function flush( has_call, has_guard) { + function decomment(s, n, L, i, j, c, q, line, out) { + n = split(s, L, "\n") + out = "" + for (i = 1; i <= n; i++) { + line = "" + q = 0 + for (j = 1; j <= length(L[i]); j++) { + c = substr(L[i], j, 1) + if (c == "\"") q = !q + if (c == "#" && !q) break + line = line c + } + out = out line "\n" + } + return out + } + function flush( code, has_call, has_guard, k, K) { if (!in_step) return - has_call = (step ~ /imagetools create/ \ - && step ~ /-t[[:space:]]+"\$IMAGE:(latest|\$MAJOR_MINOR)"/) - has_guard = (step ~ /github\.event_name == .push./ \ - && step ~ /github\.ref == .refs\/heads\/main./) + code = decomment(step) + has_call = (code ~ /imagetools[ \t]+create|docker[ \t]+push|docker[ \t]+buildx[ \t]+build|crane[ \t]|regctl[ \t]|skopeo[ \t]|oras[ \t]|tags:/ \ + && code ~ /latest|MAJOR_MINOR/) + has_guard = 0 + k = split(code, K, "\n") + for (i = 1; i <= k; i++) { + if (K[i] ~ /^[ \t]+if:/ \ + && K[i] ~ /github\.event_name == .push.[ \t]*&&[ \t]*github\.ref == .refs\/heads\/main./) { + has_guard = 1 + } + } if (has_call) { calls++ if (!has_guard) ungated++ } - if (step ~ /packages\/container/) endpoint++ + if (code ~ /packages\/container/) endpoint++ } /^jobs:[[:space:]]*$/ { in_jobs = 1; next } in_jobs && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { flush(); in_step = 0; next } @@ -676,12 +732,12 @@ if ! awk ' flush() ok = 1 if (calls != 1) { - printf "expected exactly one moving-tag imagetools step, found %d\n", \ + printf "expected exactly one moving-tag publish step, found %d\n", \ calls > "/dev/stderr" ok = 0 } if (ungated) { - printf "%d moving-tag imagetools step(s) lack the push-to-main guard\n", \ + printf "%d moving-tag publish step(s) lack the push-to-main guard\n", \ ungated > "/dev/stderr" ok = 0 } @@ -696,6 +752,26 @@ if ! awk ' exit 1 fi +# The gate is only as real as the trigger that can satisfy it: deleting the +# on.push.branches entry (or renaming the trigger) leaves "Move floating tags" +# unrunnable while every check above stays green, the silent-disable mirror +# of the defect this freeze fixes. Assert the triggers explicitly. +if ! awk ' + /^on:[ \t]*$/ { in_on = 1; next } + in_on && /^[a-zA-Z]/ { in_on = 0 } + in_on && /^ [A-Za-z_]+:/ { + in_push = ($0 ~ /^ push:/) + if ($0 ~ /^ workflow_dispatch:/) has_dispatch = 1 + next + } + in_on && in_push && /^ -[ \t]+main[ \t]*$/ { has_main = 1; has_push = 1 } + END { exit !(has_push && has_main && has_dispatch) } +' "$release_workflow"; then + printf '%s\n' \ + "release.yml no longer triggers on push to main with workflow_dispatch" >&2 + exit 1 +fi + # Every job declares the protected environment, not only the registry publish # jobs: workflow_dispatch runs the selected ref's YAML, so the environment's # deployment-branch rule is the control that keeps a dispatch of unmodified @@ -758,44 +834,57 @@ if grep -nE 'ref:[[:space:]]*\$\{\{[^}]*tag[^}]*\}\}' "$release_workflow" \ exit 1 fi -# The OIDC publish path must run on an exact npm version, not a range. A range -# operator resolves to whatever the registry serves that day, which is the same -# mutable-dependency shape the action pins exist to prevent. -npm_install_specs="$test_tmp/npm-install-specs" -grep -o 'npm install -g npm@[^ "]*' "$release_workflow" | sort -u \ - > "$npm_install_specs" -while IFS= read -r spec; do - if ! grep -qE '^npm install -g npm@[0-9]+\.[0-9]+\.[0-9]+$' <<<"$spec"; then - printf '%s\n' \ - "npm install spec is not an exact pinned version: $spec" >&2 - exit 1 - fi -done < "$npm_install_specs" - -# A pinned install alone does not prove the pin took: the install line could -# drift, be shadowed by a PATH entry, or install a resolved-otherwise version -# and nothing would notice. Require, per step that installs npm, that the SAME -# step body runs `npm --version` and compares something against the pinned -# literal: `!= "X.Y.Z"` or `== "X.Y.Z"` outside the install spec itself. A -# comment or error message that merely names the version cannot satisfy it, -# and the required set derives from the workflow's own install lines, so a -# second install elsewhere cannot ride on the first step's assertion. +# The OIDC publish path must run on an exact npm version, not a range, and the +# pin must be proven, not only written: the install line could drift, be +# shadowed by a PATH entry, or install a resolved-otherwise version and +# nothing would notice. Every step that globally installs npm, under any +# spelling (`install` or `i`, `-g` or `--global`), must carry an exact +# npm@X.Y.Z spec, run `npm --version`, and compare something against that +# literal with `!=` or `==` outside the install spec itself. Comments are +# stripped first, so neither the spec nor the comparison can be satisfied by +# prose, and the required set derives from the workflow's own install lines, +# so a second install elsewhere cannot ride on the first step's assertion. if ! awk ' - function flush( m, v, ev, cmp, rest) { + function decomment(s, n, L, i, j, c, q, line, out) { + n = split(s, L, "\n") + out = "" + for (i = 1; i <= n; i++) { + line = "" + q = 0 + for (j = 1; j <= length(L[i]); j++) { + c = substr(L[i], j, 1) + if (c == "\"") q = !q + if (c == "#" && !q) break + line = line c + } + out = out line "\n" + } + return out + } + function flush( code, m, spec, v, ev, cmp, rest) { if (!in_step) return - m = step - while (match(m, /npm install -g npm@[0-9]+\.[0-9]+\.[0-9]+/)) { - v = substr(m, RSTART, RLENGTH) - sub(/^.*npm@/, "", v) - ev = v - gsub(/\./, "\\.", ev) - cmp = "(!=|==)[[:space:]]*\"?" ev "\"?" - rest = step - gsub(/npm install -g npm@[0-9]+\.[0-9]+\.[0-9]+/, "", rest) - if (index(rest, "npm --version") == 0 || rest !~ cmp) { - printf "step installs npm@%s without asserting the pin took\n", v \ + code = decomment(step) + m = code + while (match(m, /npm[ \t]+(i|install)[ \t]+(-g|--global)[ \t]+npm[^ \t\n"'"'"';&|]*/)) { + spec = substr(m, RSTART, RLENGTH) + installs++ + if (spec !~ /npm@[0-9]+\.[0-9]+\.[0-9]+$/) { + printf "npm install spec is not an exact pinned version: %s\n", spec \ > "/dev/stderr" bad = 1 + } else { + v = spec + sub(/^.*npm@/, "", v) + ev = v + gsub(/\./, "\\.", ev) + cmp = "(!=|==)[ \t]*\"?" ev "\"?" + rest = code + gsub(/npm[ \t]+(i|install)[ \t]+(-g|--global)[ \t]+npm@[0-9]+\.[0-9]+\.[0-9]+/, "", rest) + if (index(rest, "npm --version") == 0 || rest !~ cmp) { + printf "step installs npm@%s without asserting the pin took\n", v \ + > "/dev/stderr" + bad = 1 + } } m = substr(m, RSTART + RLENGTH) } @@ -804,34 +893,51 @@ if ! awk ' in_jobs && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { flush(); in_step = 0; next } in_jobs && /^ - / { flush(); in_step = 1; step = $0 ORS; next } in_jobs && in_step { step = step $0 ORS } - END { flush(); if (bad) exit 1 } + END { + flush() + if (!installs) { + print "no npm install step found in release workflow" > "/dev/stderr" + bad = 1 + } + if (bad) exit 1 + } ' "$release_workflow"; then exit 1 fi -# Once a job's resolver step (id: rel) has run, later steps must consume the -# resolver's outputs, not needs.release-please.outputs.version/tag_name: the -# resolver is the boundary that proved the tag, and a second derivation of the -# same value can drift from what was verified. The rel step itself may read -# tag_name as its RELEASE_TAG input. The job set derives from which jobs -# actually carry a rel step. +# Once a job's resolver step (id: rel) has run, nothing downstream may read +# needs.release-please.outputs.version/tag_name: the resolver is the boundary +# that proved the tag, and a second derivation of the same value can drift +# from what was verified. Every line of every step is scanned (including the +# - name:/if: line, where the first-line skip used to hide a read), every +# job-level line is scanned (an env: read there feeds all steps), and every +# job is in scope (a read in a job with no rel step is equally unverified). +# The only exemption is the rel step itself, which reads tag_name as its +# RELEASE_TAG input. stale_reads="$test_tmp/stale-release-please-reads" awk ' - function flush( n, L, name, i) { - if (!in_step) return - if (step ~ /\n id:[[:space:]]*rel[[:space:]]*\n/) { - rel_job[job] = 1 - return + function strip(line, j, c, q, out) { + out = "" + q = 0 + for (j = 1; j <= length(line); j++) { + c = substr(line, j, 1) + if (c == "\"") q = !q + if (c == "#" && !q) break + out = out c } - n = split(step, L, "\n") - name = L[1] - for (i = 2; i <= n; i++) { - if (L[i] ~ /needs\.release-please\.outputs\.(version|tag_name)/) { - flagged[++nf] = job " :: " name " :: " L[i] - flag_job[nf] = job - } + return out + } + function scan(line, where) { + if (strip(line) ~ /needs\.release-please\.outputs\.(version|tag_name)/) { + flagged[++nf] = job " :: " where " :: " line } } + function flush( n, L, i) { + if (!in_step) return + if (step ~ /\n id:[[:space:]]*rel[[:space:]]*\n/) return + n = split(step, L, "\n") + for (i = 1; i <= n; i++) scan(L[i], L[1]) + } /^jobs:[[:space:]]*$/ { in_jobs = 1; next } in_jobs && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { flush() @@ -842,12 +948,11 @@ awk ' next } in_jobs && /^ - / { flush(); in_step = 1; step = $0 ORS; next } + in_jobs && !in_step { scan($0, "(job level)") } in_jobs && in_step { step = step $0 ORS } END { flush() - for (i = 1; i <= nf; i++) { - if (flag_job[i] in rel_job) print flagged[i] - } + for (i = 1; i <= nf; i++) print flagged[i] } ' "$release_workflow" > "$stale_reads" From 2f04ac1560a0afa18545d57d66ecfef8ea8fcdbe Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:04:32 -0500 Subject: [PATCH 23/23] ci(release): floor the moving tags against the releases list The event gate alone cannot bound a stale run: a re-run of an older push run replays its stored needs outputs under the original event and ref, and would move :latest and :X.Y backward to that run's version. The gated step now derives the newest published release from the live releases list (contents:read, drafts and prereleases excluded) and refuses when this run's version is not the maximum. The check reduces to VERSION == max, because the run's own release already exists by the time the step runs. The all-steps assertion now requires the carrier to carry the floor as well as the event+ref guard, and the red-check spec gains a gated-but-floorless carrier mutation to prove the clause binds. --- .github/workflows/release.yml | 19 +++++++++++++++ scripts/test-resolve-release-tag.sh | 36 ++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e81f3ea7..02a919ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -406,6 +406,7 @@ jobs: VERSION: ${{ steps.rel.outputs.version }} IMAGE: ${{ steps.rel.outputs.image }} DIGESTS: ${{ steps.manifest.outputs.digests }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail MAJOR_MINOR="${VERSION%.*}" @@ -416,6 +417,24 @@ jobs: # never move a pointer, so this step skips on dispatch. After a # backfill that is genuinely newest in its minor line, advance :X.Y # by hand: docker buildx imagetools create -t "$IMAGE:X.Y" . + # The event gate alone does not bound a stale run: re-running an + # older push run replays its stored needs outputs under the + # original event and ref, and would move the pointers backward to + # that run's version. Floor against live state instead: this run's + # release already exists by now, so VERSION must be the newest + # published release or the run is stale and the tags stay put. + floor="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" -q ' + [ .[] | select(.draft == false) | .tag_name + | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")) + | ltrimstr("v") | split(".") | map(tonumber) ] + | if length == 0 then "" else max | join(".") end')" || { + echo "::error::could not list releases to bound the moving tags" + exit 1 + } + if [ -n "$floor" ] && [ "$floor" != "$VERSION" ]; then + echo "::error::release v$floor is newer than this run's v$VERSION; refusing to move the moving tags backward (stale re-run?)" + exit 1 + fi # shellcheck disable=SC2086 docker buildx imagetools create -t "$IMAGE:latest" -t "$IMAGE:$MAJOR_MINOR" $DIGESTS diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh index 72e88983..b2751e39 100755 --- a/scripts/test-resolve-release-tag.sh +++ b/scripts/test-resolve-release-tag.sh @@ -381,6 +381,7 @@ job=docker-manifest VERSION: ${{ steps.rel.outputs.version }} IMAGE: ${{ steps.rel.outputs.image }} DIGESTS: ${{ steps.manifest.outputs.digests }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail MAJOR_MINOR="${VERSION%.*}" @@ -391,6 +392,24 @@ job=docker-manifest # never move a pointer, so this step skips on dispatch. After a # backfill that is genuinely newest in its minor line, advance :X.Y # by hand: docker buildx imagetools create -t "$IMAGE:X.Y" . + # The event gate alone does not bound a stale run: re-running an + # older push run replays its stored needs outputs under the + # original event and ref, and would move the pointers backward to + # that run's version. Floor against live state instead: this run's + # release already exists by now, so VERSION must be the newest + # published release or the run is stale and the tags stay put. + floor="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" -q ' + [ .[] | select(.draft == false) | .tag_name + | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")) + | ltrimstr("v") | split(".") | map(tonumber) ] + | if length == 0 then "" else max | join(".") end')" || { + echo "::error::could not list releases to bound the moving tags" + exit 1 + } + if [ -n "$floor" ] && [ "$floor" != "$VERSION" ]; then + echo "::error::release v$floor is newer than this run's v$VERSION; refusing to move the moving tags backward (stale re-run?)" + exit 1 + fi # shellcheck disable=SC2086 docker buildx imagetools create -t "$IMAGE:latest" -t "$IMAGE:$MAJOR_MINOR" $DIGESTS @@ -686,8 +705,11 @@ fi # tags"), gated on both the event and the ref. Matching on code text with # comments stripped keeps a comment quoting either pattern from counting. # The guard must sit on the step's if: line as a single && condition, so an -# ||-weakened or comment-borne condition cannot satisfy it. No step may read -# the retired packages/container endpoint at all. +# ||-weakened or comment-borne condition cannot satisfy it. The carrier must +# also bound the move against live state: a re-run of an older push run +# replays stored outputs under the original event and ref, so the step must +# read the releases list and refuse (exit 1) rather than move the tags +# backward. No step may read the retired packages/container endpoint at all. if ! awk ' function decomment(s, n, L, i, j, c, q, line, out) { n = split(s, L, "\n") @@ -705,7 +727,7 @@ if ! awk ' } return out } - function flush( code, has_call, has_guard, k, K) { + function flush( code, has_call, has_guard, has_floor, k, K) { if (!in_step) return code = decomment(step) has_call = (code ~ /imagetools[ \t]+create|docker[ \t]+push|docker[ \t]+buildx[ \t]+build|crane[ \t]|regctl[ \t]|skopeo[ \t]|oras[ \t]|tags:/ \ @@ -718,9 +740,12 @@ if ! awk ' has_guard = 1 } } + has_floor = (code ~ /releases[?\/]/ && code ~ /::error::/ \ + && code ~ /exit[ \t]+1/) if (has_call) { calls++ if (!has_guard) ungated++ + if (!has_floor) unfloored++ } if (code ~ /packages\/container/) endpoint++ } @@ -741,6 +766,11 @@ if ! awk ' ungated > "/dev/stderr" ok = 0 } + if (unfloored) { + printf "%d moving-tag publish step(s) lack the live release floor\n", \ + unfloored > "/dev/stderr" + ok = 0 + } if (endpoint) { print "retired packages/container read present in a workflow step" \ > "/dev/stderr"