From 34030f443cab712a90a13356967dbf7c59f6b42e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:36:41 +0100 Subject: [PATCH] fix(governance): gate the lock-gate's own pin on freshness, and bump it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock gate is staged from a THIRD pin. Not the caller's `uses:` ref and not actions.lock's record of it, but a SHA hardcoded inside governance-reusable.yml for its own `actions/checkout`. A called reusable workflow has no context exposing its own commit, so the hardcode is forced. standards#946 fixed `scripts/update-actions-lock.sh` and did not bump that pin. Every caller therefore kept being judged by the pre-#946 verifier, including metadatastician/burble#226, which had bumped its own pin specifically to pick the fix up and still went red on `governance / Actions lockfile verify`. Measured: the failing job logged `HEAD is now at 4f7f02c`; that wrapper returns rc=1 where the fixed one returns rc=0. The pin's SHAPE was already guarded — tests/test_governance_reusable_shape.sh asserts 40-hex and refuses `ref: main`, job-scoped and aimed correctly. Its CURRENCY was guarded by nothing. A well-formed SHA can point at stale tooling, and did. That is the guard/consumer trap in its usual form: the guard asks "is this 40 hex characters?", the consumer needs "does this contain today's verifier?". The obvious predicate deadlocks. "The pin contains the working tree" would force a PR that edits a helper to pin to its own merge commit, which does not exist yet. So the assertion is that the pin already contains everything on the COMPARE ref, path-scoped to the step's own sparse-checkout list: * pull_request -> base SHA. A PR editing a helper passes; a PR opened while main is already stale is forced to bump, and can. * push to main -> HEAD. Red exactly when the bump is owed, healed by the next PR, which pull_request will not let through unbumped. The pin is one change behind by construction. That is intended, and the comment at the pin now says so instead of asking a human to remember. Scope is read out of the step's `sparse-checkout:` list rather than hardcoded, so the guard cannot drift from what the step actually stages. - scripts/check-lock-gate-pin-freshness.sh — the guard. Fails, never skips, on an unresolvable pin or a renamed subject. - scripts/tests/check-lock-gate-pin-freshness-test.sh — 10 controls against a throwaway git repo, offline. Meta-mutant: removing the path scoping kills exactly the two controls that assert it. - governance-reusable.yml — pin 4f7f02ca -> 9c256b67; comment rewritten. - self-test.yml — fetch-depth: 0 (the guard needs both objects) and the step. - governance-reusable-contract-test.sh — bind an assertion to `Checkout standards for the lock gate`. It bound only to the DUPKEY step, whose name shares the nouns "checkout", "pinned" and "standards"; the block is delimited by awk range, because `grep -A N` either stops short of the `ref:` or reaches the next step's. Does NOT close burble#226 on its own: #226 pins e977cc67, whose copy of this workflow still carries 4f7f02ca. #226 must re-bump to the SHA this PR produces. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/governance-reusable.yml | 21 +- .github/workflows/self-test.yml | 20 ++ scripts/check-lock-gate-pin-freshness.sh | 136 +++++++++++++ .../check-lock-gate-pin-freshness-test.sh | 183 ++++++++++++++++++ .../governance-reusable-contract-test.sh | 31 +++ 5 files changed, 383 insertions(+), 8 deletions(-) create mode 100755 scripts/check-lock-gate-pin-freshness.sh create mode 100644 scripts/tests/check-lock-gate-pin-freshness-test.sh diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 19d028a98..0f3e01999 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -1214,14 +1214,19 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - # ⚠ BUMP THIS whenever scripts/check-actions-lock-gate.sh, - # scripts/update-actions-lock.sh or .machine_readable/lock-allow.txt - # changes, or callers are judged against a stale gate. Pinned to an - # immutable commit for the same reason as the dupkey helpers in - # workflow-lint: following `main` would let an edit in standards - # change the verdict of every already-pinned caller with no review - # in their repositories. - ref: 4f7f02ca528212c578fd56379a202d219d01abe0 + # ⚠ ENFORCED, no longer a request: scripts/check-lock-gate-pin-freshness.sh + # fails Self Test unless this commit already contains everything on + # main across the sparse-checkout list below. Change one of those + # files and the NEXT pull request must bump this line — it cannot be + # this PR's own merge commit, which does not exist yet, so the pin is + # one change behind by construction and that is the intended shape. + # Pinned to an immutable commit for the same reason as the dupkey + # helpers in workflow-lint: following `main` would let an edit in + # standards change the verdict of every already-pinned caller with no + # review in their repositories. This pin is invisible to the caller's + # `uses:` ref and to actions.lock — it is a third, independent pin, + # which is precisely how it went stale across standards#946. + ref: 9c256b67486b4b30c757730e1b65b2c2d2af935b path: .standards-lock persist-credentials: false sparse-checkout: | diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 527d135ec..1b1c52330 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -35,6 +35,12 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # check-lock-gate-pin-freshness.sh compares the lock gate's pinned + # commit against the base ref. A depth-1 clone has neither object, and + # that guard fails rather than skipping on an unresolvable pin, so the + # history is a requirement and not an optimisation. + fetch-depth: 0 # PyYAML is required by the secret-scanner canary. The scorecard # grounding tests execute the same checks as registry-verify, including @@ -47,3 +53,17 @@ jobs: - name: Run tests/*.sh and scripts/tests/*.sh run: bash scripts/run-shell-test-suite.sh + + # Not part of the offline suite: this one needs real git history, so it + # cannot live in scripts/tests/*.sh where a contributor runs it on a + # shallow or detached tree. On a pull request the comparison is the base + # SHA, so a PR that edits a helper is not asked to pin to its own + # unborn merge commit; on main it is HEAD, so the bump owed after such a + # PR lands shows up immediately instead of rotting silently. + - name: Lock-gate pin is not stale + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + COMPARE="${BASE_SHA:-$GITHUB_SHA}" + bash scripts/check-lock-gate-pin-freshness.sh "$COMPARE" diff --git a/scripts/check-lock-gate-pin-freshness.sh b/scripts/check-lock-gate-pin-freshness.sh new file mode 100755 index 000000000..1d31ce75e --- /dev/null +++ b/scripts/check-lock-gate-pin-freshness.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# check-lock-gate-pin-freshness.sh — the lock gate's own pin must not be stale. +# +# WHY THIS EXISTS +# --------------- +# `governance-reusable.yml` stages the lock-gate tooling from a THIRD pin: not +# the caller's `uses:` ref and not the lockfile's record of it, but a SHA +# hardcoded inside the callee for its own `actions/checkout`. A called reusable +# workflow has no context exposing its own commit, so the hardcode is forced. +# +# That third pin is invisible to every other control. When standards#946 fixed +# `scripts/update-actions-lock.sh`, this pin still pointed at the commit BEFORE +# the fix, so every caller kept being judged by the broken verifier — including +# callers that had just bumped specifically to pick the fix up. The file already +# carried a comment saying "BUMP THIS", but a comment is not a gate. +# +# THE PREDICATE, AND WHY IT IS THIS ONE +# ------------------------------------- +# The obvious assertion — "the pin contains the working tree's helpers" — +# DEADLOCKS: a PR that edits a helper would have to pin to its own merge commit, +# which does not exist yet. Unsatisfiable in-PR is the same failure class as a +# required check that can never report. +# +# So the predicate is: +# +# the pinned commit must already contain everything that is on the +# COMPARE ref (main), over exactly the paths the step stages. +# +# * on a pull request, COMPARE is the PR's base SHA. A PR that edits a helper +# PASSES — its edit is not on base yet. A PR opened while main is already +# stale is FORCED to bump, and can, because the needed commit exists. +# * on a push to main, COMPARE is HEAD. Red exactly when a helper change has +# just landed and the bump is owed; healed by the next PR, which the +# pull_request run will not let through unbumped. +# +# The comparison is path-scoped, so a rebase or any unrelated commit cannot fail +# it — only a real divergence in the staged tooling can. +# +# SCOPE IS TAKEN FROM THE STEP, NOT HARDCODED +# ------------------------------------------- +# The paths compared are read out of the step's own `sparse-checkout:` list, so +# adding a file to what the gate stages automatically extends what this guard +# protects. A hardcoded list here would be a guard asking a different question +# than its consumer, which is the exact trap this file belongs to. +# +# Usage: check-lock-gate-pin-freshness.sh [COMPARE_REF] (default: origin/main) + +set -uo pipefail + +STEP_NAME='Checkout standards for the lock gate' + +fail() { + echo "::error::lock-gate pin freshness: $*" >&2 + exit 1 +} + +# Resolve the workflow path INSIDE the function, never at script load: a fixture +# override exported by a test after the top-level assignment would otherwise be +# ignored and every mutant would silently read the real tree and "pass". +workflow_path() { + local root + root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + printf '%s\n' "${LOCK_GATE_WORKFLOW:-$root/.github/workflows/governance-reusable.yml}" +} + +# Print the step's YAML block: from its `- name:` line to the next `- name:` at +# the same indent, exclusive. `grep -A N` cannot do this — the block is 19 lines +# today and any fixed N is either short of the `ref:` or long enough to capture +# the NEXT step's `ref:` and assert against the wrong pin. +step_block() { + awk -v want="- name: $STEP_NAME" ' + index($0, want) { inblock = 1; indent = match($0, /-/); next } + inblock && /^[[:space:]]*- name:/ && match($0, /-/) == indent { exit } + inblock { print } + ' "$(workflow_path)" +} + +main() { + local compare="${1:-origin/main}" + local wf block pin paths diverged + + wf="$(workflow_path)" + [ -f "$wf" ] || fail "workflow not found: $wf" + + block="$(step_block)" + [ -n "$block" ] || + fail "no step named '$STEP_NAME' in $wf — the guard has lost its subject; \ +rename it here too rather than deleting the assertion" + + pin="$(printf '%s\n' "$block" | sed -n 's/^[[:space:]]*ref:[[:space:]]*\([^[:space:]#]*\).*/\1/p' | head -1)" + [ -n "$pin" ] || fail "step '$STEP_NAME' has no 'ref:' — it would follow the default branch" + printf '%s' "$pin" | grep -Eq '^[0-9a-f]{40}$' || + fail "step '$STEP_NAME' is pinned to '$pin', not an immutable 40-hex commit" + + # The staged scope IS the guarded scope. + paths="$(printf '%s\n' "$block" | awk ' + /^[[:space:]]*sparse-checkout:[[:space:]]*\|/ { inlist = 1; next } + inlist && /^[[:space:]]*[a-z-]+:/ { inlist = 0 } + inlist && NF { gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print } + ')" + [ -n "$paths" ] || fail "step '$STEP_NAME' stages no sparse-checkout paths — nothing to compare" + + git rev-parse --verify --quiet "$compare^{commit}" >/dev/null || + fail "compare ref '$compare' is not resolvable in this clone" + + # A missing pin object must FAIL, never skip: a skip is indistinguishable from + # a pass and this guard exists because an unasserted pin rotted unnoticed. + if ! git cat-file -e "$pin^{commit}" 2>/dev/null; then + git fetch --quiet --depth=1 origin "$pin" 2>/dev/null || true + git cat-file -e "$pin^{commit}" 2>/dev/null || + fail "pinned commit $pin is not present and could not be fetched — \ +give the checkout 'fetch-depth: 0' or grant the fetch network access; \ +this guard does not pass on an unverifiable pin" + fi + + # shellcheck disable=SC2086 + diverged="$(git diff --name-only "$pin" "$compare" -- $paths)" + + if [ -n "$diverged" ]; then + echo "::error::The lock gate is staged from $pin, which does NOT contain what is already on $compare." >&2 + echo "Stale in the pinned tree:" >&2 + printf ' %s\n' $diverged >&2 + echo >&2 + echo "Every caller of governance-reusable.yml is being judged by that older tooling," >&2 + echo "including callers that bumped their own pin specifically to pick up the fix." >&2 + echo "Cure: set 'ref:' under '$STEP_NAME' to a commit containing the above" >&2 + echo "(usually the current tip of main), in this PR." >&2 + exit 1 + fi + + echo "PASS: lock-gate pin $pin contains $compare over: $(printf '%s ' $paths)" +} + +main "$@" diff --git a/scripts/tests/check-lock-gate-pin-freshness-test.sh b/scripts/tests/check-lock-gate-pin-freshness-test.sh new file mode 100644 index 000000000..762cf8c62 --- /dev/null +++ b/scripts/tests/check-lock-gate-pin-freshness-test.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Mutants for scripts/check-lock-gate-pin-freshness.sh. +# +# Every control builds a THROWAWAY git repository with real commits, so the +# freshness comparison is exercised against genuine history with no network and +# no dependence on this repo's own state. A control that asserted against the +# real tree would go green or red for reasons unrelated to the mutation. + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +GUARD="$ROOT/scripts/check-lock-gate-pin-freshness.sh" +[ -f "$GUARD" ] || { echo "FAIL: guard not found at $GUARD" >&2; exit 1; } + +pass=0 +fail=0 + +check() { # name expected_rc actual_rc [haystack needle] + local name="$1" want="$2" got="$3" + if [ "$got" != "$want" ]; then + echo "FAIL: $name — expected rc=$want, got rc=$got" >&2 + fail=$((fail + 1)) + return + fi + if [ "$#" -ge 5 ] && ! printf '%s' "$4" | grep -Fq -- "$5"; then + echo "FAIL: $name — rc was right but the message never mentioned '$5'" >&2 + echo "----- output -----" >&2; printf '%s\n' "$4" >&2; echo "------------------" >&2 + fail=$((fail + 1)) + return + fi + echo "ok: $name" + pass=$((pass + 1)) +} + +refute() { # name haystack needle + local name="$1" + if printf '%s' "$2" | grep -Fq -- "$3"; then + echo "FAIL: $name — output mentioned '$3' and must not" >&2 + fail=$((fail + 1)) + return + fi + echo "ok: $name" + pass=$((pass + 1)) +} + +# Build a repo whose helper changed in the SECOND commit, plus an unrelated file +# that also changed, so path-scoping can be told apart from "any divergence". +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cd "$WORK" || exit 1 +git init --quiet -b main . +git config user.email t@example.invalid +git config user.name t +mkdir -p scripts .machine_readable +echo v1 > scripts/update-actions-lock.sh +echo v1 > scripts/check-actions-lock-gate.sh +echo v1 > .machine_readable/lock-allow.txt +echo v1 > UNRELATED.md +git add -A && git commit --quiet -m c1 +OLD="$(git rev-parse HEAD)" +echo v2 > scripts/update-actions-lock.sh +echo v2 > UNRELATED.md +git add -A && git commit --quiet -m c2 +NEW="$(git rev-parse HEAD)" +# A third commit touching ONLY the unrelated file, to prove path-scoping. +echo v3 > UNRELATED.md +git add -A && git commit --quiet -m c3 +NEWEST="$(git rev-parse HEAD)" + +# $1 = the `ref:` value; $2 (optional) = step name override. +write_fixture() { + local ref="$1" name="${2:-Checkout standards for the lock gate}" + mkdir -p "$WORK/.github/workflows" + cat > "$WORK/.github/workflows/governance-reusable.yml" <&1; } + +# 1. The real defect: the pin predates a helper change that is already on main. +write_fixture "$OLD" +out="$(run "$NEW")"; rc=$? +check "stale pin is refused" 1 "$rc" "$out" "scripts/update-actions-lock.sh" + +# 2. And it must not name files outside the staged scope. UNRELATED.md differs +# between the two commits too; if it appears, the guard is diffing the whole +# tree and every rebase would redden it. +refute "stale report is path-scoped (never names UNRELATED.md)" "$out" "UNRELATED.md" + +# 3. The cured state passes. +write_fixture "$NEW" +out="$(run "$NEW")"; rc=$? +check "fresh pin is accepted" 0 "$rc" "$out" "PASS" + +# 4. Path-scoping: an unrelated commit on top must NOT fail a fresh pin. +# Without this, every rebase would redden the gate and the guard would be +# turned off rather than obeyed. +write_fixture "$NEW" +out="$(run "$NEWEST")"; rc=$? +check "unrelated divergence does not fail it" 0 "$rc" "$out" "PASS" + +# 5. A moving ref is refused — the whole point of pinning. +write_fixture "main" +out="$(run "$NEW")"; rc=$? +check "ref: main is refused" 1 "$rc" "$out" "not an immutable 40-hex commit" + +# 6. A short/abbreviated SHA is refused. +write_fixture "${NEW:0:12}" +out="$(run "$NEW")"; rc=$? +check "abbreviated sha is refused" 1 "$rc" "$out" "not an immutable 40-hex commit" + +# 7. Renaming the step must FAIL, not vacuously pass. This is the exact way the +# existing contract test lost its subject: it greps a step name, and a step +# that no longer matches simply stops being checked. +write_fixture "$NEW" "Checkout standards for something else" +out="$(run "$NEW")"; rc=$? +check "renamed step fails loudly" 1 "$rc" "$out" "lost its subject" + +# 8. A pin that cannot be resolved must FAIL, never skip. +write_fixture "dddddddddddddddddddddddddddddddddddddddd" +out="$(run "$NEW")"; rc=$? +check "unresolvable pin fails, not skips" 1 "$rc" "$out" "does not pass on an unverifiable pin" + +# 9. No ref: at all. +mkdir -p "$WORK/.github/workflows" +cat > "$WORK/.github/workflows/governance-reusable.yml" <<'YAML' +jobs: + gate: + steps: + - name: Checkout standards for the lock gate + uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + with: + repository: hyperpolymath/standards + sparse-checkout: | + scripts/update-actions-lock.sh +YAML +export LOCK_GATE_WORKFLOW="$WORK/.github/workflows/governance-reusable.yml" +out="$(run "$NEW")"; rc=$? +check "missing ref: is refused" 1 "$rc" "$out" "would follow the default branch" + +# 10. Staging nothing must not be a free pass. +cat > "$WORK/.github/workflows/governance-reusable.yml" <