From ba0f28edbb4ca7f20dc216f52fd7283a434dc85a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:08:55 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(pins):=20relock-standards-pin.sh=20?= =?UTF-8?q?=E2=80=94=20move=20actions.lock=20with=20the=20standards=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pin bumpers rewrite `uses: hyperpolymath/standards/...@SHA` and nothing else, so in a repo with an actions.lock they leave the lock keyed to the old standards commit: check-lock-sync.sh rejects it and GitHub refuses the run at startup. gh-actions-lock v0.1.6 cannot repair it (blind to job-level `uses:`). This is the missing step, as one fail-closed tool. It re-keys the per-workflow entries, rebuilds the standards dependency record from the actions the called reusables use AT the target, copies the records those need verbatim from standards' own lock at the target, prunes only records the old standards record alone kept alive (transitively), and keeps gh-actions-lock's C-locale record order. It works on a copy and leaves the lock untouched (exit 1) when a workflow still pins another standards commit, a reusable is absent at the target, the old record is missing, or a needed record is in neither lock. Known answers: on hyperpolymath/resource-algebra and on hyperpolymath/rsr-template-repo, re-pinned da2c748 -> ae36881, it reproduces byte for byte the locks verified by hand there (resource-algebra#6, rsr-template-repo#237); both pass check-lock-sync.sh. scripts/tests/relock-standards-pin-test.sh (offline, synthetic standards and consumer): known answer, idempotence, three refusals that leave the lock byte-identical, no-lock no-op. Six mutants of the script, each killed: no iteration, no pruning, no record copying, no stale-pin refusal, over-pruning, no sorting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LiQzWJdcPfRU6YMDGRG8Xi --- scripts/relock-standards-pin.sh | 156 +++++++++++++++++++++ scripts/tests/relock-standards-pin-test.sh | 153 ++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100755 scripts/relock-standards-pin.sh create mode 100755 scripts/tests/relock-standards-pin-test.sh diff --git a/scripts/relock-standards-pin.sh b/scripts/relock-standards-pin.sh new file mode 100755 index 000000000..101b01036 --- /dev/null +++ b/scripts/relock-standards-pin.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# relock-standards-pin.sh — move a consumer's actions.lock to the standards +# commit its reusable-workflow pins were just re-pointed at. +# +# WHY: both pin bumpers (propagate-workflow-pins.sh and the scheduled +# apply-workflow-pins-remote.sh) rewrite `uses: hyperpolymath/standards/...@SHA` +# in workflow YAML and nothing else. In a repo with an actions.lock that leaves +# the lock keyed to the OLD standards commit: per-workflow entries name a ref no +# `uses:` carries any more, and the standards dependency record still lists the +# actions the OLD reusables used. check-lock-sync.sh fails, and GitHub refuses +# such runs at startup (jobs=0; mirror.yml, standards 2026-09-22). Measured +# 2026-10-14: 200 of 368 governance callers carry an actions.lock, so a fix-mode +# applier run would have broken their reusable-calling workflows. +# `gh actions-lock` cannot repair it: v0.1.6 is blind to job-level `uses:`. +# +# WHAT IT WRITES, and nothing else: +# * per-workflow entries hyperpolymath/standards@OLD -> @TARGET; +# * the hyperpolymath/standards@TARGET dependency record, whose nested `uses:` +# is every action the called reusables use AT TARGET (owner/repo@ref); +# * records those actions need and the lock lacks, copied verbatim from +# standards' own actions.lock at TARGET; +# * removal of the OLD standards records, and of records that only they +# kept alive (nothing else is pruned); +# * dependency records in C-locale key order, as gh actions-lock writes them. +# +# FAIL-CLOSED: it works on a copy and leaves the lock untouched (exit 1) when a +# workflow still pins a standards reusable at another SHA, when the called +# reusable is not in standards at TARGET, when the OLD record is missing, or +# when a needed record is in neither lock. A lock with a dangling edge is +# worse than a stale one: the stale one is at least reported by the gate. +# +# Usage: relock-standards-pin.sh --to [--standards ] +# holds the workflow files and actions.lock: a repo's +# .github/workflows, or the applier's flat fetch directory. +# --standards a standards git checkout that contains ; default +# $STANDARDS_DIR, else the checkout this script lives in. +# Exit: 0 relocked or nothing to do; 1 refused (lock untouched); 2 usage. +set -euo pipefail + +TARGET="" SROOT="${STANDARDS_DIR:-}" WF="" +while [ $# -gt 0 ]; do + case "$1" in + --to) TARGET="${2:-}"; shift 2 ;; + --to=*) TARGET="${1#*=}"; shift ;; + --standards) SROOT="${2:-}"; shift 2 ;; + -h|--help) sed -n '2,41p' "$0"; exit 0 ;; + -*) echo "relock-standards-pin: unknown option $1" >&2; exit 2 ;; + *) WF="$1"; shift ;; + esac +done +[ -n "$SROOT" ] || SROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +[[ "$TARGET" =~ ^[0-9a-f]{40}$ ]] || { echo "relock-standards-pin: --to must be a full 40-hex SHA" >&2; exit 2; } +[ -n "$WF" ] && [ -d "$WF" ] || { echo "relock-standards-pin: workflows directory required" >&2; exit 2; } +git -C "$SROOT" cat-file -e "${TARGET}^{commit}" 2>/dev/null \ + || { echo "relock-standards-pin: standards commit $TARGET not in $SROOT" >&2; exit 2; } + +L="$WF/actions.lock" +[ -f "$L" ] || { echo "relock-standards-pin: no actions.lock in $WF; nothing to relock"; exit 0; } +# refuse REASON... — report why the relock is refused and exit 1, lock untouched. +refuse() { echo "relock-standards-pin: REFUSED ($*); $L left untouched" >&2; exit 1; } + +PIN_RE='hyperpolymath/standards/\.github/workflows/[A-Za-z0-9._-]+\.ya?ml@[0-9a-fA-F]+' +shopt -s nullglob +wfs=("$WF"/*.yml "$WF"/*.yaml) +shopt -u nullglob +pins=$(grep -hoE "$PIN_RE" "${wfs[@]}" 2>/dev/null | sort -u || true) +stale=$(printf '%s\n' "$pins" | grep -v "@${TARGET}$" | grep . || true) +[ -z "$stale" ] || refuse "workflows still pin other standards commits: $(echo $stale)" + +olds=$(grep -oE "'hyperpolymath/standards@[0-9a-f]{40}'" "$L" | tr -d "'" | sed 's/.*@//' | sort -u | grep -vx "$TARGET" || true) +if [ -z "$olds" ]; then echo "relock-standards-pin: lock already keyed to standards@${TARGET:0:12}"; exit 0; fi +[ -n "$pins" ] || refuse "lock names standards commits but no workflow calls a standards reusable" + +tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT +q="'" + +# rec_body KEY FILE — print the dependency record KEY (header and body) from FILE. +rec_body() { awk -v k=" ${q}$1${q}:" '$0==k{f=1;print;next} f&&/^ [^ ]/{exit} f' "$2"; } + +# Actions the called reusables use at TARGET, normalised to owner/repo@ref. +: > "$tmp/uses" +for p in $pins; do + path=${p#hyperpolymath/standards/}; path=${path%@*} + git -C "$SROOT" show "${TARGET}:${path}" > "$tmp/reusable" 2>/dev/null || refuse "$path is not in standards at ${TARGET:0:12}" + sed 's/[[:space:]]#.*$//' "$tmp/reusable" \ + | grep -oE "^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*['\"]?[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[^[:space:]'\"]+" \ + | sed -E "s/^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*['\"]?//; s#^([^/]+/[^/@]+)[^@]*@#\1@#" >> "$tmp/uses" || true +done +grep -v '^hyperpolymath/standards@' "$tmp/uses" | LC_ALL=C sort -u > "$tmp/uses.s" || true + +# owner_id / repo_id come from an OLD record; without one the lock was already dangling. +ids="" +for o in $olds; do ids=$(rec_body "hyperpolymath/standards@$o" "$L" | grep -E '^ (owner_id|repo_id):' || true); [ -n "$ids" ] && break; done +[ -n "$ids" ] || refuse "no hyperpolymath/standards@OLD dependency record to take owner_id/repo_id from" + +# Records the OLD standards records kept alive: the pruning candidates. +: > "$tmp/cand" +for o in $olds; do rec_body "hyperpolymath/standards@$o" "$L" | grep -oE "^ - '[^']+'" | sed -E "s/^ +- '|'$//g" >> "$tmp/cand" || true; done + +cp "$L" "$tmp/lock" +for o in $olds; do + sed -i "s|^ - 'hyperpolymath/standards@$o'\$| - 'hyperpolymath/standards@$TARGET'|" "$tmp/lock" + awk -v k=" ${q}hyperpolymath/standards@$o${q}:" '$0==k{s=1;next} s&&/^ [^ ]/{s=0} !s' "$tmp/lock" > "$tmp/l" && mv "$tmp/l" "$tmp/lock" +done +awk -v k=" ${q}hyperpolymath/standards@$TARGET${q}:" '$0==k{s=1;next} s&&/^ [^ ]/{s=0} !s' "$tmp/lock" > "$tmp/l" && mv "$tmp/l" "$tmp/lock" +{ printf " 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n" "$TARGET" "$TARGET" "$TARGET" + printf '%s\n' "$ids" + if [ -s "$tmp/uses.s" ]; then echo " uses:"; sed "s|.*| - '&'|" "$tmp/uses.s"; fi +} >> "$tmp/lock" + +# Records the new pin needs: from this lock, else verbatim from standards' own. +git -C "$SROOT" show "${TARGET}:.github/workflows/actions.lock" > "$tmp/slock" 2>/dev/null || : > "$tmp/slock" +while read -r u; do + [ -n "$u" ] || continue + grep -qF " '$u':" "$tmp/lock" && continue + r=$(rec_body "$u" "$tmp/slock") + [ -n "$r" ] || refuse "no record for $u in this lock or in standards' own lock at ${TARGET:0:12}" + printf '%s\n' "$r" >> "$tmp/lock" +done < "$tmp/uses.s" + +# Prune candidates nothing references any more (to a fixed point). +deps_line() { grep -n '^dependencies:$' "$1" | cut -d: -f1; } +while :; do + n=$(deps_line "$tmp/lock") + grep -oE "^ {8,12}- '[^']+'" "$tmp/lock" | sed -E "s/^ +- '|'$//g" | LC_ALL=C sort -u > "$tmp/edges" + pruned=0 + while read -r c; do + [ -n "$c" ] || continue + grep -qxF "$c" "$tmp/edges" && continue + tail -n +"$((n+1))" "$tmp/lock" | grep -qxF " '$c':" || continue + rec_body "$c" "$tmp/lock" | grep -oE "^ - '[^']+'" | sed -E "s/^ +- '|'$//g" >> "$tmp/cand" || true + awk -v k=" ${q}$c${q}:" '$0==k{s=1;next} s&&/^ [^ ]/{s=0} !s' "$tmp/lock" > "$tmp/l" && mv "$tmp/l" "$tmp/lock" + pruned=1 + done < <(LC_ALL=C sort -u "$tmp/cand") + [ "$pruned" = 1 ] || break +done + +# Dependency records in C-locale key order (portable awk: tag lines, sort, strip). +n=$(deps_line "$tmp/lock") +head -n "$n" "$tmp/lock" > "$tmp/out" +tail -n +"$((n+1))" "$tmp/lock" \ + | awk -v q="$q" 'index($0, " " q) == 1 { key = $0 } { printf "%s\t%08d\t%s\n", key, NR, $0 }' \ + | LC_ALL=C sort -t "$(printf '\t')" -k1,1 -k2,2n | cut -f3- >> "$tmp/out" + +# Closure: every ref the lock names must have a record of its own. +n=$(deps_line "$tmp/out") +grep -oE "^ {8,12}- '[^']+'" "$tmp/out" | sed -E "s/^ +- '|'$//g" | LC_ALL=C sort -u > "$tmp/edges" +tail -n +"$((n+1))" "$tmp/out" | grep -oE "^ '[^']+':" | sed -E "s/^ '|':$//g" | LC_ALL=C sort -u > "$tmp/recs" +dangling=$(LC_ALL=C comm -23 "$tmp/edges" "$tmp/recs") +[ -z "$dangling" ] || refuse "result would have dangling edges: $(echo $dangling)" + +cat "$tmp/out" > "$L" +echo "relock-standards-pin: $L keyed to standards@${TARGET:0:12} (from $(echo $olds | sed 's/\([0-9a-f]\{12\}\)[0-9a-f]*/\1/g'))" diff --git a/scripts/tests/relock-standards-pin-test.sh b/scripts/tests/relock-standards-pin-test.sh new file mode 100755 index 000000000..247932e31 --- /dev/null +++ b/scripts/tests/relock-standards-pin-test.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +set -euo pipefail + +# Regression test for relock-standards-pin.sh. Offline: a synthetic standards +# repository and a synthetic consumer lock, with known answers. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RELOCK="$SCRIPT_DIR/../relock-standards-pin.sh" +SYNC="$SCRIPT_DIR/../check-lock-sync.sh" +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +# fail MESSAGE — report a failed assertion and stop the test. +fail() { echo "FAIL: $*" >&2; exit 1; } + +# Synthetic standards: OLD's reusable uses checkout + old/dep; NEW's uses +# checkout + acme/tool, and NEW's own lock records acme/tool. +S="$T/standards"; git init -q -b main "$S"; git -C "$S" config user.email t@t; git -C "$S" config user.name t +mkdir -p "$S/.github/workflows" +cat > "$S/.github/workflows/x-reusable.yml" <<'Y' +on: { workflow_call: {} } +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1111111111111111111111111111111111111111 # v1 + - uses: old/dep/sub@2222222222222222222222222222222222222222 +Y +git -C "$S" add -A; git -C "$S" commit -q -m old; OLD=$(git -C "$S" rev-parse HEAD) +cat > "$S/.github/workflows/x-reusable.yml" <<'Y' +on: { workflow_call: {} } +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1111111111111111111111111111111111111111 # v1 + - uses: acme/tool/sub@3333333333333333333333333333333333333333 +Y +cat > "$S/.github/workflows/actions.lock" <<'Y' +version: 'v0.0.3' +workflows: + '.github/workflows/x-reusable.yml': + - 'acme/tool@3333333333333333333333333333333333333333' + - 'actions/checkout@1111111111111111111111111111111111111111' +dependencies: + 'acme/tool@3333333333333333333333333333333333333333': + ref: 'v3' + commit: 'sha1-3333333333333333333333333333333333333333' + owner_id: 30 + repo_id: 31 + 'actions/checkout@1111111111111111111111111111111111111111': + ref: 'v1' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 10 + repo_id: 11 +Y +git -C "$S" add -A; git -C "$S" commit -q -m new; NEW=$(git -C "$S" rev-parse HEAD) + +# A consumer whose caller was re-pointed to NEW but whose lock is still on OLD. +consumer() { + local d="$T/$1/.github/workflows"; rm -rf "$T/$1"; mkdir -p "$d" + printf 'on: push\njobs:\n call:\n uses: hyperpolymath/standards/.github/workflows/x-reusable.yml@%s\n' "$NEW" > "$d/caller.yml" + printf 'on: push\njobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@1111111111111111111111111111111111111111\n' > "$d/own.yml" + cat > "$d/actions.lock" </dev/null +L="$D/actions.lock" +grep -q "^ - 'hyperpolymath/standards@$NEW'\$" "$L" || fail "workflow entry not re-keyed" +grep -q "$OLD" "$L" && fail "OLD standards commit still in the lock" +grep -q "^ 'hyperpolymath/standards@$NEW':\$" "$L" || fail "no record for the new pin" +grep -q "^ 'acme/tool@3333333333333333333333333333333333333333':\$" "$L" || fail "needed record not copied from standards' lock" +grep -q "old/dep@" "$L" && fail "record only the OLD reusable used was not pruned" +grep -q "old/sub@" "$L" && fail "record kept alive only by a pruned record was not pruned" +grep -q "unrelated/orphan@" "$L" || fail "an orphan the OLD record never named was pruned" +grep -q "owner_id: 6759885" "$L" || fail "owner_id not carried over" +[ "$(awk '/^dependencies:$/{f=1;next} f && /^ \x27/' "$L" | LC_ALL=C sort -c 2>&1; echo $?)" = 0 ] || fail "dependency records not in C-locale key order" +if command -v gawk >/dev/null 2>&1; then + sed -i '/unrelated\/orphan/,/repo_id: 41/d' "$L" # the gate checks closure, not unrelated orphans + bash "$SYNC" "$D" >/dev/null 2>&1 || fail "check-lock-sync.sh rejects the relocked lock" +fi +echo " PASS known answer (re-key, record rebuilt, needed record copied, OLD-only records pruned transitively, unrelated orphan kept)" + +# 2. Idempotent. +D=$(consumer idem); bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null +cp "$D/actions.lock" "$T/once"; bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null +cmp -s "$T/once" "$D/actions.lock" || fail "second run changed the lock" +echo " PASS idempotent" + +# 3. Refusals leave the lock byte-identical. +refuse_case() { # name mutate-command + local d; d=$(consumer "$1"); eval "$2"; cp "$d/actions.lock" "$T/before" + if bash "$RELOCK" --to "$NEW" --standards "$S" "$d" >/dev/null 2>&1; then fail "$1: relock succeeded"; fi + cmp -s "$T/before" "$d/actions.lock" || fail "$1: lock modified on refusal" + echo " PASS refused: $1" +} +refuse_case still-on-old 'sed -i "s/@$NEW/@$OLD/" "$d/caller.yml"' +# A pin whose reusable needs a record that neither lock has: a commit NEW2 +# whose reusable uses acme/tool at a ref standards' own lock does not record. +sed -i "s#acme/tool/sub@3333333333333333333333333333333333333333#acme/tool/sub@5555555555555555555555555555555555555555#" "$S/.github/workflows/x-reusable.yml" +git -C "$S" commit -qam new2; NEW2=$(git -C "$S" rev-parse HEAD) +refuse_case record-nowhere 'sed -i "s/@$NEW/@$NEW2/" "$d/caller.yml"; NEW_SAVE=$NEW; NEW=$NEW2' +NEW=$NEW_SAVE +refuse_case no-old-record 'sed -i "/^ .hyperpolymath\/standards@$OLD.:/,/^ - .old\/dep/d" "$d/actions.lock"' + +# 4. No lock: nothing to do. +D=$(consumer nolock); rm "$D/actions.lock" +bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null || fail "no-lock repo was refused" +[ ! -e "$D/actions.lock" ] || fail "a lock was created" +echo " PASS no actions.lock: nothing to do" + +echo "PASS: relock-standards-pin.sh" From 52efdd24b40c51054049edc030619aa581f976cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:08:55 +0000 Subject: [PATCH 2/8] fix(pins): the applier and the propagator relock actions.lock with the pins apply-workflow-pins-remote.sh fetched and committed only *.yml. Measured 2026-10-14 (the ledger census of 2026-09-22): 200 of 368 governance callers carry an actions.lock, so a fix-mode run would have opened PRs that move their reusable pins while their locks stay on the old commit, stopping those workflows at startup. propagate-workflow-pins.sh had the same gap. - The applier fetches actions.lock with the workflows (GraphQL and REST paths), relocks it in the same verified commit, and reports a repo whose lock cannot follow as LOCK-REFUSED instead of landing it. In fix mode it fetches the target commit when the CI checkout lacks it. A new known-answer control fails the run if the relock is missing. - The propagator relocks each repo it rewrote before staging, reports RELOCKED / LOCK-REFUSED per repo, and exits 1 when any lock was refused. Verified: the applier's --self-test, propagate-workflow-pins-test.sh 18/18, check-lock-sync-test.sh 13/13, relock-standards-pin-test.sh, and the docstring scan (9/9 touched functions documented). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LiQzWJdcPfRU6YMDGRG8Xi --- scripts/apply-workflow-pins-remote.sh | 46 +++++++++++++++++++++++++-- scripts/propagate-workflow-pins.sh | 21 ++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/scripts/apply-workflow-pins-remote.sh b/scripts/apply-workflow-pins-remote.sh index 0c9ce9bbc..d7936b80c 100755 --- a/scripts/apply-workflow-pins-remote.sh +++ b/scripts/apply-workflow-pins-remote.sh @@ -49,6 +49,11 @@ # (6 of 9 campaign heads unsigned). An unsigned applier opens PRs that can # never merge, which is worse than opening none. # * Shell-only: no Python, no Ruby. +# * THE LOCK FOLLOWS THE PINS. A repo's actions.lock is fetched with its +# workflows and re-keyed by scripts/relock-standards-pin.sh in the same +# commit; a lock that cannot follow (fail-closed refusal) means the repo is +# reported LOCK-REFUSED and not landed. Pins without their lock stop the +# workflows at startup, which is worse than leaving them behind. # # Usage: # bash scripts/apply-workflow-pins-remote.sh [options] @@ -76,6 +81,10 @@ ONLY_REPO="" OUT_FILE="" LIMIT=0 BRANCH_NAME="chore/re-point-standards-workflow-pins" +# The standards checkout this script runs from: the relock reads reusables and +# standards' own actions.lock AT THE TARGET from it. +STANDARDS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RELOCK="$(dirname "${BASH_SOURCE[0]}")/relock-standards-pin.sh" log() { printf '%s\n' "$*" >&2; } @@ -260,6 +269,16 @@ self_test() { echo " FAIL illegal repair did not yield a legal pin: $(cat "$d/illegal.yml")" >&2; rc=1 fi + # The relock is what keeps a fix-mode PR from desyncing actions.lock; a + # missing relock must stop the run, not silently land pins without locks. + # Its own known-answer suite is scripts/tests/relock-standards-pin-test.sh. + echo "control: the lock relock is present" + if [ -f "$RELOCK" ] && bash -n "$RELOCK"; then + echo " PASS $(basename "$RELOCK") present and parses" + else + echo " FAIL $RELOCK missing or unparseable" >&2; rc=1 + fi + rm -rf "$d" return $rc } @@ -379,7 +398,7 @@ fetch_workflows() { done < <(jq -r ' (.data.repository.object.entries // [])[] | select(.type == "blob") - | select(.name | test("\\.ya?ml$")) + | select((.name | test("\\.ya?ml$")) or .name == "actions.lock") | select(.object.text != null) | "\(.name)\t\(.object.text | @base64)"' "$resp" 2>/dev/null) rm -f "$resp" @@ -399,7 +418,7 @@ fetch_workflows() { case "$listing" in *"HTTP 404"*) return 0 ;; esac return 1 fi - for fname in $(printf '%s\n' "$listing" | grep -E '\.ya?ml$'); do + for fname in $(printf '%s\n' "$listing" | grep -E '\.ya?ml$|^actions\.lock$'); do gh api "repos/${repo}/contents/.github/workflows/${fname}" \ -H 'Accept: application/vnd.github.raw' > "${dest}/${fname}" 2>/dev/null \ || { rm -f "${dest}/${fname}"; return 1; } @@ -505,6 +524,12 @@ main() { TARGET_SHA=$(resolve_target) [ -n "$TARGET_SHA" ] || { log "FATAL: could not resolve a target SHA."; exit 1; } validate_target "$TARGET_SHA" || exit 1 + # The relock reads the target's reusables and lock from this checkout; a + # shallow CI checkout may hold a different commit, so fetch it if needed. + if [ "$DO_FIX" -eq 1 ] && ! git -C "$STANDARDS_ROOT" cat-file -e "${TARGET_SHA}^{commit}" 2>/dev/null; then + git -C "$STANDARDS_ROOT" fetch -q --depth=1 origin "$TARGET_SHA" 2>/dev/null \ + || { log "FATAL: ${TARGET_SHA} is not in ${STANDARDS_ROOT} and could not be fetched; the lock relock needs it."; exit 1; } + fi log "== target: ${TARGET_SHA} (reachable from ${STANDARDS_REPO} main) ==" [ "$DO_FIX" -eq 1 ] && log "== MODE: --fix (will open PRs) ==" || log "== MODE: audit (writes nothing) ==" @@ -557,6 +582,23 @@ main() { shopt -u nullglob [ "$found" -eq 0 ] && continue + # The lock must follow the pins. Re-pointing `uses:` alone leaves + # actions.lock keyed to the old standards commit: check-lock-sync.sh + # rejects it and GitHub refuses the run at startup (jobs=0). Measured + # 2026-10-14: 200 of 368 governance callers carry a lock, and this applier + # used to fetch and commit only *.yml, so every fix-mode PR to one of them + # would have stopped its reusable-calling workflows. relock is fail-closed; + # a repo whose lock cannot follow is reported and NOT landed. + if [ "$DO_FIX" -eq 1 ] && [ "${#changed[@]}" -gt 0 ] && [ -f "$rdir/actions.lock" ]; then + local lock_before; lock_before=$(cat "$rdir/actions.lock") + if ! bash "$RELOCK" --to "$TARGET_SHA" --standards "$STANDARDS_ROOT" "$rdir" >&2; then + log " LOCK-REFUSED ${repo}: actions.lock cannot follow the new pins; NOT landed" + printf '%s\t-\tLOCK-REFUSED\t-\n' "$repo" >> "$tsv" + continue + fi + [ "$lock_before" != "$(cat "$rdir/actions.lock")" ] && changed+=("actions.lock") + fi + if [ "$DO_FIX" -eq 1 ] && [ "${#changed[@]}" -gt 0 ]; then local url if url=$(land_pr "$repo" "$rdir" "${changed[@]}"); then diff --git a/scripts/propagate-workflow-pins.sh b/scripts/propagate-workflow-pins.sh index e9d558298..a7802bc9f 100755 --- a/scripts/propagate-workflow-pins.sh +++ b/scripts/propagate-workflow-pins.sh @@ -54,6 +54,7 @@ set -euo pipefail # --------------------------------------------------------------------------- FIX_MODE=false +LOCK_REFUSED=0 TARGET_SHA="${STANDARDS_TARGET_SHA:-}" BRANCH="chore/bump-standards-pins" TARGET_PATH="." @@ -174,6 +175,8 @@ rewrite_file() { # Per-repo processing # --------------------------------------------------------------------------- +# process_repo REPO — audit, and under --fix rewrite, every standards pin in +# REPO's workflows, relock its actions.lock to match, and stage the result. process_repo() { local repo="$1" name name="$(basename "$repo")" @@ -199,6 +202,19 @@ process_repo() { fi done + # The lock must follow the pins. Rewriting `uses:` alone leaves actions.lock + # keyed to the old standards commit, which check-lock-sync.sh rejects and + # GitHub refuses at startup (jobs=0). relock-standards-pin.sh is fail-closed: + # it leaves the lock untouched and exits 1 when it cannot relock correctly. + if $FIX_MODE && [ "$files_touched" -gt 0 ] && [ -f "$wfdir/actions.lock" ]; then + if bash "$(dirname "${BASH_SOURCE[0]}")/relock-standards-pin.sh" --to "$TARGET_SHA" "$wfdir" >&2; then + printf '%-30s\t%-28s\t%s\t-> %s\n' "$name" "actions.lock" "RELOCKED" "${TARGET_SHA:0:12}" + else + printf '%-30s\t%-28s\t%s\t-> %s\n' "$name" "actions.lock" "LOCK-REFUSED" "${TARGET_SHA:0:12}" + LOCK_REFUSED=$((LOCK_REFUSED + 1)) + fi + fi + if $FIX_MODE && [ "$files_touched" -gt 0 ] && git -C "$repo" rev-parse --git-dir >/dev/null 2>&1; then local current_branch current_branch=$(git -C "$repo" branch --show-current 2>/dev/null || echo "") @@ -243,3 +259,8 @@ if $FIX_MODE; then else echo "# Re-run with --fix to stage the bump on a branch (no commit/push)." fi +if [ "$LOCK_REFUSED" -gt 0 ]; then + echo "# LOCK-REFUSED in $LOCK_REFUSED repo(s): their pins moved but actions.lock could not follow." + echo "# Do NOT commit those as they stand; see relock-standards-pin.sh's message above." + exit 1 +fi From cccec83b9042ef9a8c73ffe7d523297726d625f4 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:24:12 +0000 Subject: [PATCH 3/8] fix(pins): accept SHA prefixes and skip staging when relocking fails --- scripts/propagate-workflow-pins.sh | 4 +- scripts/relock-standards-pin.sh | 3 +- scripts/tests/propagate-workflow-pins-test.sh | 41 +++++++++++++++++++ scripts/tests/relock-standards-pin-test.sh | 18 ++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/scripts/propagate-workflow-pins.sh b/scripts/propagate-workflow-pins.sh index a7802bc9f..2ec7222ee 100755 --- a/scripts/propagate-workflow-pins.sh +++ b/scripts/propagate-workflow-pins.sh @@ -212,6 +212,8 @@ process_repo() { else printf '%-30s\t%-28s\t%s\t-> %s\n' "$name" "actions.lock" "LOCK-REFUSED" "${TARGET_SHA:0:12}" LOCK_REFUSED=$((LOCK_REFUSED + 1)) + # Leave this repo's index and branch untouched when the lock cannot follow. + return 0 fi fi @@ -260,7 +262,7 @@ else echo "# Re-run with --fix to stage the bump on a branch (no commit/push)." fi if [ "$LOCK_REFUSED" -gt 0 ]; then - echo "# LOCK-REFUSED in $LOCK_REFUSED repo(s): their pins moved but actions.lock could not follow." + echo "# LOCK-REFUSED in $LOCK_REFUSED repo(s): their pins moved but actions.lock could not follow; staging skipped." echo "# Do NOT commit those as they stand; see relock-standards-pin.sh's message above." exit 1 fi diff --git a/scripts/relock-standards-pin.sh b/scripts/relock-standards-pin.sh index 101b01036..996de5bd0 100755 --- a/scripts/relock-standards-pin.sh +++ b/scripts/relock-standards-pin.sh @@ -67,7 +67,8 @@ shopt -s nullglob wfs=("$WF"/*.yml "$WF"/*.yaml) shopt -u nullglob pins=$(grep -hoE "$PIN_RE" "${wfs[@]}" 2>/dev/null | sort -u || true) -stale=$(printf '%s\n' "$pins" | grep -v "@${TARGET}$" | grep . || true) +stale=$(printf '%s\n' "$pins" | awk -F@ -v target="$TARGET" \ + 'NF > 1 && $NF != substr(target, 1, length($NF))') [ -z "$stale" ] || refuse "workflows still pin other standards commits: $(echo $stale)" olds=$(grep -oE "'hyperpolymath/standards@[0-9a-f]{40}'" "$L" | tr -d "'" | sed 's/.*@//' | sort -u | grep -vx "$TARGET" || true) diff --git a/scripts/tests/propagate-workflow-pins-test.sh b/scripts/tests/propagate-workflow-pins-test.sh index 977bf00c5..cd873ac41 100755 --- a/scripts/tests/propagate-workflow-pins-test.sh +++ b/scripts/tests/propagate-workflow-pins-test.sh @@ -158,6 +158,47 @@ set -e try "linked shallow worktree defers to server reachability" test "$RC" -eq 0 try "linked shallow worktree does not report unreachable" not_contains "not reachable" "$OUT" +# ── 11. A refused relock must not stage the rewritten pins ─────────────────── +ROOT="$TEST_DIR/refused-many" +R="$ROOT/refused"; mk_consumer "$R" +cat > "$R/.github/workflows/actions.lock" <> "$R/.github/workflows/governance.yml" +git -C "$R" add .github/workflows/governance.yml +printf '\n# unstaged edit\n' >> "$R/.github/workflows/governance.yml" +git -C "$R" diff --cached --binary > "$TEST_DIR/index-before" +cp "$R/.github/workflows/actions.lock" "$TEST_DIR/lock-before" +mk_consumer "$ROOT/success" +set +e +OUT=$(bash "$PROP" --fix --to "$TARGET" "$ROOT" 2>&1) +RC=$? +set -e +git -C "$R" diff --cached --binary > "$TEST_DIR/index-after" +try "refused relock exits nonzero" test "$RC" -eq 1 +try "refused relock is reported" contains LOCK-REFUSED "$OUT" +try "refused relock leaves lock unchanged" cmp -s "$TEST_DIR/lock-before" "$R/.github/workflows/actions.lock" +try "refused relock preserves index without staging pins" cmp -s "$TEST_DIR/index-before" "$TEST_DIR/index-after" +try "refused relock leaves branch unchanged" test "$(git -C "$R" branch --show-current)" = main +try "refused relock retains staged workflow edit" file_has "$R/.github/workflows/governance.yml" '# staged edit' +try "refused relock retains unstaged workflow edit" file_has "$R/.github/workflows/governance.yml" '# unstaged edit' +try "refused relock reports skipped staging" contains 'staging skipped' "$OUT" +try "refused relock does not prevent other repo fixes" file_has "$ROOT/success/.github/workflows/governance.yml" "governance-reusable.yml@${TARGET}" + echo "----------------------------------------" echo "$PASS/$TOTAL test cases passed." [ "$PASS" -eq "$TOTAL" ] || { echo "Some propagation tests FAILED."; exit 1; } diff --git a/scripts/tests/relock-standards-pin-test.sh b/scripts/tests/relock-standards-pin-test.sh index 247932e31..e8a1033cf 100755 --- a/scripts/tests/relock-standards-pin-test.sh +++ b/scripts/tests/relock-standards-pin-test.sh @@ -128,6 +128,19 @@ cp "$D/actions.lock" "$T/once"; bash "$RELOCK" --to "$NEW" --standards "$S" "$D" cmp -s "$T/once" "$D/actions.lock" || fail "second run changed the lock" echo " PASS idempotent" +# Matching abbreviated and full pins can coexist without rewriting workflows. +for length in 7 12 39; do + D=$(consumer "prefix-$length") + cp "$D/caller.yml" "$D/full.yml" + sed -i "s/@$NEW/@${NEW:0:length}/" "$D/caller.yml" + cp "$D/caller.yml" "$T/prefix-before" + bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null + grep -q "^ 'hyperpolymath/standards@$NEW':\$" "$D/actions.lock" || fail "$length-character pin not relocked" + grep -q "$OLD" "$D/actions.lock" && fail "$length-character pin left OLD in lock" + cmp -s "$T/prefix-before" "$D/caller.yml" || fail "abbreviated pin was rewritten" + echo " PASS matching $length-character and full pins" +done + # 3. Refusals leave the lock byte-identical. refuse_case() { # name mutate-command local d; d=$(consumer "$1"); eval "$2"; cp "$d/actions.lock" "$T/before" @@ -136,6 +149,11 @@ refuse_case() { # name mutate-command echo " PASS refused: $1" } refuse_case still-on-old 'sed -i "s/@$NEW/@$OLD/" "$d/caller.yml"' +BAD_PREFIX="${NEW:0:11}" +if [ "${NEW:11:1}" = 0 ]; then BAD_PREFIX+=1; else BAD_PREFIX+=0; fi +refuse_case different-prefix 'sed -i "s/@$NEW/@$BAD_PREFIX/" "$d/caller.yml"' +refuse_case mixed-commits 'cp "$d/caller.yml" "$d/other.yml"; sed -i "s/@$NEW/@$BAD_PREFIX/" "$d/other.yml"' +refuse_case overlong-pin 'sed -i "s/@$NEW/@${NEW}a/" "$d/caller.yml"' # A pin whose reusable needs a record that neither lock has: a commit NEW2 # whose reusable uses acme/tool at a ref standards' own lock does not record. sed -i "s#acme/tool/sub@3333333333333333333333333333333333333333#acme/tool/sub@5555555555555555555555555555555555555555#" "$S/.github/workflows/x-reusable.yml" From 5fd137374fab85606c3a20d48d731f5de07943d9 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:26:01 +0000 Subject: [PATCH 4/8] docs(pins): clarify workflow pinning function behavior and failures --- scripts/apply-workflow-pins-remote.sh | 19 ++++++++++++++----- scripts/propagate-workflow-pins.sh | 9 +++++++-- scripts/relock-standards-pin.sh | 4 ++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/scripts/apply-workflow-pins-remote.sh b/scripts/apply-workflow-pins-remote.sh index d7936b80c..00ec859f0 100755 --- a/scripts/apply-workflow-pins-remote.sh +++ b/scripts/apply-workflow-pins-remote.sh @@ -364,14 +364,16 @@ list_repos() { rm -f "$all" "$part" } -# fetch_workflows — download .github/workflows/*.y*ml. +# fetch_workflows — download .github/workflows/*.yml, +# *.yaml and actions.lock, when present, into destdir. # The REMOTE content is the only evidence: a local checkout can be arbitrarily # stale, and reading one is what produced a false "285 callers track main" # census on 2026-09-15. -# Returns non-zero when the answer is UNKNOWN (rate limit, 5xx, a file that -# would not download). Only a 404 is "no workflows". Returning 0 on failure -# made a rate-limited repo vanish from the census while `walked N` still -# counted it (measured 2026-10-02, shared 5,000/h token exhausted mid-run). +# Returns 0 for a successful GraphQL response, including an empty tree. Blobs +# without text are skipped; decoding failures remove the destination file +# without changing the success status. In the REST fallback, returns 0 for +# a missing directory (HTTP 404), and 1 for other listing or download failures; +# files already downloaded remain in destdir on failure. fetch_workflows() { local repo="$1" dest="$2" owner="${1%%/*}" name="${1##*/}" resp mkdir -p "$dest" @@ -494,6 +496,13 @@ commit_payload() { # --------------------------------------------------------------------------- # MAIN # --------------------------------------------------------------------------- +# main [options] — audit remote standards pins and write a TSV census to +# stdout, or to --out. Under --fix, fetch the target commit locally if needed +# and create remote branches, commits and PRs for changed workflows and locks. +# Fetch, relock and PR failures become census statuses and processing continues; +# a relock failure prevents that repository's changes from being submitted. +# Exits 1 on failed controls, authentication, target validation/fetch or repository +# enumeration, and 2 on an unknown option; --self-test exits with its test status. main() { while [ $# -gt 0 ]; do case "$1" in diff --git a/scripts/propagate-workflow-pins.sh b/scripts/propagate-workflow-pins.sh index 2ec7222ee..81255ae62 100755 --- a/scripts/propagate-workflow-pins.sh +++ b/scripts/propagate-workflow-pins.sh @@ -175,8 +175,13 @@ rewrite_file() { # Per-repo processing # --------------------------------------------------------------------------- -# process_repo REPO — audit, and under --fix rewrite, every standards pin in -# REPO's workflows, relock its actions.lock to match, and stage the result. +# process_repo REPO — print an audit of matching standards SHA pins in the +# local checkout's workflows; do nothing if its workflows directory is absent. +# Under --fix, rewrite pins to TARGET_SHA and relock an existing actions.lock +# when workflows change. A relock failure increments LOCK_REFUSED for the +# script's final exit status and leaves rewritten workflows in place, but +# skips branch selection and staging. Otherwise, branch/staging failures are +# ignored; returns 0 after processing without committing or pushing. process_repo() { local repo="$1" name name="$(basename "$repo")" diff --git a/scripts/relock-standards-pin.sh b/scripts/relock-standards-pin.sh index 996de5bd0..7adf1fbc5 100755 --- a/scripts/relock-standards-pin.sh +++ b/scripts/relock-standards-pin.sh @@ -79,6 +79,8 @@ tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT q="'" # rec_body KEY FILE — print the dependency record KEY (header and body) from FILE. +# KEY is the unquoted owner/repo@ref. A missing record produces no output and +# succeeds; a file read error propagates awk's non-zero status. rec_body() { awk -v k=" ${q}$1${q}:" '$0==k{f=1;print;next} f&&/^ [^ ]/{exit} f' "$2"; } # Actions the called reusables use at TARGET, normalised to owner/repo@ref. @@ -123,6 +125,8 @@ while read -r u; do done < "$tmp/uses.s" # Prune candidates nothing references any more (to a fixed point). +# deps_line FILE — print the one-based line number of each exact dependencies: +# header. Returns non-zero if no header is found or the file cannot be read. deps_line() { grep -n '^dependencies:$' "$1" | cut -d: -f1; } while :; do n=$(deps_line "$tmp/lock") From f3f8b8bec9893674053b28dcb93eb1419fcb2035 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:27:36 +0000 Subject: [PATCH 5/8] fix(pins): a refused relock restores the workflows; a short pin keeps its own record Builds on the two CodeRabbit autofix commits for the #1254 findings, closing what they left open. - propagate-workflow-pins.sh: on LOCK-REFUSED the autofix skipped staging but left the rewritten pins in the working tree, one `git add` from the state the relock exists to prevent. The propagator now keeps the originals of the files it rewrites and restores them on refusal (uncommitted edits included); index and branch stay untouched. - relock-standards-pin.sh: the autofix accepted a prefix pin but still fed every pin into the target's record, and re-keyed the short-pinned workflow's entry to the full SHA. The gates match an entry to a `uses:` by its ref as written, so that lock is rejected. Now a 7+ hex prefix of the target is current, keeps its own short-keyed record, and only full pins feed the target's record. Before writing, each workflow's standards entries must name exactly the refs it pins, or the relock is refused. Under 7 hex is refused, as the applier reads 7..40. - The refusal messages no longer use $(echo $var) (CodeFactor SC2116). - apply-workflow-pins-remote.sh: fetch_workflows keeps the measured 2026-10-02 rationale for its non-404 failure status. Tests: relock 16 checks (short pins at 7/12/39 hex with check-lock-sync, entry desync and 6-hex refusals, plus the autofix's refusals); propagate 29/29 (byte-for-byte restore added to the autofix's case 11). Mutants killed: exact-match-only, all-pins-feed-the-record, no 7-hex minimum, no consistency guard, no restore, no early return. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LiQzWJdcPfRU6YMDGRG8Xi --- scripts/apply-workflow-pins-remote.sh | 5 +- scripts/propagate-workflow-pins.sh | 24 ++++-- scripts/relock-standards-pin.sh | 40 +++++++-- scripts/tests/propagate-workflow-pins-test.sh | 8 +- scripts/tests/relock-standards-pin-test.sh | 84 ++++++++++++++++--- 5 files changed, 130 insertions(+), 31 deletions(-) diff --git a/scripts/apply-workflow-pins-remote.sh b/scripts/apply-workflow-pins-remote.sh index 00ec859f0..935638b12 100755 --- a/scripts/apply-workflow-pins-remote.sh +++ b/scripts/apply-workflow-pins-remote.sh @@ -373,7 +373,10 @@ list_repos() { # without text are skipped; decoding failures remove the destination file # without changing the success status. In the REST fallback, returns 0 for # a missing directory (HTTP 404), and 1 for other listing or download failures; -# files already downloaded remain in destdir on failure. +# files already downloaded remain in destdir on failure. Only a 404 is "no +# workflows": returning 0 on failure made a rate-limited repo vanish from the +# census while `walked N` still counted it (measured 2026-10-02, shared +# 5,000/h token exhausted mid-run). fetch_workflows() { local repo="$1" dest="$2" owner="${1%%/*}" name="${1##*/}" resp mkdir -p "$dest" diff --git a/scripts/propagate-workflow-pins.sh b/scripts/propagate-workflow-pins.sh index 81255ae62..e806730d7 100755 --- a/scripts/propagate-workflow-pins.sh +++ b/scripts/propagate-workflow-pins.sh @@ -179,9 +179,10 @@ rewrite_file() { # local checkout's workflows; do nothing if its workflows directory is absent. # Under --fix, rewrite pins to TARGET_SHA and relock an existing actions.lock # when workflows change. A relock failure increments LOCK_REFUSED for the -# script's final exit status and leaves rewritten workflows in place, but -# skips branch selection and staging. Otherwise, branch/staging failures are -# ignored; returns 0 after processing without committing or pushing. +# script's final exit status, restores each rewritten workflow to its content +# before this run (uncommitted edits included), and skips branch selection and +# staging. Otherwise, branch/staging failures are ignored; returns 0 after +# processing without committing or pushing. process_repo() { local repo="$1" name name="$(basename "$repo")" @@ -189,7 +190,9 @@ process_repo() { [ -d "$wfdir" ] || return 0 local total_behind=0 files_touched=0 - local f base behind + local f base behind orig="" + # The originals of the files rewritten below, restored if the lock refuses. + if $FIX_MODE; then orig="$(mktemp -d)"; fi for f in "$wfdir"/*.yml "$wfdir"/*.yaml; do [ -f "$f" ] || continue grep -qE "$PIN_RE" "$f" || continue @@ -200,6 +203,7 @@ process_repo() { total_behind=$((total_behind + behind)) files_touched=$((files_touched + 1)) if $FIX_MODE; then + cp -p "$f" "$orig/$base" rewrite_file "$f" fi else @@ -217,10 +221,16 @@ process_repo() { else printf '%-30s\t%-28s\t%s\t-> %s\n' "$name" "actions.lock" "LOCK-REFUSED" "${TARGET_SHA:0:12}" LOCK_REFUSED=$((LOCK_REFUSED + 1)) - # Leave this repo's index and branch untouched when the lock cannot follow. + # New pins over the old lock is the state the relock exists to prevent: + # put this repo's workflows back as they were, and leave its index and + # branch untouched. + cp -p "$orig"/* "$wfdir"/ + rm -rf "$orig" + log " [not staged] $name — lock refused; workflows restored" return 0 fi fi + [ -z "$orig" ] || rm -rf "$orig" if $FIX_MODE && [ "$files_touched" -gt 0 ] && git -C "$repo" rev-parse --git-dir >/dev/null 2>&1; then local current_branch @@ -267,7 +277,7 @@ else echo "# Re-run with --fix to stage the bump on a branch (no commit/push)." fi if [ "$LOCK_REFUSED" -gt 0 ]; then - echo "# LOCK-REFUSED in $LOCK_REFUSED repo(s): their pins moved but actions.lock could not follow; staging skipped." - echo "# Do NOT commit those as they stand; see relock-standards-pin.sh's message above." + echo "# LOCK-REFUSED in $LOCK_REFUSED repo(s): actions.lock could not follow the pins; workflows restored, staging skipped." + echo "# See relock-standards-pin.sh's message above." exit 1 fi diff --git a/scripts/relock-standards-pin.sh b/scripts/relock-standards-pin.sh index 7adf1fbc5..e8f1d6156 100755 --- a/scripts/relock-standards-pin.sh +++ b/scripts/relock-standards-pin.sh @@ -26,10 +26,14 @@ # kept alive (nothing else is pruned); # * dependency records in C-locale key order, as gh actions-lock writes them. # +# A short pin that prefixes the target (as the applier leaves a FRESH one) is +# current: its own short-keyed record is left alone. +# # FAIL-CLOSED: it works on a copy and leaves the lock untouched (exit 1) when a # workflow still pins a standards reusable at another SHA, when the called -# reusable is not in standards at TARGET, when the OLD record is missing, or -# when a needed record is in neither lock. A lock with a dangling edge is +# reusable is not in standards at TARGET, when the OLD record is missing, +# when a needed record is in neither lock, or when a workflow's standards entries +# would not name exactly the refs it pins. A lock with a dangling edge is # worse than a stale one: the stale one is at least reported by the gate. # # Usage: relock-standards-pin.sh --to [--standards ] @@ -46,7 +50,7 @@ while [ $# -gt 0 ]; do --to) TARGET="${2:-}"; shift 2 ;; --to=*) TARGET="${1#*=}"; shift ;; --standards) SROOT="${2:-}"; shift 2 ;; - -h|--help) sed -n '2,41p' "$0"; exit 0 ;; + -h|--help) sed -n '2,44p' "$0"; exit 0 ;; -*) echo "relock-standards-pin: unknown option $1" >&2; exit 2 ;; *) WF="$1"; shift ;; esac @@ -67,13 +71,21 @@ shopt -s nullglob wfs=("$WF"/*.yml "$WF"/*.yaml) shopt -u nullglob pins=$(grep -hoE "$PIN_RE" "${wfs[@]}" 2>/dev/null | sort -u || true) -stale=$(printf '%s\n' "$pins" | awk -F@ -v target="$TARGET" \ - 'NF > 1 && $NF != substr(target, 1, length($NF))') -[ -z "$stale" ] || refuse "workflows still pin other standards commits: $(echo $stale)" +# A short pin (7+ hex) that prefixes TARGET names TARGET: the applier counts it +# FRESH and leaves it as written, and the lock keys it by that short ref in a +# record of its own, which is left as it is. Only full pins feed TARGET's record. +full="" stale="" +for p in $pins; do + r=${p##*@} + if [ "$r" = "$TARGET" ]; then full="$full $p" + elif [ "${#r}" -lt 7 ] || [ "${TARGET:0:${#r}}" != "$r" ]; then stale="$stale $p" + fi +done +[ -z "$stale" ] || refuse "workflows still pin other standards commits:$stale" olds=$(grep -oE "'hyperpolymath/standards@[0-9a-f]{40}'" "$L" | tr -d "'" | sed 's/.*@//' | sort -u | grep -vx "$TARGET" || true) if [ -z "$olds" ]; then echo "relock-standards-pin: lock already keyed to standards@${TARGET:0:12}"; exit 0; fi -[ -n "$pins" ] || refuse "lock names standards commits but no workflow calls a standards reusable" +[ -n "$full" ] || refuse "lock names standards commits but no workflow pins standards@${TARGET:0:12} in full" tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT q="'" @@ -85,7 +97,7 @@ rec_body() { awk -v k=" ${q}$1${q}:" '$0==k{f=1;print;next} f&&/^ [^ ]/{ex # Actions the called reusables use at TARGET, normalised to owner/repo@ref. : > "$tmp/uses" -for p in $pins; do +for p in $full; do path=${p#hyperpolymath/standards/}; path=${path%@*} git -C "$SROOT" show "${TARGET}:${path}" > "$tmp/reusable" 2>/dev/null || refuse "$path is not in standards at ${TARGET:0:12}" sed 's/[[:space:]]#.*$//' "$tmp/reusable" \ @@ -155,7 +167,17 @@ n=$(deps_line "$tmp/out") grep -oE "^ {8,12}- '[^']+'" "$tmp/out" | sed -E "s/^ +- '|'$//g" | LC_ALL=C sort -u > "$tmp/edges" tail -n +"$((n+1))" "$tmp/out" | grep -oE "^ '[^']+':" | sed -E "s/^ '|':$//g" | LC_ALL=C sort -u > "$tmp/recs" dangling=$(LC_ALL=C comm -23 "$tmp/edges" "$tmp/recs") -[ -z "$dangling" ] || refuse "result would have dangling edges: $(echo $dangling)" +[ -z "$dangling" ] || refuse "result would have dangling edges: ${dangling//$'\n'/ }" + +# Each workflow's standards entries must name exactly the refs its pins carry. +# The gates match an entry to a `uses:` by its ref as written, so an entry +# re-keyed to TARGET for a workflow that pins a short ref is a desync they reject. +for w in "${wfs[@]}"; do + want=$(grep -oE "$PIN_RE" "$w" | sed 's#^hyperpolymath/standards/.*@#hyperpolymath/standards@#' | LC_ALL=C sort -u || true) + have=$(awk -v k=" '.github/workflows/${w##*/}':" '$0==k{f=1;next} f&&!/^ - /{exit} f' "$tmp/out" \ + | grep -oE "'hyperpolymath/standards@[0-9a-fA-F]+'" | tr -d "'" | LC_ALL=C sort -u || true) + [ "$want" = "$have" ] || refuse "${w##*/}: lock entries [${have//$'\n'/ }] would not match its pins [${want//$'\n'/ }]" +done cat "$tmp/out" > "$L" echo "relock-standards-pin: $L keyed to standards@${TARGET:0:12} (from $(echo $olds | sed 's/\([0-9a-f]\{12\}\)[0-9a-f]*/\1/g'))" diff --git a/scripts/tests/propagate-workflow-pins-test.sh b/scripts/tests/propagate-workflow-pins-test.sh index cd873ac41..c79e2eefd 100755 --- a/scripts/tests/propagate-workflow-pins-test.sh +++ b/scripts/tests/propagate-workflow-pins-test.sh @@ -158,7 +158,9 @@ set -e try "linked shallow worktree defers to server reachability" test "$RC" -eq 0 try "linked shallow worktree does not report unreachable" not_contains "not reachable" "$OUT" -# ── 11. A refused relock must not stage the rewritten pins ─────────────────── +# ── 11. A refused relock restores the workflows and stages nothing ────────── +# The reusables are not in standards at the target, so the relock is refused. +# New pins over the old lock must be left neither in the index nor in the tree. ROOT="$TEST_DIR/refused-many" R="$ROOT/refused"; mk_consumer "$R" cat > "$R/.github/workflows/actions.lock" <> "$R/.github/workflows/governance.yml" git -C "$R" diff --cached --binary > "$TEST_DIR/index-before" cp "$R/.github/workflows/actions.lock" "$TEST_DIR/lock-before" +cp "$R/.github/workflows/governance.yml" "$TEST_DIR/governance-before" +cp "$R/.github/workflows/scorecard.yml" "$TEST_DIR/scorecard-before" mk_consumer "$ROOT/success" set +e OUT=$(bash "$PROP" --fix --to "$TARGET" "$ROOT" 2>&1) @@ -196,6 +200,8 @@ try "refused relock preserves index without staging pins" cmp -s "$TEST_DIR/inde try "refused relock leaves branch unchanged" test "$(git -C "$R" branch --show-current)" = main try "refused relock retains staged workflow edit" file_has "$R/.github/workflows/governance.yml" '# staged edit' try "refused relock retains unstaged workflow edit" file_has "$R/.github/workflows/governance.yml" '# unstaged edit' +try "refused relock restores an edited workflow byte for byte" cmp -s "$TEST_DIR/governance-before" "$R/.github/workflows/governance.yml" +try "refused relock restores an unedited workflow byte for byte" cmp -s "$TEST_DIR/scorecard-before" "$R/.github/workflows/scorecard.yml" try "refused relock reports skipped staging" contains 'staging skipped' "$OUT" try "refused relock does not prevent other repo fixes" file_has "$ROOT/success/.github/workflows/governance.yml" "governance-reusable.yml@${TARGET}" diff --git a/scripts/tests/relock-standards-pin-test.sh b/scripts/tests/relock-standards-pin-test.sh index e8a1033cf..9e50372b2 100755 --- a/scripts/tests/relock-standards-pin-test.sh +++ b/scripts/tests/relock-standards-pin-test.sh @@ -36,6 +36,15 @@ jobs: - uses: actions/checkout@1111111111111111111111111111111111111111 # v1 - uses: acme/tool/sub@3333333333333333333333333333333333333333 Y +# A second reusable, called in the short-pin case only. +cat > "$S/.github/workflows/y-reusable.yml" <<'Y' +on: { workflow_call: {} } +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: zeta/only@7777777777777777777777777777777777777777 +Y cat > "$S/.github/workflows/actions.lock" <<'Y' version: 'v0.0.3' workflows: @@ -128,19 +137,6 @@ cp "$D/actions.lock" "$T/once"; bash "$RELOCK" --to "$NEW" --standards "$S" "$D" cmp -s "$T/once" "$D/actions.lock" || fail "second run changed the lock" echo " PASS idempotent" -# Matching abbreviated and full pins can coexist without rewriting workflows. -for length in 7 12 39; do - D=$(consumer "prefix-$length") - cp "$D/caller.yml" "$D/full.yml" - sed -i "s/@$NEW/@${NEW:0:length}/" "$D/caller.yml" - cp "$D/caller.yml" "$T/prefix-before" - bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null - grep -q "^ 'hyperpolymath/standards@$NEW':\$" "$D/actions.lock" || fail "$length-character pin not relocked" - grep -q "$OLD" "$D/actions.lock" && fail "$length-character pin left OLD in lock" - cmp -s "$T/prefix-before" "$D/caller.yml" || fail "abbreviated pin was rewritten" - echo " PASS matching $length-character and full pins" -done - # 3. Refusals leave the lock byte-identical. refuse_case() { # name mutate-command local d; d=$(consumer "$1"); eval "$2"; cp "$d/actions.lock" "$T/before" @@ -161,6 +157,26 @@ git -C "$S" commit -qam new2; NEW2=$(git -C "$S" rev-parse HEAD) refuse_case record-nowhere 'sed -i "s/@$NEW/@$NEW2/" "$d/caller.yml"; NEW_SAVE=$NEW; NEW=$NEW2' NEW=$NEW_SAVE refuse_case no-old-record 'sed -i "/^ .hyperpolymath\/standards@$OLD.:/,/^ - .old\/dep/d" "$d/actions.lock"' +refuse_case short-pin-elsewhere 'sed -i "s/@$NEW/@${OLD:0:7}/" "$d/caller.yml"' +# Under 7 hex is no pin the applier writes or reads, even when it prefixes NEW +# and the lock carries a matching entry and record for it. +# tiny DIR — add tiny.yml pinning a 6-hex prefix of NEW, with its lock entry. +tiny() { + sed "s/@$NEW/@${NEW:0:6}/" "$1/caller.yml" > "$1/tiny.yml" + sed -i "/^dependencies:\$/i\\ '.github/workflows/tiny.yml':\\n - 'hyperpolymath/standards@${NEW:0:6}'" "$1/actions.lock" + printf " 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n owner_id: 6759885\n repo_id: 1116521501\n" \ + "${NEW:0:6}" "${NEW:0:6}" "$NEW" >> "$1/actions.lock" +} +refuse_case short-pin-too-short 'tiny "$d"' +# desync DIR — caller.yml now pins a short NEW ref but its lock entry still +# names OLD; full.yml pins NEW in full. Re-keying caller.yml's entry to the full +# NEW would not match its pin, which the gates reject. +desync() { + cp "$1/caller.yml" "$1/full.yml" + sed -i "s/@$NEW/@${NEW:0:12}/" "$1/caller.yml" + sed -i "/^dependencies:\$/i\\ '.github/workflows/full.yml':\\n - 'hyperpolymath/standards@$OLD'" "$1/actions.lock" +} +refuse_case short-pin-entry-desync 'desync "$d"' # 4. No lock: nothing to do. D=$(consumer nolock); rm "$D/actions.lock" @@ -168,4 +184,46 @@ bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null || fail "no-lock rep [ ! -e "$D/actions.lock" ] || fail "a lock was created" echo " PASS no actions.lock: nothing to do" +# 5. A short pin that prefixes NEW is current (the applier leaves it FRESH), at +# the lengths the applier reads (7..39 hex). Its workflow and its own record +# stay byte-identical, and only full pins feed NEW's record: zeta/only, used by +# the short-pinned reusable alone, must not join it. +for length in 7 12 39; do + D=$(consumer "short-$length"); SHORT=${NEW:0:length} + printf 'on: push\njobs:\n call:\n uses: hyperpolymath/standards/.github/workflows/y-reusable.yml@%s\n' "$SHORT" > "$D/short.yml" + sed -i "/^dependencies:\$/i\\ '.github/workflows/short.yml':\\n - 'hyperpolymath/standards@$SHORT'" "$D/actions.lock" + cat >> "$D/actions.lock" < "$T/short-before" + cp "$D/short.yml" "$T/short-wf-before" + bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null || fail "$length-hex pin prefixing the target was refused" + L="$D/actions.lock" + cmp -s "$T/short-wf-before" "$D/short.yml" || fail "$length-hex pin: the workflow was rewritten" + short_rec "$L" | cmp -s - "$T/short-before" || fail "$length-hex pin: its own record was changed" + grep -q "^ - 'hyperpolymath/standards@$SHORT'\$" "$L" || fail "$length-hex pin: its workflow entry was re-keyed" + grep -q "$OLD" "$L" && fail "$length-hex pin: OLD left in the lock" + awk -v k=" 'hyperpolymath/standards@$NEW':" '$0==k{f=1;next} f&&/^ [^ ]/{exit} f' "$L" | grep -q zeta/only \ + && fail "$length-hex pin: a reusable called only by the short pin fed the full record" + grep -q "^ 'zeta/only@7777777777777777777777777777777777777777':\$" "$L" || fail "$length-hex pin: its record's dependency was pruned" + if command -v gawk >/dev/null 2>&1; then + sed -i '/unrelated\/orphan/,/repo_id: 41/d' "$L" + bash "$SYNC" "$D" >/dev/null 2>&1 || fail "$length-hex pin: check-lock-sync.sh rejects the relocked lock" + fi + echo " PASS $length-hex pin prefixing the target: left as it is, full record from full pins only" +done + echo "PASS: relock-standards-pin.sh" From 75580f34714b1670212fe03e94c0da08d8b2ee6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:33:07 +0000 Subject: [PATCH 6/8] test(governance-shape): freeze the two JS runtimes job names (port of #1255/#1256) Self Test has been red on main since #1249 added the js-runtimes jobs to governance-reusable.yml without adding their names to the frozen context list. This is the identical change carried by #1255 and #1256, ported so this PR's Self Test can go green; it no-ops once either lands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LiQzWJdcPfRU6YMDGRG8Xi --- tests/test_governance_reusable_shape.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_governance_reusable_shape.sh b/tests/test_governance_reusable_shape.sh index 8283661c2..9e89ac969 100755 --- a/tests/test_governance_reusable_shape.sh +++ b/tests/test_governance_reusable_shape.sh @@ -50,7 +50,9 @@ Trusted-base reduction policy Licence consistency Exemption ratchet Debt ratchet -UUID v7 conformance" +UUID v7 conformance +JS runtimes — environment provisions Bun first +JS runtimes — \${{ matrix.runtime }}" ACTUAL=$(grep -P '^ name: ' "$F" | sed 's/^ name: //') if [ "$(printf '%s' "$FROZEN" | sort)" = "$(printf '%s' "$ACTUAL" | sort)" ]; then ok "job names match the frozen context list ($(printf '%s\n' "$ACTUAL" | wc -l) jobs)" else bad "job names drifted from the frozen list — renames create phantom contexts estate-wide"; diff <(printf '%s\n' "$FROZEN" | sort) <(printf '%s\n' "$ACTUAL" | sort); fi From a508e5f93f1eed6b4c5cfb052f7967b31b49010f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:46 +0100 Subject: [PATCH 7/8] Restore workflows on relock refusal and preserve abbreviated pin records (#1257) Restore rewritten workflows byte for byte when relocking fails, preserving staged and unstaged edits and skipping branch changes and staging. Preserve matching abbreviated pins and their lock records, derive full-pin dependencies only from full pins, and reject mismatched workflow lock entries and invalid short refs. Expand regression coverage for restoration, abbreviated pins, and refusal cases. Clarify remote fetch failure reporting, census behavior, per-repository failure exit semantics, and local propagation staging and error behavior. Validation: Tests were not run; inspected the pinned diffs, commit history, contribution guidance, and commit-message hook. [View coding task](https://app.coderabbit.ai/code/tasks/ab062557-c72b-59b9-89c3-a03323e86d04?source=coding_agent_github_pr_description) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/apply-workflow-pins-remote.sh | 1 + scripts/propagate-workflow-pins.sh | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/apply-workflow-pins-remote.sh b/scripts/apply-workflow-pins-remote.sh index 935638b12..457faf70c 100755 --- a/scripts/apply-workflow-pins-remote.sh +++ b/scripts/apply-workflow-pins-remote.sh @@ -504,6 +504,7 @@ commit_payload() { # and create remote branches, commits and PRs for changed workflows and locks. # Fetch, relock and PR failures become census statuses and processing continues; # a relock failure prevents that repository's changes from being submitted. +# Recorded repository failures do not cause a non-zero exit status. # Exits 1 on failed controls, authentication, target validation/fetch or repository # enumeration, and 2 on an unknown option; --self-test exits with its test status. main() { diff --git a/scripts/propagate-workflow-pins.sh b/scripts/propagate-workflow-pins.sh index e806730d7..4e4eef8ba 100755 --- a/scripts/propagate-workflow-pins.sh +++ b/scripts/propagate-workflow-pins.sh @@ -181,8 +181,10 @@ rewrite_file() { # when workflows change. A relock failure increments LOCK_REFUSED for the # script's final exit status, restores each rewritten workflow to its content # before this run (uncommitted edits included), and skips branch selection and -# staging. Otherwise, branch/staging failures are ignored; returns 0 after -# processing without committing or pushing. +# staging. Otherwise, selects BRANCH and stages the entire workflows directory +# when pins change in a Git checkout; branch/staging failures are ignored. +# Returns 0 after processing without committing or pushing. Temporary directory +# creation, workflow backup, rewrite or restoration errors terminate the script. process_repo() { local repo="$1" name name="$(basename "$repo")" From 9db47ee8ba3c7afb20b495d8a963693b09a39e8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:43:47 +0000 Subject: [PATCH 8/8] test(relock): clear the three CodeFactor warnings - SC2115: `rm -rf "$T/$1"` becomes `rm -rf "${T:?}/${1:?}"`, so an empty name can never widen the removal to the whole temp tree. - SC2324 (x2): `BAD_PREFIX+=1` / `+=0` append a hex digit to a string, which is the intent; quoting the digit says so and silences the warning. No behaviour change: relock-standards-pin-test.sh passes as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N6my4F3ohjwFReLW8cYYqC --- scripts/tests/relock-standards-pin-test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/tests/relock-standards-pin-test.sh b/scripts/tests/relock-standards-pin-test.sh index 9e50372b2..5b6412e3e 100755 --- a/scripts/tests/relock-standards-pin-test.sh +++ b/scripts/tests/relock-standards-pin-test.sh @@ -67,7 +67,7 @@ git -C "$S" add -A; git -C "$S" commit -q -m new; NEW=$(git -C "$S" rev-parse HE # A consumer whose caller was re-pointed to NEW but whose lock is still on OLD. consumer() { - local d="$T/$1/.github/workflows"; rm -rf "$T/$1"; mkdir -p "$d" + local d="$T/$1/.github/workflows"; rm -rf "${T:?}/${1:?}"; mkdir -p "$d" printf 'on: push\njobs:\n call:\n uses: hyperpolymath/standards/.github/workflows/x-reusable.yml@%s\n' "$NEW" > "$d/caller.yml" printf 'on: push\njobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@1111111111111111111111111111111111111111\n' > "$d/own.yml" cat > "$d/actions.lock" <