From cded7e26cf9ec1063abe46bd938af374835916af Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Tue, 20 Oct 2026 14:08:14 +0000 Subject: [PATCH] fix(lock): close-lock keeps a moved tag as a leaf's ref: when no current tag names the pin close-lock refused to write a leaf record for a SHA-pinned action when no current tag pointed at that SHA (SKIP, exit 3). dtolnay/rust-toolchain moves `v1`, so old pins such as @e2a55d2 (standards@75bd5994 mirror-reusable.yml) and @02cb101 (panic-attack@b689a74 scan-and-report.yml) became unclosable, and every caller's actions.lock with them (measured on hyperpolymath/echidna#446, run 39423933176). GitHub accepts a record whose ref: names a tag that has since moved. Measured on standards run 39286638218 (2026-10-19 16:36 UTC): the runner used the lockfile, downloaded dtolnay/rust-toolchain@e2a55d2 from a record with ref: 'v1', and the step passed. v1 was already 683b588 by then (Dependabot moved standards to it at 14:45 UTC, e926751). Fallback, tried only when the tag listing succeeded and found nothing, in order: 1. the same key's record in the lock as it stood before gh actions-lock (new --prior FILE; regen_lock now snapshots and passes it), used only when its commit is the key SHA and its owner/repo ids match the API; 2. the `# ` comment at a call site of that SHA (this repository's workflows and the called workflows close-lock already reads). Either candidate is written only if it is a tag now (commits/tags/) and its commit descends from the pin (compare status ahead|identical). Branch names (`# stable`), deleted tags, self-SHA refs (standards#1271) and tags on another line are refused, and the key SKIPs with exit 3 as before. Tests: close-lock 34 -> 53 offline cases (both sources, and every refusal as a planted positive). regen-dependabot-locks 59 -> 61 (close-lock gets the pre-regeneration lock byte for byte; nothing is left in the checkout). lock-selfcheck 9/9 unchanged. All pass under mawk and gawk; nine guard mutants are each killed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JHfuPCv1pEiqkQGG5DQASb --- scripts/close-lock.sh | 111 ++++++++++++++++++- scripts/regen-dependabot-locks.sh | 12 +- scripts/tests/close-lock-test.sh | 102 ++++++++++++++++- scripts/tests/regen-dependabot-locks-test.sh | 11 +- 4 files changed, 227 insertions(+), 9 deletions(-) diff --git a/scripts/close-lock.sh b/scripts/close-lock.sh index 1025227a3..ade0eb721 100755 --- a/scripts/close-lock.sh +++ b/scripts/close-lock.sh @@ -50,10 +50,40 @@ # lock-selfcheck.sh and the pre-commit hook; its contract is the corpus in # scripts/tests/fixtures/actions-lock-closure/. # +# MOVED TAGS. A leaf record for a SHA-pinned action takes as its `ref:` a tag +# that points at that SHA now. A record whose `ref:` is the SHA itself is +# rejected by runner verification (standards#1271), so none is written. An +# action that moves its tags (dtolnay/rust-toolchain moves `v1`) leaves old pins +# with no tag at all. MEASURED on hyperpolymath/standards run 39286638218 +# (2026-10-19 16:36 UTC): the runner said "Using locked action versions from the +# workflow's lockfile", downloaded dtolnay/rust-toolchain@e2a55d2… from a record +# with `ref: 'v1'`, and the step passed. By then `v1` was already 683b588 +# (Dependabot moved standards to it at 14:45 UTC, e926751). So a record whose +# `ref:` names a tag that has since moved is accepted. When no current tag points +# at the SHA, and only when the tag listing itself succeeded, one of these is +# tried, in order: +# 1. the record for the same key in the lock as it stood BEFORE +# `gh actions-lock` rewrote it (`--prior FILE`; regen-dependabot-locks.sh +# passes it), used only when its commit is that SHA and its owner and repo +# ids are the ones the API returns now; +# 2. the `# ` comment at a call site of that SHA, in this repository's +# workflows or in a called workflow read below. +# The candidate's `ref:` is written only when it is a tag in that repository +# NOW and its current commit descends from the SHA (compare status `ahead` or +# `identical`). That holds when a tag moved forward past the pin. A branch name +# (`# stable`), a deleted tag, a self-SHA ref and a commit the tag never reached +# are refused, and the key SKIPs with exit 3 as before. +# # GH_BIN overrides the `gh` binary (the test suite uses an offline stub). set -euo pipefail -CHECK=0 -if [[ "${1:-}" == "--check" ]]; then CHECK=1; shift; fi +CHECK=0; PRIOR="" +while :; do + case "${1:-}" in + --check) CHECK=1; shift ;; + --prior) PRIOR="${2:?close-lock: --prior needs a FILE}"; shift 2 ;; + *) break ;; + esac +done WF="${1:-.github/workflows}" if [[ "$CHECK" -eq 1 && -f "$WF" ]]; then L="$WF"; else L="$WF/actions.lock"; fi GH_BIN="${GH_BIN:-gh}" @@ -62,6 +92,10 @@ if [[ ! -f "$L" ]]; then [[ "$CHECK" -eq 1 ]] && exit 2 exit 1 fi +if [[ -n "$PRIOR" && ! -f "$PRIOR" ]]; then + echo "close-lock: no prior lock $PRIOR" >&2 + exit 1 +fi SC="$(mktemp -d)"; trap 'rm -rf "$SC"' EXIT TAB="$(printf '\t')" # keys: list the dependency record keys already present in the lock. @@ -97,6 +131,50 @@ reusable_calls() { keyfile() { printf '%s' "$1" | sha256sum | cut -c1-32; } # fold: lowercase the owner/repo part of each owner/repo@ref on stdin. fold() { awk '{ at = index($0, "@"); if (at) print tolower(substr($0, 1, at - 1)) substr($0, at); else print tolower($0) }'; } +# uses_tags FILE: "keytag" for each external SHA-pinned `uses:` in FILE that +# carries a trailing `# ` comment (the first word of the comment). +uses_tags() { + { grep -E '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]' "$1" \ + | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]+//' | tr -d '"'"'"'' \ + | awk -v t="$TAB" '{ if (!match($0, /[[:space:]]#/)) next + v = substr($0, 1, RSTART - 1); sub(/[[:space:]]+$/, "", v); sub(/,$/, "", v) + c = substr($0, RSTART + RLENGTH); sub(/^[[:space:]]+/, "", c); split(c, w, /[[:space:]]+/) + if (w[1] == "" || v ~ /^(\.\/|\$\/|docker:\/\/)/ || v ~ /[[:space:]]/) next + at = index(v, "@"); if (!at) next + r = substr(v, at + 1); if (length(r) != 40 || r !~ /^[0-9a-f]+$/) next + n = split(substr(v, 1, at - 1), a, "/"); if (n < 2 || a[1] == "" || a[2] == "") next + print a[1] "/" a[2] "@" r t w[1] }'; } || true +} +# prior_record KEY: "refcommitowner_idrepo_id" of KEY's record in +# the --prior lock (owner/repo compared case-insensitively), or nothing. +prior_record() { + [ -n "$PRIOR" ] || return 0 + awk -v k="$(printf '%s\n' "$1" | fold)" -v t="$TAB" ' + function fk(s, at) { at = index(s, "@"); return tolower(substr(s, 1, at - 1)) substr(s, at) } + /^dependencies:[[:space:]]*$/ { d = 1; next } + d && /^[a-z_]+:/ { d = 0 } + !d { next } + /^ '\''[^'\'']+'\'':$/ { if (on) exit; s = $0; sub(/^ '\''/, "", s); sub(/'\'':$/, "", s); on = (fk(s) == k); next } + on && /^ ref: '\''[^'\'']+'\''$/ { r = $0; sub(/^ ref: '\''/, "", r); sub(/'\''$/, "", r) } + on && /^ commit: '\''[^'\'']+'\''$/ { c = $0; sub(/^ commit: '\''/, "", c); sub(/'\''$/, "", c) } + on && /^ owner_id: [0-9]+$/ { o = $2 } + on && /^ repo_id: [0-9]+$/ { p = $2 } + END { if (r != "") print r t c t o t p }' "$PRIOR" +} +# moved_tag OWNER NAME SHA TAG: succeed when TAG is a tag of OWNER/NAME now (not +# a branch, not a SHA) and its current commit descends from SHA, i.e. the tag +# moved forward past the pin. Otherwise print why on stdout and fail. +moved_tag() { + local tsha st + if ! printf '%s' "$4" | grep -qxE '[A-Za-z0-9._+@/-]+' || printf '%s' "$4" | grep -qxE '[0-9a-f]{40}'; then + echo "not a symbolic tag name"; return 1 + fi + tsha=$("$GH_BIN" api "repos/$1/$2/commits/tags/$4" --jq .sha 2>/dev/null) || tsha="" + if ! printf '%s' "$tsha" | grep -qxE '[0-9a-f]{40}'; then echo "no tag of that name"; return 1; fi + st=$("$GH_BIN" api "repos/$1/$2/compare/$3...$tsha" --jq .status 2>/dev/null) || st="" + case "$st" in ahead|identical) return 0 ;; esac + echo "tag is at ${tsha:0:12}, which does not descend from the pin (compare: ${st:-failed})"; return 1 +} if [[ "$CHECK" -eq 1 ]]; then keys | fold | LC_ALL=C sort -u > "$SC/have" @@ -119,9 +197,10 @@ if [[ "$CHECK" -eq 1 ]]; then fi # ── reusable workflows: read each called file once, transitively ───────────── -mkdir -p "$SC/u"; : > "$SC/read"; : > "$SC/bad"; : > "$SC/more" +mkdir -p "$SC/u"; : > "$SC/read"; : > "$SC/bad"; : > "$SC/more"; : > "$SC/ctags" for f in "$WF"/*.yml "$WF"/*.yaml; do [ -e "$f" ] || continue + uses_tags "$f" >> "$SC/ctags" uses_values "$f" | reusable_calls done | LC_ALL=C sort -u > "$SC/calls" while :; do @@ -135,6 +214,7 @@ while :; do && grep -qE '^[[:space:]]*\{?[[:space:]]*"?jobs"?[[:space:]]*:' "$SC/body"; then uses_values "$SC/body" | external_refs >> "$SC/u/$(keyfile "$k")" uses_values "$SC/body" | reusable_calls >> "$SC/more" + uses_tags "$SC/body" >> "$SC/ctags" else echo " SKIP $k (cannot read $p at $ref)"; echo "$k" >> "$SC/bad" fi @@ -219,13 +299,34 @@ for pass in 1 2 3 4 5 6; do if printf '%s' "$ref" | grep -qxE '[0-9a-f]{40}' && ! grep -qxF "$k$TAB" "$SC/owed"; then # Consume all pages before choosing a tag. An early pipeline exit can # SIGPIPE gh under pipefail and discard an otherwise valid result. - tag_ref="" + tag_ref=""; listed=0; why="" if "$GH_BIN" api "repos/$owner/$name/tags?per_page=100" --paginate \ --jq '.[] | select(.commit.sha == "'"$sha"'") | .name' > "$SC/tags" 2>/dev/null; then + listed=1 tag_ref=$(awk '/^[A-Za-z0-9._+@\/-]+$/ && !(length($0) == 40 && $0 ~ /^[0-9a-f]+$/) { if (!first) first = $0; if (!preferred && $0 ~ /^v?[0-9]+\.[0-9]+\.[0-9]+/) preferred = $0 } END { if (preferred) print preferred; else if (first) print first }' "$SC/tags") fi + # No current tag points at the SHA: try a moved tag (see MOVED TAGS). + if [ -z "$tag_ref" ] && [ "$listed" -eq 1 ]; then + IFS="$TAB" read -r p_ref p_commit p_owner p_repo <<<"$(prior_record "$k")" || true + if [ -n "${p_ref:-}" ]; then + if [ "$p_commit" != "sha1-$sha" ]; then why="$why; prior record: commit $p_commit" + elif [ "$p_owner $p_repo" != "$ids" ]; then why="$why; prior record: ids $p_owner $p_repo, API $ids" + elif r=$(moved_tag "$owner" "$name" "$sha" "$p_ref"); then tag_ref="$p_ref"; src="the prior lock record" + else why="$why; prior ref '$p_ref': $r"; fi + fi + if [ -z "$tag_ref" ]; then + while IFS= read -r c; do + [ -n "$c" ] || continue + if r=$(moved_tag "$owner" "$name" "$sha" "$c"); then tag_ref="$c"; src="a call-site comment"; break; fi + why="$why; call-site '# $c': $r" + done < <(awk -F'\t' -v k="$(printf '%s\n' "$k" | fold)" 'BEGIN { OFS = "\t" } + { at = index($1, "@"); if (tolower(substr($1, 1, at - 1)) substr($1, at) == k) print ($2 ~ /^v?[0-9]+\.[0-9]+\.[0-9]+/ ? 0 : 1), $2 }' \ + "$SC/ctags" | LC_ALL=C sort -u | cut -f2) + fi + [ -n "$tag_ref" ] && echo " ~ $k ref '$tag_ref' from $src: no current tag points at the pin; the tag moved forward from it" + fi if [ -z "$tag_ref" ]; then - echo " SKIP $k (cannot resolve a symbolic tag for leaf SHA)" + echo " SKIP $k (cannot resolve a symbolic tag for leaf SHA${why:+$why})" skipped=$((skipped + 1)) continue fi diff --git a/scripts/regen-dependabot-locks.sh b/scripts/regen-dependabot-locks.sh index d65e48b44..19989f62e 100755 --- a/scripts/regen-dependabot-locks.sh +++ b/scripts/regen-dependabot-locks.sh @@ -25,6 +25,9 @@ # gh actions-lock → relock-sha-keys → complete-job-refs → close-lock → prune-stale # then RESTORES every file except actions.lock, because `gh actions-lock` # rewrite mode de-pins inline SHAs and can invent `uses: $/...` local refs. +# close-lock gets the lock as it stood before `gh actions-lock` (--prior). That +# lets a leaf whose tag has since moved keep its old `ref:` (close-lock.sh, +# MOVED TAGS). # The result is committed only when: # * the ONLY changed path is .github/workflows/actions.lock, # * the lock names no `$/` ref and close-lock resolved every edge, and @@ -73,10 +76,14 @@ LOCK_PATH=.github/workflows/actions.lock # --verify-local, fails a lock that is missing their refs. Before that, they # were refused as `composite-unsupported`. regen_lock() { - local dir="$1" changed + local dir="$1" changed prior + prior="$(mktemp)" ( cd "$dir" || exit 1 [ -f "$LOCK_PATH" ] || { echo no-lock; exit 0; } + # Snapshot the lock before the tool rewrites it: close-lock may reuse a + # record from it for a leaf whose tag moved (close-lock.sh, MOVED TAGS). + cp "$LOCK_PATH" "$prior" || { echo tool-failed; exit 0; } # `gh actions-lock` exits non-zero whenever a workflow names a mutable # ref it will not auto-pin (e.g. hyperpolymath/cicd-suite/...@main, which # the template's dogfood-gate and main-estate-audit carry), even though it @@ -95,7 +102,7 @@ regen_lock() { git clean -fdq bash "$REGEN_TOOLS/relock-sha-keys.sh" >&2 || { echo tool-failed; exit 0; } bash "$REGEN_TOOLS/complete-job-refs.sh" >&2 || { echo tool-failed; exit 0; } - GH_BIN="$GH_BIN" bash "$REGEN_TOOLS/close-lock.sh" >&2 + GH_BIN="$GH_BIN" bash "$REGEN_TOOLS/close-lock.sh" --prior "$prior" >&2 case $? in 0) ;; 3) echo unresolvable; exit 0 ;; *) echo tool-failed; exit 0 ;; esac bash "$REGEN_TOOLS/prune-stale.sh" >&2 || { echo tool-failed; exit 0; } if grep -Fq '$/' "$LOCK_PATH"; then echo corrupt; exit 0; fi @@ -110,6 +117,7 @@ regen_lock() { if ! bash "$REGEN_TOOLS/check-lock-sync.sh" >&2; then echo unverified; exit 0; fi if [ -z "$changed" ]; then echo current; else echo changed; fi ) + rm -f "$prior" } # Commit messages: a Dependabot/applier PR repair, and a default-branch repair. diff --git a/scripts/tests/close-lock-test.sh b/scripts/tests/close-lock-test.sh index 257e60fa9..bace0c569 100755 --- a/scripts/tests/close-lock-test.sh +++ b/scripts/tests/close-lock-test.sh @@ -26,7 +26,9 @@ bad() { echo "FAIL: $1"; fail=$((fail + 1)); } # Stub: repos// → "7 9"; commits/v1 → $C; anything under o/missing → an # HTTP error body on stdout, exit 1; anything under o/garbled → a truncated body -# on stdout, exit 0 (the {"messa shape AGENTS.md §5 records). A contents/ call +# on stdout, exit 0 (the {"messa shape AGENTS.md §5 records). Moved tags: the +# tag v1 of any o/ repo is at $C, $C descends from $A (compare `ahead`), $B is +# on another line (`diverged`), and no other tag exists. A contents/ call # serves the file planted under $WORK/contents (its path with / ? = as _), or a # 404 body with exit 1 when none is planted. mkdir -p "$WORK/contents" @@ -48,6 +50,10 @@ case "\$2" in repos/o/buildmeta/tags*) echo 'v1.2.3+build.4' ;; repos/o/releaseat/tags*) echo 'release@2026' ;; repos/o/leaf/tags*) printf 'v4\nv4.38.0\n' ;; + repos/o/*/commits/tags/v1) echo $C ;; + repos/o/*/commits/tags/*) echo '{"message":"No commit found"}'; exit 1 ;; + repos/o/*/compare/$A...$C) echo ahead ;; + repos/o/*/compare/*) echo diverged ;; repos/o/*/commits/v1) echo $C ;; repos/o/*/commits/*) echo '{"message":"No commit found"}'; exit 1 ;; repos/o/*) echo '7 9' ;; @@ -256,6 +262,100 @@ out=$(run "$d"); st=$? [ "$st" = 3 ] && ! grep -qxF " 'o/garbled@$A':" "$d/actions.lock" \ && ok "malformed called file (exit 0, no jobs:) → SKIP, exit 3" || bad "malformed called file → SKIP, exit 3 — st=$st $out" +# runp : run with --prior. +runp() { (cd "$1/../.." && GH_BIN="$WORK/gh" bash "$TARGET" --prior "$2") 2>&1; } +# mkprior : a pre-regeneration lock whose dependencies: come from stdin. +mkprior() { { echo 'dependencies:'; cat; } > "$WORK/$1.prior"; printf '%s' "$WORK/$1.prior"; } +# refused