From 6de5f9248d32efe953ee90317cd4915ca81ae65e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:34:23 +0000 Subject: [PATCH] fix(lock): relock-standards-pin matches records case-insensitively on owner/repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's lockfile parser (github/actions-lockfile v0.0.6) folds owner/repo case. 'Swatinem/rust-cache@6323deb…' and 'swatinem/rust-cache@6323deb…' are one key to it. Two records for that key with different metadata are a parse error ("duplicate action key ... after canonicalization with differing metadata"), and every run that reads the lock then fails. relock-standards-pin.sh looked records up byte-for-byte. A consumer that held 'Swatinem/rust-cache@6323deb…' (ref v2) was treated as lacking the record the reusables name as 'swatinem/rust-cache@6323deb…', so the script copied standards' record (ref v2.9.2) beside it. Measured on hyperpolymath/januskey when re-pinning 7d2041a -> 75bd599: the result failed the parser. The fix: - Record lookup (and the poisoned self-SHA repair) find the consumer's record under any owner/repo spelling and keep that spelling. - The prune pass compares edges and records the same way. - The closure check compares edges and records the same way. Tests: two new cases in scripts/tests/relock-standards-pin-test.sh, a record held under another case (kept, not duplicated) and a self-SHA record held under another case (repaired in place). Both failed before this change. The whole suite passes, including its check-lock-sync legs (gawk present). shellcheck reports no new findings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013CuBToxr5h9catptGiTsFX --- scripts/relock-standards-pin.sh | 36 +++++++++++++++----- scripts/tests/relock-standards-pin-test.sh | 38 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/scripts/relock-standards-pin.sh b/scripts/relock-standards-pin.sh index 8e17343e..00d6f901 100755 --- a/scripts/relock-standards-pin.sh +++ b/scripts/relock-standards-pin.sh @@ -95,6 +95,22 @@ q="'" # 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"; } +# GitHub's lockfile parser folds owner/repo case and keeps the ref as written: +# 'Swatinem/rust-cache@X' and 'swatinem/rust-cache@X' are one key, and two +# records for it with different metadata make the whole lock unparseable +# ("duplicate action key ... after canonicalization"). Lookups and the closure +# check compare keys that way. +# fold — read owner/repo@ref lines; print each with owner/repo lower-cased. +fold() { awk '{ i = index($0, "@"); if (i) print tolower(substr($0, 1, i - 1)) substr($0, i); else print }'; } +# lock_key KEY FILE — print the dependency key in FILE naming the same pin as +# KEY, in FILE's own spelling; nothing if FILE has none. +lock_key() { + local want; want=$(printf '%s\n' "$1" | fold) + awk -v q="$q" -v w="$want" 'index($0, " " q) == 1 && /@/ && /:$/ { + k = substr($0, 6, length($0) - 7); i = index(k, "@") + if (tolower(substr(k, 1, i - 1)) substr(k, i) == w) { print k; exit } }' "$2" +} + # Actions the called reusables use at TARGET, normalised to owner/repo@ref. : > "$tmp/uses" for p in $full; do @@ -130,13 +146,16 @@ awk -v k=" ${q}hyperpolymath/standards@$TARGET${q}:" '$0==k{s=1;next} s&&/^ git -C "$SROOT" show "${TARGET}:.github/workflows/actions.lock" > "$tmp/slock" 2>/dev/null || : > "$tmp/slock" while read -r u; do [ -n "$u" ] || continue - existing=$(rec_body "$u" "$tmp/lock") + # The consumer's record may spell owner/repo differently; it is still this pin's. + key=$(lock_key "$u" "$tmp/lock") + existing=""; [ -z "$key" ] || existing=$(rec_body "$key" "$tmp/lock") if [[ -n "$existing" ]]; then pin=${u##*@} if [[ "$pin" =~ ^[0-9a-f]{40}$ ]] && printf '%s\n' "$existing" | grep -qxF " ref: '$pin'"; then # A consumer can retain a poisoned copy after Standards repairs its own # record. Refresh only ref:, and only with identical commit and IDs. - repaired=$(rec_body "$u" "$tmp/slock") + skey=$(lock_key "$u" "$tmp/slock") + repaired=""; [ -z "$skey" ] || repaired=$(rec_body "$skey" "$tmp/slock") symbolic=$(printf '%s\n' "$repaired" | sed -n "s/^ ref: '\([^']*\)'$/\1/p") [[ -n "$symbolic" && ! "$symbolic" =~ ^[0-9a-fA-F]{40}$ ]] \ || refuse "no symbolic repair for $u in target Standards lock" @@ -145,7 +164,7 @@ while read -r u; do [[ "$local_identity" == "$source_identity" \ && "$(printf '%s\n' "$source_identity" | wc -l)" -eq 3 ]] \ || refuse "identity mismatch while repairing $u" - awk -v k=" '$u':" -v replacement=" ref: '$symbolic'" ' + awk -v k=" '$key':" -v replacement=" ref: '$symbolic'" ' $0 == k { selected = 1; print; next } /^ [^ ]/ { selected = 0 } selected && /^ ref:/ { print replacement; next } @@ -166,12 +185,13 @@ done < "$tmp/uses.s" 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" + grep -oE "^ {8,12}- '[^']+'" "$tmp/lock" | sed -E "s/^ +- '|'$//g" | fold | 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 + printf '%s\n' "$c" | fold | grep -qxFf - "$tmp/edges" && continue + tail -n +"$((n+1))" "$tmp/lock" > "$tmp/deps" + c=$(lock_key "$c" "$tmp/deps"); [ -n "$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 @@ -188,8 +208,8 @@ tail -n +"$((n+1))" "$tmp/lock" \ # 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" +grep -oE "^ {8,12}- '[^']+'" "$tmp/out" | sed -E "s/^ +- '|'$//g" | fold | LC_ALL=C sort -u > "$tmp/edges" +tail -n +"$((n+1))" "$tmp/out" | grep -oE "^ '[^']+':" | sed -E "s/^ '|':$//g" | fold | 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: ${dangling//$'\n'/ }" diff --git a/scripts/tests/relock-standards-pin-test.sh b/scripts/tests/relock-standards-pin-test.sh index 2748eda7..1db6ee08 100755 --- a/scripts/tests/relock-standards-pin-test.sh +++ b/scripts/tests/relock-standards-pin-test.sh @@ -270,4 +270,42 @@ Y echo " PASS $length-hex pin prefixing the target: left as it is, full record from full pins only" done + +# 6. GitHub's lockfile parser folds owner/repo case: 'ACME/tool@' and +# 'acme/tool@' are one key, and two records for it with different +# metadata are a parse error ("duplicate action key ... after +# canonicalization"), which fails every run. A record the consumer already +# holds under another owner/repo spelling is the needed record: it is kept, +# never copied a second time. Measured on hyperpolymath/januskey, whose +# 'Swatinem/rust-cache@6323deb…' met standards' 'swatinem/rust-cache@6323deb…'. +# case_variant DIR REF — give DIR's lock a 'ACME/Tool' spelling of the record +# NEW's reusable needs, with ref: REF. +case_variant() { + cat >> "$1/actions.lock" </dev/null || fail "case variant: relock refused" +L="$D/actions.lock" +[ "$(acme_keys "$L")" = 1 ] || fail "case variant: the record is keyed $(acme_keys "$L") times (the parser rejects a duplicate)" +grep -q "^ 'ACME/Tool@3333333333333333333333333333333333333333':\$" "$L" || fail "case variant: the consumer's spelling was not kept" +grep -q "ref: 'v3.0.1'" "$L" || fail "case variant: the consumer's symbolic ref was overwritten" +echo " PASS record held under another owner/repo case is kept, not duplicated" + +D=$(consumer case-self-sha); case_variant "$D" '3333333333333333333333333333333333333333' +bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null || fail "case variant self-SHA: relock refused" +L="$D/actions.lock" +[ "$(acme_keys "$L")" = 1 ] || fail "case variant self-SHA: the record is keyed $(acme_keys "$L") times" +grep -q "ref: '3333333333333333333333333333333333333333'" "$L" && fail "case variant self-SHA: the self-SHA ref survived" +awk "/^ 'ACME\\/Tool@3333333333333333333333333333333333333333':\$/{f=1;next} f&&/^ [^ ]/{exit} f" "$L" | grep -qx " ref: 'v3'" \ + || fail "case variant self-SHA: not repaired to standards' symbolic ref" +echo " PASS self-SHA record held under another owner/repo case is repaired in place" + echo "PASS: relock-standards-pin.sh"