From 33f17a0acc0b01577171100f6f413fd363678aaf Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:21:12 +0100 Subject: [PATCH] actions.lock: list the lock-sync gate, and make the checker require coverage Two defects, one cause: a workflow file that has NO KEY in actions.lock is refused by GitHub at startup (jobs=0, startup_failure) even when it contains zero `uses:` refs and therefore has nothing to pin. 1. Add the missing key for the gate this repo already ships: '.github/workflows/lock-sync-gate.yml': [] MEASURED, single-variable flip on two independent repositories: * hyperpolymath/verisimdb - 7 consecutive startup_failure -> success * hyperpolymath/blocky-writer - 2 of 2 startup_failure -> success Nothing else changed in either case. An empty commit with the lock untouched still failed; the commit adding this line passed. `gh actions-lock` already emits this empty-list form for other zero-`uses:` workflows in this very lockfile (labels.yml), so the spelling is the generator's own convention - the generator simply omitted this file. 2. Teach scripts/check-lock-sync.sh to catch it (clause 4, COVERAGE). The gate could not defend the very fix it 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 is still refused. Thirteen repositories passed the gate with exactly this gap present. Clause 4 diffs the set of files under .github/workflows/ against the set of lockfile keys and fails on any file with no key, naming it and quoting the empty-list form. 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. 3. Add `workflow_dispatch:` to the gate so it can be exercised on demand. A startup-failed run cannot be re-run (`gh run rerun` refuses it), which is what made this defect expensive to diagnose. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/actions.lock | 1 + .github/workflows/lock-sync-gate.yml | 1 + scripts/check-lock-sync.sh | 32 ++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index c157436..867005a 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -26,6 +26,7 @@ workflows: '.github/workflows/instant-sync.yml': [] '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] + '.github/workflows/lock-sync-gate.yml': [] '.github/workflows/mirror.yml': - 'hyperpolymath/standards@8f2ee50841e216cd8c192eeb68953118190f105c' '.github/workflows/openssf-compliance.yml': diff --git a/.github/workflows/lock-sync-gate.yml b/.github/workflows/lock-sync-gate.yml index 936c873..db046cb 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] diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 6ab01cc..3088bbd 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -214,6 +214,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. --- @@ -251,12 +278,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 }