From b44c3bd1f4fdd935a3ba1fff51f128330d822dc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:02:59 +0000 Subject: [PATCH 1/5] fix(lock): complete-job-refs dedupes owner/repo case-insensitively complete-job-refs compared workflow entries as exact strings. A workflow that spells `uses: Swatinem/rust-cache@v2.9.2` next to the lowercase entry gh actions-lock writes (`swatinem/rust-cache@v2.9.2`) got a second, capitalised entry. close-lock then treated it as a dangling edge to a new repository, and exited 3 ("lock is NOT closed") wherever it could not resolve that spelling. Measured on hyperpolymath/echidnabot main: four workflows gained the duplicate, and the chain refused to finish. GitHub owner/repository names are case-insensitive and refs are not; check-lock-sync.sh already keys entries that way. complete-job-refs now dedupes on the same key, so the existing spelling wins. Tests: new owner-case and ref-case cases in complete-job-refs-test.sh (18/18 under gawk and mawk); the unfixed script fails the owner-case case. close-lock (26), prune-stale (18), check-lock-sync (13) and regen-dependabot-locks (59) unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019a95kk88TZ1iegsBK3kox3 --- scripts/complete-job-refs.sh | 10 ++++++++-- scripts/tests/complete-job-refs-test.sh | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/scripts/complete-job-refs.sh b/scripts/complete-job-refs.sh index bd34b812d..8e351ff9a 100755 --- a/scripts/complete-job-refs.sh +++ b/scripts/complete-job-refs.sh @@ -100,15 +100,21 @@ done | LC_ALL=C sort -u >> "$WANT" # 2. rewrite the workflows: section, merging wanted job refs into each path entry LC_ALL=C awk -v want="$WANT" ' BEGIN { while ((getline l < want) > 0) { split(l, w, "\t"); WANT[w[1]] = WANT[w[1]] SUBSEP w[2] } } +# ck: the dedup key of a ref. GitHub owner/repository names are +# case-insensitive; refs are not (the same rule as check-lock-sync.sh). An +# existing `swatinem/rust-cache@v2` entry therefore already covers +# `uses: Swatinem/rust-cache@v2`; adding the second spelling left a ref that +# close-lock then tried to resolve as a new repository. +function ck(s, i) { i = match(s, /@[^@]*$/); return i ? tolower(substr(s, 1, i - 1)) substr(s, i) : s } # flush: write the current workflow entry, merged with its wanted refs, sorted and deduplicated. function flush( i, n, arr, j, k, seen, out, c) { if (cur == "") return SEEN[cur] = 1 n = split(items, arr, SUBSEP) c = 0; delete seen; delete out - for (i = 1; i <= n; i++) if (arr[i] != "" && !(arr[i] in seen)) { seen[arr[i]] = 1; out[++c] = arr[i] } + for (i = 1; i <= n; i++) if (arr[i] != "" && !(ck(arr[i]) in seen)) { seen[ck(arr[i])] = 1; out[++c] = arr[i] } if (cur in WANT) { n = split(WANT[cur], arr, SUBSEP) - for (i = 1; i <= n; i++) if (arr[i] != "" && !(arr[i] in seen)) { seen[arr[i]] = 1; out[++c] = arr[i] } } + for (i = 1; i <= n; i++) if (arr[i] != "" && !(ck(arr[i]) in seen)) { seen[ck(arr[i])] = 1; out[++c] = arr[i] } } if (c == 0) { printf " '\''%s'\'': []\n", cur; cur = ""; items = ""; return } for (i = 1; i < c; i++) for (j = i + 1; j <= c; j++) if (out[j] < out[i]) { k = out[i]; out[i] = out[j]; out[j] = k } printf " '\''%s'\'':\n", cur diff --git a/scripts/tests/complete-job-refs-test.sh b/scripts/tests/complete-job-refs-test.sh index bdad2b24f..5f4ed6b2e 100755 --- a/scripts/tests/complete-job-refs-test.sh +++ b/scripts/tests/complete-job-refs-test.sh @@ -114,6 +114,28 @@ else bad "the local-composite block differs between complete-job-refs, prune-stale and check-lock-sync" fi +echo "=== owner/repo case: an existing entry covers a differently cased uses: ===" +mkdir -p "$WORK/t5/.github/workflows" +printf 'jobs:\n a:\n runs-on: x\n steps:\n - uses: Swatinem/rust-cache@v2\n - uses: o/r@V3\n' \ + > "$WORK/t5/.github/workflows/c.yml" +cat > "$WORK/t5/.github/workflows/actions.lock" </dev/null; st=$? +L5="$WORK/t5/.github/workflows/actions.lock" +[ "$st" = 0 ] && grep -qxF " - 'swatinem/rust-cache@v2'" "$L5" && ! grep -qF "'Swatinem/rust-cache@v2'" "$L5" \ + && ok "owner case differs only → existing entry kept, no second spelling" \ + || bad "owner case differs only → existing entry kept, no second spelling (st=$st)" +grep -qxF " - 'o/r@V3'" "$L5" && grep -qxF " - 'o/r@v3'" "$L5" \ + && ok "ref case differs → both refs kept (refs are case-sensitive)" \ + || bad "ref case differs → both refs kept (refs are case-sensitive)" + echo "=== structural ===" mkrepo t3; printf 'dependencies:\n' > "$WORK/t3/.github/workflows/actions.lock" run t3 >/dev/null; st=$? From a28e861c23a4c338f6a838fb03b1a1f1a2a3b350 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:03:33 +0000 Subject: [PATCH 2/5] fix(lock): keep the dependency-backed spelling when deduping case variants A lock written by the earlier complete-job-refs can hold both `Swatinem/rust-cache@v2` and `swatinem/rust-cache@v2` in one workflow entry, sorted capitalised-first. The case-insensitive dedup kept whichever came first, so it could keep the spelling with no dependency record, which close-lock (exact-string match) then treats as dangling. Of two spellings, keep the one with a record. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019a95kk88TZ1iegsBK3kox3 --- scripts/complete-job-refs.sh | 27 +++++++++++++++++++------ scripts/tests/complete-job-refs-test.sh | 19 +++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/scripts/complete-job-refs.sh b/scripts/complete-job-refs.sh index 8e351ff9a..f82edb94b 100755 --- a/scripts/complete-job-refs.sh +++ b/scripts/complete-job-refs.sh @@ -22,7 +22,7 @@ WF="${1:-.github/workflows}" L="$WF/actions.lock" [ -f "$L" ] || { echo "complete-job-refs: no $L" >&2; exit 1; } TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT -WANT="$TMP/want"; NEW="$TMP/new.lock" +WANT="$TMP/want"; NEW="$TMP/new.lock"; DEPS="$TMP/deps" # Local composite actions (standards#1122). A local action (`uses: ./dir`) has # no dependency record of its own, so the actions it uses belong in the CALLING # workflow's entry. gh actions-lock writes them there; prune-stale used to drop @@ -97,24 +97,39 @@ for f in "$WF"/*.yml "$WF"/*.yaml; do set -e done | LC_ALL=C sort -u >> "$WANT" +# The dependency keys already recorded. When one workflow entry holds two +# case spellings of the same owner/repo@ref, the one with a record is kept. +awk -v q="'" '/^dependencies:/ { d = 1; next } + d && index($0, " " q) == 1 && substr($0, length($0) - 1) == q ":" { print substr($0, 6, length($0) - 7) }' "$L" > "$DEPS" + # 2. rewrite the workflows: section, merging wanted job refs into each path entry -LC_ALL=C awk -v want="$WANT" ' -BEGIN { while ((getline l < want) > 0) { split(l, w, "\t"); WANT[w[1]] = WANT[w[1]] SUBSEP w[2] } } +LC_ALL=C awk -v want="$WANT" -v deps="$DEPS" ' +BEGIN { while ((getline l < want) > 0) { split(l, w, "\t"); WANT[w[1]] = WANT[w[1]] SUBSEP w[2] } + while ((getline l < deps) > 0) DEP[l] = 1 } # ck: the dedup key of a ref. GitHub owner/repository names are # case-insensitive; refs are not (the same rule as check-lock-sync.sh). An # existing `swatinem/rust-cache@v2` entry therefore already covers # `uses: Swatinem/rust-cache@v2`; adding the second spelling left a ref that # close-lock then tried to resolve as a new repository. function ck(s, i) { i = match(s, /@[^@]*$/); return i ? tolower(substr(s, 1, i - 1)) substr(s, i) : s } +# keep: add ref a to out unless a case spelling of it is already there; of two +# spellings, the one with a dependency record wins (a lock written before the +# case fix can hold both, sorted capitalised-first). +function keep(a, k) { + if (a == "") return + k = ck(a) + if (!(k in seen)) { seen[k] = ++c; out[c] = a } + else if ((a in DEP) && !(out[seen[k]] in DEP)) out[seen[k]] = a +} # flush: write the current workflow entry, merged with its wanted refs, sorted and deduplicated. -function flush( i, n, arr, j, k, seen, out, c) { +function flush( i, n, arr, j, k) { if (cur == "") return SEEN[cur] = 1 n = split(items, arr, SUBSEP) c = 0; delete seen; delete out - for (i = 1; i <= n; i++) if (arr[i] != "" && !(ck(arr[i]) in seen)) { seen[ck(arr[i])] = 1; out[++c] = arr[i] } + for (i = 1; i <= n; i++) keep(arr[i]) if (cur in WANT) { n = split(WANT[cur], arr, SUBSEP) - for (i = 1; i <= n; i++) if (arr[i] != "" && !(ck(arr[i]) in seen)) { seen[ck(arr[i])] = 1; out[++c] = arr[i] } } + for (i = 1; i <= n; i++) keep(arr[i]) } if (c == 0) { printf " '\''%s'\'': []\n", cur; cur = ""; items = ""; return } for (i = 1; i < c; i++) for (j = i + 1; j <= c; j++) if (out[j] < out[i]) { k = out[i]; out[i] = out[j]; out[j] = k } printf " '\''%s'\'':\n", cur diff --git a/scripts/tests/complete-job-refs-test.sh b/scripts/tests/complete-job-refs-test.sh index 5f4ed6b2e..8bdcc0587 100755 --- a/scripts/tests/complete-job-refs-test.sh +++ b/scripts/tests/complete-job-refs-test.sh @@ -136,6 +136,25 @@ grep -qxF " - 'o/r@V3'" "$L5" && grep -qxF " - 'o/r@v3'" "$L5" \ && ok "ref case differs → both refs kept (refs are case-sensitive)" \ || bad "ref case differs → both refs kept (refs are case-sensitive)" +echo "=== a lock that already holds both spellings keeps the one with a record ===" +mkdir -p "$WORK/t6/.github/workflows" +cp "$WORK/t5/.github/workflows/c.yml" "$WORK/t6/.github/workflows/c.yml" +cat > "$WORK/t6/.github/workflows/actions.lock" </dev/null; st=$? +L6="$WORK/t6/.github/workflows/actions.lock" +[ "$st" = 0 ] && grep -qxF " - 'swatinem/rust-cache@v2'" "$L6" && ! grep -qF "'Swatinem/rust-cache@v2'" "$L6" \ + && ok "both spellings present → the dependency-backed one survives" \ + || bad "both spellings present → the dependency-backed one survives (st=$st)" + echo "=== structural ===" mkrepo t3; printf 'dependencies:\n' > "$WORK/t3/.github/workflows/actions.lock" run t3 >/dev/null; st=$? From 3edcf722bbe5887120b4a0a31acaeed6ea0caf1f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:41:07 +0100 Subject: [PATCH 3/5] Update scripts/complete-job-refs.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/complete-job-refs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/complete-job-refs.sh b/scripts/complete-job-refs.sh index f82edb94b..ae3ada7bd 100755 --- a/scripts/complete-job-refs.sh +++ b/scripts/complete-job-refs.sh @@ -111,7 +111,7 @@ BEGIN { while ((getline l < want) > 0) { split(l, w, "\t"); WANT[w[1]] = WANT[w[ # existing `swatinem/rust-cache@v2` entry therefore already covers # `uses: Swatinem/rust-cache@v2`; adding the second spelling left a ref that # close-lock then tried to resolve as a new repository. -function ck(s, i) { i = match(s, /@[^@]*$/); return i ? tolower(substr(s, 1, i - 1)) substr(s, i) : s } +function ck(s, i) { i = index(s, "@"); return i ? tolower(substr(s, 1, i - 1)) substr(s, i) : s } # keep: add ref a to out unless a case spelling of it is already there; of two # spellings, the one with a dependency record wins (a lock written before the # case fix can hold both, sorted capitalised-first). From b5c714590bab41950f3e5b7e670b6be04f50794f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:16:09 +0000 Subject: [PATCH 4/5] test(lock): cover a tag containing @ in complete-job-refs 3edcf722 moved the dedup key split to the first @ (owner/repo cannot contain one; a tag such as release@2026 can). This adds the comment for that rule and a fixture where o/r@Release@2026 and o/r@release@2026 must both survive. It fails against the last-@ split and passes now (20/20, gawk and mawk). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019a95kk88TZ1iegsBK3kox3 --- scripts/complete-job-refs.sh | 3 ++- scripts/tests/complete-job-refs-test.sh | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/complete-job-refs.sh b/scripts/complete-job-refs.sh index ae3ada7bd..ff39bd22f 100755 --- a/scripts/complete-job-refs.sh +++ b/scripts/complete-job-refs.sh @@ -106,7 +106,8 @@ awk -v q="'" '/^dependencies:/ { d = 1; next } LC_ALL=C awk -v want="$WANT" -v deps="$DEPS" ' BEGIN { while ((getline l < want) > 0) { split(l, w, "\t"); WANT[w[1]] = WANT[w[1]] SUBSEP w[2] } while ((getline l < deps) > 0) DEP[l] = 1 } -# ck: the dedup key of a ref. GitHub owner/repository names are +# ck: the dedup key of a ref, split at the FIRST @ (owner/repo cannot contain +# one; a tag such as `release@2026` can). GitHub owner/repository names are # case-insensitive; refs are not (the same rule as check-lock-sync.sh). An # existing `swatinem/rust-cache@v2` entry therefore already covers # `uses: Swatinem/rust-cache@v2`; adding the second spelling left a ref that diff --git a/scripts/tests/complete-job-refs-test.sh b/scripts/tests/complete-job-refs-test.sh index 8bdcc0587..b190dab1f 100755 --- a/scripts/tests/complete-job-refs-test.sh +++ b/scripts/tests/complete-job-refs-test.sh @@ -155,6 +155,21 @@ L6="$WORK/t6/.github/workflows/actions.lock" && ok "both spellings present → the dependency-backed one survives" \ || bad "both spellings present → the dependency-backed one survives (st=$st)" +echo "=== a tag containing @ keeps its case (split at the first @) ===" +mkdir -p "$WORK/t7/.github/workflows" +printf 'jobs:\n a:\n runs-on: x\n steps:\n - uses: o/r@Release@2026\n' > "$WORK/t7/.github/workflows/c.yml" +cat > "$WORK/t7/.github/workflows/actions.lock" </dev/null; st=$? +L7="$WORK/t7/.github/workflows/actions.lock" +[ "$st" = 0 ] && grep -qxF " - 'o/r@Release@2026'" "$L7" && grep -qxF " - 'o/r@release@2026'" "$L7" \ + && ok "o/r@Release@2026 and o/r@release@2026 are different refs → both kept" \ + || bad "o/r@Release@2026 and o/r@release@2026 are different refs → both kept (st=$st)" + echo "=== structural ===" mkrepo t3; printf 'dependencies:\n' > "$WORK/t3/.github/workflows/actions.lock" run t3 >/dev/null; st=$? From 39f895b717994df593c86de7d59915cec6a9bb38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:17:57 +0000 Subject: [PATCH 5/5] test(lock): use [[ ]] in the new complete-job-refs assertions SonarQube Cloud shelldre:S7688 on lines 132, 154 and 169. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019a95kk88TZ1iegsBK3kox3 --- scripts/tests/complete-job-refs-test.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/tests/complete-job-refs-test.sh b/scripts/tests/complete-job-refs-test.sh index b190dab1f..1a0899cb7 100755 --- a/scripts/tests/complete-job-refs-test.sh +++ b/scripts/tests/complete-job-refs-test.sh @@ -129,7 +129,7 @@ dependencies: EOF run t5 >/dev/null; st=$? L5="$WORK/t5/.github/workflows/actions.lock" -[ "$st" = 0 ] && grep -qxF " - 'swatinem/rust-cache@v2'" "$L5" && ! grep -qF "'Swatinem/rust-cache@v2'" "$L5" \ +[[ "$st" = 0 ]] && grep -qxF " - 'swatinem/rust-cache@v2'" "$L5" && ! grep -qF "'Swatinem/rust-cache@v2'" "$L5" \ && ok "owner case differs only → existing entry kept, no second spelling" \ || bad "owner case differs only → existing entry kept, no second spelling (st=$st)" grep -qxF " - 'o/r@V3'" "$L5" && grep -qxF " - 'o/r@v3'" "$L5" \ @@ -151,7 +151,7 @@ dependencies: EOF run t6 >/dev/null; st=$? L6="$WORK/t6/.github/workflows/actions.lock" -[ "$st" = 0 ] && grep -qxF " - 'swatinem/rust-cache@v2'" "$L6" && ! grep -qF "'Swatinem/rust-cache@v2'" "$L6" \ +[[ "$st" = 0 ]] && grep -qxF " - 'swatinem/rust-cache@v2'" "$L6" && ! grep -qF "'Swatinem/rust-cache@v2'" "$L6" \ && ok "both spellings present → the dependency-backed one survives" \ || bad "both spellings present → the dependency-backed one survives (st=$st)" @@ -166,7 +166,7 @@ dependencies: EOF run t7 >/dev/null; st=$? L7="$WORK/t7/.github/workflows/actions.lock" -[ "$st" = 0 ] && grep -qxF " - 'o/r@Release@2026'" "$L7" && grep -qxF " - 'o/r@release@2026'" "$L7" \ +[[ "$st" = 0 ]] && grep -qxF " - 'o/r@Release@2026'" "$L7" && grep -qxF " - 'o/r@release@2026'" "$L7" \ && ok "o/r@Release@2026 and o/r@release@2026 are different refs → both kept" \ || bad "o/r@Release@2026 and o/r@release@2026 are different refs → both kept (st=$st)"