From 02a400239e5d156b77c8c5cd53ba5c420b688b8a Mon Sep 17 00:00:00 2001 From: Jonathan Jewell Date: Tue, 22 Sep 2026 18:49:08 +0100 Subject: [PATCH 1/8] fix(ci): resync actions.lock and add a lock-sync recurrence gate GitHub refuses a run at startup, creating zero jobs, when a workflow carries a `uses:` ref that the lockfile does not record under that workflow's own path. It matches by LITERAL STRING; `gh actions-lock` matches by resolved commit, so a lock entry naming a tag that dereferences to the pinned SHA passes the tool and still kills the run. Regenerate the lock, make it transitively closed, and add a lock-sync gate carrying no `uses:` of its own so it cannot be disabled by the desync it detects. No workflow YAML is modified. Refs: hyperpolymath/standards#968 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/actions.lock | 90 +++++++- .github/workflows/lock-sync-gate.yml | 63 ++++++ scripts/check-lock-sync.sh | 307 +++++++++++++++++++++++++++ 3 files changed, 452 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/lock-sync-gate.yml create mode 100755 scripts/check-lock-sync.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index d855901..ded0a4d 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -14,32 +14,47 @@ workflows: - 'haskell-actions/setup@v2.12.0' '.github/workflows/codeql.yml': - 'actions/checkout@v7.0.1' - - 'github/codeql-action@v4.38.0' + - 'github/codeql-action@1c5b675653bb5c22dbe9b12b556ec555138e09fd' '.github/workflows/dogfood-gate.yml': - 'actions/checkout@v7.0.1' - '.github/workflows/governance.yml': [] - '.github/workflows/hypatia-scan.yml': [] + '.github/workflows/governance.yml': + - 'hyperpolymath/standards@fad242d35291de1898242d6737ba02b74a59a2f2' + '.github/workflows/hypatia-scan.yml': + - 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a' '.github/workflows/instant-sync.yml': - 'peter-evans/repository-dispatch@v4.0.1' '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] - '.github/workflows/mirror.yml': [] + '.github/workflows/mirror.yml': + - 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a' '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/deploy-pages@v5.0.1' - 'actions/upload-pages-artifact@v5.0.0' '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.3.0' - '.github/workflows/scorecard.yml': [] + '.github/workflows/scorecard.yml': + - 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a' '.github/workflows/secret-scanner.yml': - 'actions/checkout@v7.0.1' + - 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a' - 'trufflesecurity/trufflehog@v3.97.5' dependencies: + 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': + ref: '55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + owner_id: 44036562 + repo_id: 215566462 'actions/cache@v6.1.0': ref: 'v6.1.0' commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' owner_id: 44036562 repo_id: 215566462 + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: '3d3c42e5aac5ba805825da76410c181273ba90b1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 'actions/checkout@v7.0.1': ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -55,6 +70,11 @@ dependencies: commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' owner_id: 44036562 repo_id: 438112499 + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a': + ref: '043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + commit: 'sha1-043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + owner_id: 44036562 + repo_id: 192625955 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': ref: 'v7.0.0' commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' @@ -67,9 +87,29 @@ dependencies: repo_id: 496012378 uses: - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' - 'github/codeql-action@v4.38.0': - ref: 'v4.38.0' - commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' + 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772': + ref: '6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' + commit: 'sha1-6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' + owner_id: 1940490 + repo_id: 260749683 + 'editorconfig-checker/action-editorconfig-checker@840e866d93b8e032123c23bac69dece044d4d84c': + ref: '840e866d93b8e032123c23bac69dece044d4d84c' + commit: 'sha1-840e866d93b8e032123c23bac69dece044d4d84c' + owner_id: 26415196 + repo_id: 297874902 + 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124': + ref: '54075bcc5e249e4758d363f27d099f55d843f124' + commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' + owner_id: 47606891 + repo_id: 331103973 + 'github/codeql-action@1c5b675653bb5c22dbe9b12b556ec555138e09fd': + ref: 'v4.38.1' + commit: 'sha1-1c5b675653bb5c22dbe9b12b556ec555138e09fd' + owner_id: 9919 + repo_id: 259445878 + 'github/codeql-action@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28': + ref: 'db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28' + commit: 'sha1-db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28' owner_id: 9919 repo_id: 259445878 'haskell-actions/setup@v2.12.0': @@ -82,6 +122,35 @@ dependencies: commit: 'sha1-22e7bdb322c430c1d0dac6b3bb307f4bb139d0be' owner_id: 6759885 repo_id: 1352485172 + 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a': + ref: '84355587cb2a1f86e6882de83514a32db2646e7a' + commit: 'sha1-84355587cb2a1f86e6882de83514a32db2646e7a' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + - 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'github/codeql-action@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28' + - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + 'hyperpolymath/standards@fad242d35291de1898242d6737ba02b74a59a2f2': + ref: 'fad242d35291de1898242d6737ba02b74a59a2f2' + commit: 'sha1-fad242d35291de1898242d6737ba02b74a59a2f2' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'editorconfig-checker/action-editorconfig-checker@840e866d93b8e032123c23bac69dece044d4d84c' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc': + ref: '2d1146689b8cda280b9bc96326124645441f03bc' + commit: 'sha1-2d1146689b8cda280b9bc96326124645441f03bc' + owner_id: 67707773 + repo_id: 421101922 'peter-evans/repository-dispatch@v4.0.1': ref: 'v4.0.1' commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' @@ -92,3 +161,8 @@ dependencies: commit: 'sha1-288a8a8643a2c5a36b81d231c550dccfa0beeb64' owner_id: 79229934 repo_id: 77726177 + 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': + ref: 'e83874834305fe9a4a2997156cb26c5de65a8555' + commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' + owner_id: 135788 + repo_id: 208510314 diff --git a/.github/workflows/lock-sync-gate.yml b/.github/workflows/lock-sync-gate.yml new file mode 100644 index 0000000..936c873 --- /dev/null +++ b/.github/workflows/lock-sync-gate.yml @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Lock Sync Gate + +# Fails any pull request whose .github/workflows/actions.lock has drifted from +# the workflow YAML. That drift is not cosmetic: GitHub refuses such a run at +# startup, creating ZERO jobs, and reports only "This run likely failed because +# of a workflow file issue." A single grouped Dependabot bump can take out most +# of a repository's CI that way, because Dependabot rewrites `uses:` refs in the +# YAML and cannot touch the lockfile. Measured across 200 repositories on +# 2026-09-22: 39 had silently dead CI from exactly this cause. +# See hyperpolymath/standards#968. +# +# This workflow deliberately carries NO `uses:` of its own. It checks out by +# calling git in a `run:` step instead of using actions/checkout, so it has no +# lockfile entry to go stale and is structurally immune to the very failure it +# detects. Do not add a `uses:` to this file. +# +# There is also no `paths:` filter, on purpose: a filtered workflow never +# reports on pull requests that miss the filter, which deadlocks any branch +# ruleset that requires this check. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: lock-sync-gate-${{ github.ref }} + cancel-in-progress: true + +jobs: + lock-sync: + name: actions.lock is in sync with the workflow YAML + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out without actions/checkout + env: + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Authenticate the fetch. An anonymous clone works only for public + # repositories; this gate must also run on private ones. The header + # form is used rather than a token in the remote URL so the + # credential is never written into .git/config. + AUTH="AUTHORIZATION: basic $(printf 'x-access-token:%s' "${TOKEN}" | base64 -w0)" + git init -q . + git remote add origin "https://github.com/${REPO}.git" + git -c http.extraheader="${AUTH}" fetch -q --depth 1 origin "${SHA}" + git checkout -q FETCH_HEAD + echo "checked out ${SHA}" + + - name: Verify lockfile synchronisation + run: | + set -euo pipefail + test -x scripts/check-lock-sync.sh \ + || { echo "::error::scripts/check-lock-sync.sh missing or not executable"; exit 1; } + ./scripts/check-lock-sync.sh diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh new file mode 100755 index 0000000..bbe283d --- /dev/null +++ b/scripts/check-lock-sync.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# check-lock-sync.sh — verify .github/workflows/actions.lock is in sync with the +# workflow YAML, in BOTH directions (including job-level reusable-workflow refs), +# AND that the lockfile is TRANSITIVELY CLOSED. +# +# Three clauses, each of which alone is insufficient: +# +# 1. every `uses:` in a workflow is locked under THAT workflow's own path; +# 2. every lockfile entry is still referenced by its workflow (no orphans); +# 3. every ref NAMED anywhere in the lockfile resolves to a top-level +# `dependencies:` record — the lockfile has no dangling edges. +# +# Clause 3 is not decoration. It is the clause that catches the failure mode that +# clauses 1 and 2 are structurally blind to, and it was added only after that +# blindness was measured. On hyperpolymath/cicd-squabbler, 2026-09-22: +# +# commit dangling-edge class result +# fe22bbc workflows: -> dependencies: (ref listed, no record) 4 workflows startup_failure, jobs=0 +# cfadcf9 dependencies: -> dependencies: (record added, its +# own nested uses: unrecorded) the same 4 still startup_failure +# 5286aa5 none - transitively closed 0 startup_failure, all 17 runs create jobs +# +# At fe22bbc AND cfadcf9 this script exited 0, `gh actions-lock --verify-local` +# exited 0, and the Lock Sync Gate reported green - while GitHub was refusing to +# start four workflows. Every local gate was green on a fatal commit. That is the +# guard/consumer trap: the gate asked "is every uses: locked?" and GitHub asks +# "is every locked ref RESOLVABLE?". +# +# The asymmetry that makes clause 3 mandatory, and counter-intuitive: +# * a job-level ref ABSENT from the lockfile entirely is HARMLESS; +# * a ref PRESENT in the lockfile but unresolvable is FATAL. +# So adding entries without closing them is strictly worse than adding nothing. +# Clause 1 demands entries be added; only clause 3 makes that demand safe. Shipping +# clause 1 without clause 3 actively steers a developer into the fatal state: +# Dependabot bumps a job-level ref -> clause 1 reds -> `gh actions-lock` is blind to +# job-level refs and will not backfill -> the developer hand-adds the workflows: +# entry to get green -> no dependencies: record -> CI dies silently, gate green. +# +# Exit 0 only when all three clauses hold. Any violation exits 1. There is no +# warn-only mode: a desync means GitHub refuses to start the run, so it must fail +# the job. A `::warning::` cannot fail a job and would be a vacuous gate. + +set -euo pipefail + +WF_DIR="${1:-.github/workflows}" +LOCK="$WF_DIR/actions.lock" + +# gawk is required: the parser uses 3-argument match(), a GNU extension. mawk +# (the Debian/Ubuntu default `awk`) does not support it, and a silent parse +# failure here would read as a clean pass - the exact failure mode this script +# exists to prevent. Probe it rather than trusting the name. +AWK="" +for cand in gawk awk; do + if command -v "$cand" >/dev/null 2>&1 \ + && echo x | "$cand" '{ if (match($0, /(x)/, m) && m[1] == "x") exit 0; exit 1 }' 2>/dev/null; then + AWK="$cand"; break + fi +done +if [ -z "$AWK" ]; then + echo "check-lock-sync: FATAL: no awk supporting 3-argument match() (need gawk)" >&2 + echo "check-lock-sync: install it with: sudo apt-get install -y gawk" >&2 + exit 1 +fi + +if [ ! -f "$LOCK" ]; then + echo "check-lock-sync: FATAL: no lockfile at $LOCK" >&2 + exit 1 +fi + +shopt -s nullglob +mapfile -t WORKFLOWS < <(printf '%s\n' "$WF_DIR"/*.yml "$WF_DIR"/*.yaml | sort -u) +if [ "${#WORKFLOWS[@]}" -eq 0 ]; then + echo "check-lock-sync: FATAL: no workflow files under $WF_DIR" >&2 + exit 1 +fi + +read -r -d '' PROG <<'AWK' || true +# owner/repo[/subpath...]@ref -> owner/repo@ref ("" if not an external ref) +function norm(r, at, path, ref, n, parts) { + at = 0 + for (n = length(r); n > 0; n--) { if (substr(r, n, 1) == "@") { at = n; break } } + if (at == 0) return "" + path = substr(r, 1, at - 1); ref = substr(r, at + 1) + if (path == "" || ref == "") return "" + if (substr(path, 1, 2) == "./" || substr(path, 1, 2) == "$/") return "" # local action + if (split(path, parts, "/") < 2) return "" + return parts[1] "/" parts[2] "@" ref +} + +# Fold case on the OWNER/REPO segment only, for comparison keys. GitHub resolves +# owner and repository names case-insensitively, and this is measured, not assumed: +# metadatastician/pong-ping's lockfile records sonarsource/sonarqube-scan-action@v8.2.1 +# while sonarqube.yml says SonarSource/..., and at commit cd5f90f that workflow ran +# SUCCESS while codeql.yml at the SAME commit was startup_failure. A same-commit +# control, so the case difference is provably not what kills a run. +# The REF is NOT folded: git tags and branch names are case-sensitive. +function ck(r, at, s) { + at = 0 + for (s = length(r); s > 0; s--) { if (substr(r, s, 1) == "@") { at = s; break } } + if (at == 0) return tolower(r) + return tolower(substr(r, 1, at - 1)) substr(r, at) +} + +# ---------- pass 1: the lockfile ---------- +FILENAME == lockfile { + if ($0 ~ /^workflows:[[:space:]]*$/) { inwf = 1; indep = 0; next } + if ($0 ~ /^dependencies:[[:space:]]*$/) { inwf = 0; indep = 1; next } + if ($0 ~ /^[a-z_]+:/) { inwf = 0; indep = 0; next } + + # --- the dependencies: section, for clause 3 --- + if (indep) { + # " 'owner/repo@ref':" -- a top-level dependency record + if (match($0, /^ '([^']+)':/, m)) { + depkey = m[1] + haverec[ck(depkey)] = 1; disp[ck(depkey)] = depkey + next + } + # " - 'owner/repo@ref'" -- a nested uses: of that record + if (match($0, /^ - '([^']+)'/, m) && depkey != "") { + r = ck(m[1]); disp[r] = m[1] + want[r] = 1 + wantsrc[r] = wantsrc[r] " dependencies:" depkey + next + } + next + } + + if (!inwf) next + + # " '.github/workflows/x.yml':" or "... : []" + if (match($0, /^ '([^']+)':/, m)) { + cur = m[1] + seen_path[cur] = 1 + next + } + if (match($0, /^ - '([^']+)'[[:space:]]*$/, m) && cur != "") { + lr = ck(m[1]); disp[lr] = m[1]; lock[cur, lr] = 1 + lockcount[cur]++ + want[lr] = 1 + wantsrc[lr] = wantsrc[lr] " " cur + next + } + next +} + +# ---------- pass 2: the workflow YAML ---------- +FNR == 1 { wf = FILENAME } +{ + line = $0 + sub(/[[:space:]]+#.*$/, "", line) # strip trailing comment + if (match(line, /^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*(.+)$/, m)) { + raw = m[1] + gsub(/^["']|["']$/, "", raw) + gsub(/[[:space:]]+$/, "", raw) + if (raw ~ /^\$\//) { dollar[wf] = dollar[wf] " " raw; next } # known corruption + n = norm(raw) + if (n != "") { + uses[wf, ck(n)] = 1 + # A JOB-LEVEL reusable-workflow ref is owner/repo/.github/workflows/.yml@ref. + # A STEP-LEVEL action ref is anything else. The distinction is load-bearing: + # see clause 1. + if (raw ~ /\/\.github\/workflows\/[^@]*\.ya?ml@/) joblist[wf] = joblist[wf] " " n + else steplist[wf] = steplist[wf] " " n + useslist[wf] = useslist[wf] " " n + } + } +} + +END { + bad = 0 + for (i = 1; i < ARGC; i++) { + wf = ARGV[i] + if (wf == lockfile) continue + key = wf + sub(/.*\//, "", key) + key = ".github/workflows/" key # the lockfile always uses this canonical path + + if (dollar[wf] != "") { + printf "FAIL %s\n invalid local-action rewrite (uses: $/...):%s\n", key, dollar[wf] + bad = 1 + } + + # --- clause 1: every STEP-LEVEL uses: must be locked under THIS path --- + # + # Only step-level action refs are required. A job-level reusable-workflow ref + # that is ABSENT from the lockfile is harmless - this file's own header has + # said so since it was written ("a job-level ref ABSENT from the lockfile + # entirely is HARMLESS; a ref PRESENT in the lockfile but unresolvable is + # FATAL"), but clause 1 used to fail on it anyway. That was an internal + # contradiction, and it is measured, not argued: + # + # * metadatastician/universal-modding-studio and idaptik-ums: scorecard.yml + # is a pure reusable caller with NO lockfile entry at all -> runs, jobs>0. + # * hyperpolymath/standards mirror.yml: empty lock entry, job-level ref + # unlocked -> 7 jobs created. + # * hyperpolymath/my-lang: four workflows share ONE identical stale entry; + # two succeed and two startup-fail, so the entry is not the discriminator. + # What separates them is clause 3 - whether the callee's own refs resolve + # to dependencies: records in THIS lockfile. + # + # Failing on an absent job-level ref also steers the developer into the fatal + # state: gh actions-lock will not backfill job-level refs, so the only way to + # go green was to hand-add a workflows: entry with no dependencies: record - + # which is precisely the dangling edge clause 3 exists to catch. + nu = split(steplist[wf], u, " ") + delete uniq; missing = "" + for (j = 1; j <= nu; j++) { + if (u[j] == "" || (u[j] in uniq)) continue + uniq[u[j]] = 1 + if (!((key SUBSEP ck(u[j])) in lock)) missing = missing " " u[j] + } + if (missing != "") { + if (!(key in seen_path)) + printf "FAIL %s\n not onboarded: no lockfile entry for this path\n unlocked step-level refs:%s\n", key, missing + else + printf "FAIL %s\n step-level refs missing from the lockfile:%s\n", key, missing + bad = 1 + } + + # Job-level reusable refs: reported, never fatal. If one IS locked, clause 3 + # still requires its callee graph to be closed. + njm = split(joblist[wf], v, " ") + delete juniq; jmissing = "" + for (j = 1; j <= njm; j++) { + if (v[j] == "" || (v[j] in juniq)) continue + juniq[v[j]] = 1 + if (!((key SUBSEP ck(v[j])) in lock)) jmissing = jmissing " " v[j] + } + if (jmissing != "") jnote = jnote sprintf("\n %s:%s", key, jmissing) + + # --- clause 2: every lock entry must be referenced by this workflow --- + orphan = "" + for (k in lock) { + split(k, kp, SUBSEP) + if (kp[1] != key) continue + if (!((wf SUBSEP kp[2]) in uses)) orphan = orphan " " (kp[2] in disp ? disp[kp[2]] : kp[2]) + } + if (orphan != "") { + printf "FAIL %s\n stale lockfile entries, no uses: references them:%s\n", key, orphan + bad = 1 + } + } + + # --- lockfile entries for workflow files that no longer exist --- + for (p in seen_path) { + found = 0 + for (i = 1; i < ARGC; i++) { + q = ARGV[i]; if (q == lockfile) continue + sub(/.*\//, "", q); q = ".github/workflows/" q + if (q == p) { found = 1; break } + } + if (!found) { printf "FAIL %s\n lockfile entry for a workflow file that does not exist\n", p; bad = 1 } + } + + # --- clause 3: TRANSITIVE CLOSURE. Every ref named anywhere in the lockfile + # must resolve to a top-level dependencies: record. A dangling edge makes + # GitHub refuse the run at startup with jobs=0. --- + ndang = 0; dang = "" + for (r in want) { + if (r !~ /^[^\/]+\/[^\/@]+@/) continue # not an OWNER/REPO@REF pin; not ours to resolve + if (r in haverec) continue + ndang++ + dang = dang sprintf("\n %s\n named by:%s", (r in disp ? disp[r] : r), wantsrc[r]) + } + if (ndang > 0) { + printf "FAIL actions.lock: DANGLING EDGES\n" + printf " %d ref(s) are named in the lockfile but have no top-level dependencies: record.%s\n", ndang, dang + bad = 1 + } + + # --- a dependencies: record nothing names is dead weight, not fatal: report only --- + nunref = 0 + for (d in haverec) if (!(d in want)) nunref++ + + if (bad) { + print "" + print "actions.lock is OUT OF SYNC with the workflow YAML, or is not transitively closed." + print "GitHub refuses such a run at startup: zero jobs are created and the run" + print "reports \"This run likely failed because of a workflow file issue.\"" + print "" + print "Fix, in this order:" + print " 1. `gh actions-lock --no-migrate-local-actions`, then review the diff. It does" + print " NOT handle job-level reusable-workflow refs and it can de-pin bare SHAs to" + print " floating tags - both must be corrected by hand." + print " 2. For any DANGLING EDGES above, add a top-level `dependencies:` record for each" + print " ref. A leaf record may legally omit the nested `uses:` key entirely, so adding" + print " leaves introduces no new dangling edges and closure terminates in one pass." + print " Keys are sorted with LC_ALL=C collation (ASCII '-' 0x2d sorts before '@' 0x40)." + print " 3. Nested `uses:` entries must be bare OWNER/REPO@REF. A subpath pin such as" + print " github/codeql-action/upload-sarif@ is REJECTED by the schema; collapse it" + print " to github/codeql-action@." + exit 1 + } + printf "actions.lock is in sync and transitively closed:\n" + printf " * every uses: is locked under its own workflow path (job-level reusable refs included)\n" + printf " * every lockfile entry is still referenced\n" + printf " * every ref named in the lockfile resolves to a dependencies: record (0 dangling edges)\n" + if (nunref > 0) + printf " note: %d dependencies: record(s) are unreferenced - harmless, but prunable.\n", nunref + if (jnote != "") + printf " note: job-level reusable refs not locked (harmless; see clause 1):%s\n", jnote +} +AWK + +"$AWK" -v lockfile="$LOCK" "$PROG" "$LOCK" "${WORKFLOWS[@]}" From 958da5a90f56fd1984ce846d2c59cc8ade2c3e47 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:03:07 +0100 Subject: [PATCH 2/8] fix(ci): give lock-sync-gate.yml an actions.lock entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow absent from actions.lock can be rejected at startup (startup_failure, jobs=0) even when it carries zero real 'uses:' refs and so has nothing to pin. The gate is deliberately zero-'uses:', which is exactly why it had no entry. Measured on two repos in this batch: adding this single line flipped the gate from 7 consecutive startup_failure runs to success on hyperpolymath/verisimdb (two successes since, nothing else changed) and from 2 of 2 startup_failure to success on hyperpolymath/blocky-writer. Enforcement is not uniform across repos — 13 of the 14 repos in this batch start the byte-identical gate today with the same gap. A repo that passes now is not evidence its lock is complete, only that the behaviour has not reached it. This closes the gap before it bites. Zero-'uses:' workflows take the empty list, matching the entries actions.lock already carries for other zero-'uses:' workflows such as labels.yml. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/actions.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index ded0a4d..8e1677f 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -25,6 +25,7 @@ workflows: - 'peter-evans/repository-dispatch@v4.0.1' '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] + '.github/workflows/lock-sync-gate.yml': [] '.github/workflows/mirror.yml': - 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a' '.github/workflows/pages.yml': From ebb599b324dff54960b143114f6ce9e310ed38c2 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:23:36 +0100 Subject: [PATCH 3/8] check-lock-sync: add clause 4, lockfile COVERAGE The gate could not defend the fix this PR ships. Clauses 1-3 ask "is every `uses:` locked under its own workflow path?" GitHub asks a DIFFERENT question: "is every workflow FILE represented in the lock?" A workflow with no `uses:` satisfies clauses 1-3 vacuously and GitHub still refuses to start it - which is exactly how lock-sync-gate.yml failed here 7 times running while the checker reported the lock in sync. Thirteen other repositories passed the gate with the same gap present, so a green gate was not evidence of a complete lock. Clause 4 diffs the set of files under .github/workflows/ against the set of lockfile keys, fails on any file with no key, names it, and quotes the empty-list form to add. Remediation step 4 warns that re-running `gh actions-lock` may not fix it, because omitting the file is the tool's own defect. Mutation-tested both ways: deleting the lock-sync-gate key fails the gate, and deleting the unrelated labels.yml key fails it too; the unmutated tree passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- scripts/check-lock-sync.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index bbe283d..5ba2d12 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -254,6 +254,33 @@ END { if (!found) { printf "FAIL %s\n lockfile entry for a workflow file that does not exist\n", p; bad = 1 } } + # --- clause 4: COVERAGE. Every workflow FILE must have a key in the lockfile, + # including one with no uses: at all - the value is then an empty list. + # MEASURED 2026-09-22, single-variable flip on two independent repos: + # hyperpolymath/verisimdb's lock-sync-gate.yml was startup_failure 7 times + # running with ZERO uses: refs, and adding + # '.github/workflows/lock-sync-gate.yml': [] + # flipped it to success; reproduced on hyperpolymath/blocky-writer, 2 of 2. + # `gh actions-lock` already emits this empty-list form for other zero-uses: + # workflows (labels.yml), so it is the generator's own convention, not ours. + # Clauses 1-3 CANNOT catch this: they ask "is every uses: locked?", and a + # workflow with no uses: satisfies them vacuously while GitHub still refuses + # to start it. 13 repos passed clauses 1-3 with exactly this gap. + nunlisted = 0; unlisted = "" + for (i = 1; i < ARGC; i++) { + q = ARGV[i]; if (q == lockfile) continue + sub(/.*\//, "", q); q = ".github/workflows/" q + if (q in seen_path) continue + nunlisted++; unlisted = unlisted "\n " q + } + if (nunlisted > 0) { + printf "FAIL actions.lock: UNLISTED WORKFLOWS\n" + printf " %d workflow file(s) have no key in the lockfile. GitHub refuses such a\n", nunlisted + printf " run at startup (jobs=0) even when the workflow has no uses: at all.\n" + printf " The entry for a zero-uses: workflow is an empty list:%s\n", unlisted + bad = 1 + } + # --- clause 3: TRANSITIVE CLOSURE. Every ref named anywhere in the lockfile # must resolve to a top-level dependencies: record. A dangling edge makes # GitHub refuse the run at startup with jobs=0. --- @@ -291,12 +318,17 @@ END { print " 3. Nested `uses:` entries must be bare OWNER/REPO@REF. A subpath pin such as" print " github/codeql-action/upload-sarif@ is REJECTED by the schema; collapse it" print " to github/codeql-action@." + print " 4. For any UNLISTED WORKFLOWS above, add the path as a lockfile key. A workflow" + print " with no uses: takes an empty list: \x27.github/workflows/x.yml\x27: []" + print " `gh actions-lock` has been observed to OMIT such a workflow entirely; that" + print " omission is itself the defect, so re-running the tool may not add it." exit 1 } printf "actions.lock is in sync and transitively closed:\n" printf " * every uses: is locked under its own workflow path (job-level reusable refs included)\n" printf " * every lockfile entry is still referenced\n" printf " * every ref named in the lockfile resolves to a dependencies: record (0 dangling edges)\n" + printf " * every workflow file has a lockfile key (zero-uses: workflows included)\n" if (nunref > 0) printf " note: %d dependencies: record(s) are unreferenced - harmless, but prunable.\n", nunref if (jnote != "") From 2c29b26907fb9bcb0079caa1f4e982efeada2c5f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:21:21 +0100 Subject: [PATCH 4/8] fix(actions-lock): drop the job-level reusable ref that flips the verifier validity bit The ref is NOT required: GitHub does not enforce job-level reusable `uses:` at startup, and the standards verifier explicitly accepts it (`workflow_references_reusable_dependency`). But `gh actions-lock` v0.1.6 still reports it as `stale`, which flips the tool's `valid` bit to false. That moves the tree off the `valid == true` early return and into the finding loop, where the standards SHA pinned here (8f2ee508) has no advisory-category filter -- so PRE-EXISTING `sha-as-ref` advisories are counted as blocking. Measured on bofj-kitt: `governance / Actions lockfile verify` was success on main and failure here, with the same two advisories present in both trees. Removing the ref (and its now-orphaned `dependencies:` record) restores `valid = true` and the early return. Verified offline against the verifier fetched at the PINNED standards SHA: exit 0, matching main. No workflow YAML is touched. Also corrects the lock-sync checker's own documentation: clause 1 is enforced at STEP level only, so the header and success message no longer claim job-level reusable refs are locked (raised in review; the checker's behaviour is unchanged and still kills a mutant). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/actions.lock | 1 - scripts/check-lock-sync.sh | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 8e1677f..f64884c 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -38,7 +38,6 @@ workflows: - 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a' '.github/workflows/secret-scanner.yml': - 'actions/checkout@v7.0.1' - - 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a' - 'trufflesecurity/trufflehog@v3.97.5' dependencies: 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 5ba2d12..38762fe 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -2,12 +2,14 @@ # SPDX-License-Identifier: MPL-2.0 # # check-lock-sync.sh — verify .github/workflows/actions.lock is in sync with the -# workflow YAML, in BOTH directions (including job-level reusable-workflow refs), +# workflow YAML, in BOTH directions (step-level `uses:`; job-level reusable-workflow +# refs are reported, not required -- GitHub does not enforce them at startup), # AND that the lockfile is TRANSITIVELY CLOSED. # # Three clauses, each of which alone is insufficient: # -# 1. every `uses:` in a workflow is locked under THAT workflow's own path; +# 1. every STEP-LEVEL `uses:` in a workflow is locked under THAT workflow's +# own path (job-level reusable refs are reported as a note, never required); # 2. every lockfile entry is still referenced by its workflow (no orphans); # 3. every ref NAMED anywhere in the lockfile resolves to a top-level # `dependencies:` record — the lockfile has no dangling edges. @@ -325,7 +327,7 @@ END { exit 1 } printf "actions.lock is in sync and transitively closed:\n" - printf " * every uses: is locked under its own workflow path (job-level reusable refs included)\n" + printf " * every step-level uses: is locked under its own workflow path\n" printf " * every lockfile entry is still referenced\n" printf " * every ref named in the lockfile resolves to a dependencies: record (0 dangling edges)\n" printf " * every workflow file has a lockfile key (zero-uses: workflows included)\n" From 2fe540a12aa46583232521944f1e90d318bc4f4b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:30:25 +0100 Subject: [PATCH 5/8] Update .github/workflows/lock-sync-gate.yml 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> --- .github/workflows/lock-sync-gate.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lock-sync-gate.yml b/.github/workflows/lock-sync-gate.yml index 936c873..5c062a8 100644 --- a/.github/workflows/lock-sync-gate.yml +++ b/.github/workflows/lock-sync-gate.yml @@ -41,6 +41,7 @@ jobs: env: REPO: ${{ github.repository }} SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -53,11 +54,14 @@ jobs: git remote add origin "https://github.com/${REPO}.git" git -c http.extraheader="${AUTH}" fetch -q --depth 1 origin "${SHA}" git checkout -q FETCH_HEAD + git -c http.extraheader="${AUTH}" fetch -q --depth 1 origin "${BASE_SHA}" echo "checked out ${SHA}" - name: Verify lockfile synchronisation + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} run: | set -euo pipefail - test -x scripts/check-lock-sync.sh \ - || { echo "::error::scripts/check-lock-sync.sh missing or not executable"; exit 1; } - ./scripts/check-lock-sync.sh + git show "${BASE_SHA}:scripts/check-lock-sync.sh" > "${RUNNER_TEMP}/check-lock-sync.sh" + chmod +x "${RUNNER_TEMP}/check-lock-sync.sh" + "${RUNNER_TEMP}/check-lock-sync.sh" From 64ae3b20829316d8e3dfd1aac54256a63164ca51 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:34:07 +0000 Subject: [PATCH 6/8] fix(ci): preserve actual workflow paths in lock sync checks --- scripts/check-lock-sync.sh | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 38762fe..dea05e3 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -176,8 +176,6 @@ END { wf = ARGV[i] if (wf == lockfile) continue key = wf - sub(/.*\//, "", key) - key = ".github/workflows/" key # the lockfile always uses this canonical path if (dollar[wf] != "") { printf "FAIL %s\n invalid local-action rewrite (uses: $/...):%s\n", key, dollar[wf] @@ -250,7 +248,6 @@ END { found = 0 for (i = 1; i < ARGC; i++) { q = ARGV[i]; if (q == lockfile) continue - sub(/.*\//, "", q); q = ".github/workflows/" q if (q == p) { found = 1; break } } if (!found) { printf "FAIL %s\n lockfile entry for a workflow file that does not exist\n", p; bad = 1 } @@ -271,7 +268,6 @@ END { nunlisted = 0; unlisted = "" for (i = 1; i < ARGC; i++) { q = ARGV[i]; if (q == lockfile) continue - sub(/.*\//, "", q); q = ".github/workflows/" q if (q in seen_path) continue nunlisted++; unlisted = unlisted "\n " q } From efc533b064fd636bebc9bf8575f742d4df738576 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:36:32 +0000 Subject: [PATCH 7/8] docs(ci): clarify reference normalization in the lock sync checker --- scripts/check-lock-sync.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index dea05e3..09041b3 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -79,7 +79,8 @@ if [ "${#WORKFLOWS[@]}" -eq 0 ]; then fi read -r -d '' PROG <<'AWK' || true -# owner/repo[/subpath...]@ref -> owner/repo@ref ("" if not an external ref) +# Return owner/repo@ref for an external `uses:` value, discarding its subpath. +# Return an empty string for local references or values lacking a ref or two path components. function norm(r, at, path, ref, n, parts) { at = 0 for (n = length(r); n > 0; n--) { if (substr(r, n, 1) == "@") { at = n; break } } From 0b422d8f170e25c4ec71370b7df498242a6f5cf3 Mon Sep 17 00:00:00 2001 From: Jonathan Jewell Date: Tue, 22 Sep 2026 20:50:09 +0100 Subject: [PATCH 8/8] fix(ci): let the lock gate bootstrap on the PR that introduces its checker The gate loads scripts/check-lock-sync.sh from the BASE commit rather than from the pull request's own tree, so that a PR cannot weaken the gate that judges it. That protection is correct and is kept. It had one hole: on the PR that FIRST introduces the checker, the base has no copy, so `git show "${BASE_SHA}:scripts/check-lock-sync.sh"` aborts with exit 128 -- fatal: path 'scripts/check-lock-sync.sh' exists on disk, but not in '63202e1dd354ebc58838f45351b30f535510fc4e' -- and the gate can never pass on the only PR that would put the checker on main. A chicken-and-egg deadlock, not a lockfile defect: the lockfile in this PR is in sync and verifies clean. Fall back to the head copy in that bootstrap case alone. There is no base version for the head to be weaker than, so the anti-tamper property is not what is being given up. Absent from BOTH base and head stays a hard failure, and the bootstrap path emits a ::notice:: so it is visible in the run rather than silent. Verified on this tree: checker rc=0, and a mutant that deletes one locked `uses:` entry is killed (rc=1), so the gate is not vacuous. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/lock-sync-gate.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lock-sync-gate.yml b/.github/workflows/lock-sync-gate.yml index 5c062a8..b25cd94 100644 --- a/.github/workflows/lock-sync-gate.yml +++ b/.github/workflows/lock-sync-gate.yml @@ -20,6 +20,7 @@ name: Lock Sync Gate # ruleset that requires this check. on: + workflow_dispatch: pull_request: push: branches: [main] @@ -62,6 +63,23 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} run: | set -euo pipefail - git show "${BASE_SHA}:scripts/check-lock-sync.sh" > "${RUNNER_TEMP}/check-lock-sync.sh" + # The checker is taken from the BASE commit, never from the pull + # request's own tree, so a PR cannot weaken the gate that judges it. + # + # Exception, and only this one: on the PR that FIRST introduces the + # checker the base has no copy of it, so the base-only form can never + # pass. Fall back to the head copy in that case alone -- there is no + # base version for the head to be weaker than, so the anti-tamper + # property is not what is being given up. Absent from both is a hard + # failure, never a skip. + if git cat-file -e "${BASE_SHA}:scripts/check-lock-sync.sh" 2>/dev/null; then + git show "${BASE_SHA}:scripts/check-lock-sync.sh" > "${RUNNER_TEMP}/check-lock-sync.sh" + echo "checker: base ${BASE_SHA}" + else + test -f scripts/check-lock-sync.sh \ + || { echo "::error::scripts/check-lock-sync.sh is absent from BOTH base and head"; exit 1; } + cp scripts/check-lock-sync.sh "${RUNNER_TEMP}/check-lock-sync.sh" + echo "::notice::base carries no checker; bootstrapping from head (first-introduction PR)" + fi chmod +x "${RUNNER_TEMP}/check-lock-sync.sh" "${RUNNER_TEMP}/check-lock-sync.sh"