From b6a9459d29de4abfe5ddf91bdec99af9ede6bcda Mon Sep 17 00:00:00 2001 From: Jonathan Jewell Date: Tue, 22 Sep 2026 18:48:53 +0100 Subject: [PATCH 1/7] 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 | 146 ++++++++----- .github/workflows/lock-sync-gate.yml | 63 ++++++ scripts/check-lock-sync.sh | 307 +++++++++++++++++++++++++++ 3 files changed, 463 insertions(+), 53 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 652aa89..1555226 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -7,32 +7,48 @@ workflows: - 'actions/checkout@v7.0.1' '.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@8f2ee50841e216cd8c192eeb68953118190f105c' + '.github/workflows/hypatia-scan.yml': + - 'hyperpolymath/standards@8f2ee50841e216cd8c192eeb68953118190f105c' '.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@8f2ee50841e216cd8c192eeb68953118190f105c' '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.3.0' '.github/workflows/release.yml': - 'actions/checkout@v7.0.1' - 'actions/upload-artifact@v7.0.1' + - 'slsa-framework/slsa-github-generator@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' - 'softprops/action-gh-release@v3.0.3' '.github/workflows/rhodibot.yml': - 'actions/checkout@v7.0.1' - '.github/workflows/scorecard.yml': [] - '.github/workflows/secret-scanner.yml': [] + '.github/workflows/scorecard.yml': + - 'hyperpolymath/standards@8f2ee50841e216cd8c192eeb68953118190f105c' + '.github/workflows/secret-scanner.yml': + - 'hyperpolymath/standards@8f2ee50841e216cd8c192eeb68953118190f105c' '.github/workflows/static-analysis-gate.yml': - 'actions/checkout@v7.0.1' - 'actions/download-artifact@v8.0.1' - 'actions/upload-artifact@v7.0.1' - 'erlef/setup-beam@v1.24.1' dependencies: + 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': + ref: '55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + 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' @@ -43,83 +59,107 @@ dependencies: commit: 'sha1-3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' owner_id: 44036562 repo_id: 192626254 - 'actions/upload-artifact@v7.0.1': - ref: 'v7.0.1' - commit: 'sha1-043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' - owner_id: 44036562 - repo_id: 192625955 - 'erlef/setup-beam@v1.24.1': - ref: 'v1.24.1' - commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' - owner_id: 47606891 - repo_id: 331103973 - 'github/codeql-action@v4.38.0': - ref: 'v4.38.0' - commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' - owner_id: 9919 - repo_id: 259445878 - 'hyperpolymath/smtp-notify-action@v0.3.0': - ref: 'v0.3.0' - commit: 'sha1-22e7bdb322c430c1d0dac6b3bb307f4bb139d0be' - owner_id: 6759885 - repo_id: 1352485172 - 'peter-evans/repository-dispatch@v4.0.1': - ref: 'v4.0.1' - commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' - owner_id: 18365890 - repo_id: 220359305 - 'softprops/action-gh-release@v3.0.3': - ref: 'v3.0.3' - commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' - owner_id: 2242 - repo_id: 204253808 - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': - ref: 'v6.1.0' - commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - owner_id: 44036562 - repo_id: 215566462 - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': - ref: 'v7.0.1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 44036562 - repo_id: 197814629 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a': - ref: 'v7.0.1' + ref: '043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' commit: 'sha1-043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' owner_id: 44036562 repo_id: 192625955 'actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08': - ref: 'v4.6.0' + ref: '65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08' commit: 'sha1-65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08' owner_id: 44036562 repo_id: 192625955 + 'actions/upload-artifact@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + owner_id: 44036562 + repo_id: 192625955 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772': - ref: 'stable' + ref: '6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' commit: 'sha1-6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' owner_id: 1940490 repo_id: 260749683 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393': - ref: 'v3.0.0' + ref: '51f63319f592f97930c73d9c46184d20bd206393' commit: 'sha1-51f63319f592f97930c73d9c46184d20bd206393' owner_id: 26415196 repo_id: 297874902 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124': + ref: '54075bcc5e249e4758d363f27d099f55d843f124' + commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' + owner_id: 47606891 + repo_id: 331103973 + 'erlef/setup-beam@v1.24.1': ref: 'v1.24.1' 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@cdf488f595d80d6e07e03d4674febd5ab45fa938': + ref: 'cdf488f595d80d6e07e03d4674febd5ab45fa938' + commit: 'sha1-cdf488f595d80d6e07e03d4674febd5ab45fa938' + owner_id: 9919 + repo_id: 259445878 + 'hyperpolymath/smtp-notify-action@v0.3.0': + ref: 'v0.3.0' + commit: 'sha1-22e7bdb322c430c1d0dac6b3bb307f4bb139d0be' + owner_id: 6759885 + repo_id: 1352485172 + 'hyperpolymath/standards@8f2ee50841e216cd8c192eeb68953118190f105c': + ref: '8f2ee50841e216cd8c192eeb68953118190f105c' + commit: 'sha1-8f2ee50841e216cd8c192eeb68953118190f105c' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + - 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' + - 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' + - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc': - ref: 'v2.4.4' + 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' + owner_id: 18365890 + repo_id: 220359305 + 'slsa-framework/slsa-github-generator@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a': + ref: 'f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' + commit: 'sha1-f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' + owner_id: 80431187 + repo_id: 475074978 + uses: + - 'actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08' + - 'slsa-framework/slsa-github-generator@v2.1.0' + - 'softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda' + 'slsa-framework/slsa-github-generator@v2.1.0': + ref: 'v2.1.0' + commit: 'sha1-f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' + owner_id: 80431187 + repo_id: 475074978 'softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda': - ref: 'v2.2.1' + ref: 'c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda' commit: 'sha1-c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda' owner_id: 2242 repo_id: 204253808 + 'softprops/action-gh-release@v3.0.3': + ref: 'v3.0.3' + commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' + owner_id: 2242 + repo_id: 204253808 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': - ref: 'v0.10.0' + 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 6176d6f3a5ab50d237f7c5edd79c094ecd85748d 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:05 +0100 Subject: [PATCH 2/7] 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 1555226..4f77d01 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -18,6 +18,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@8f2ee50841e216cd8c192eeb68953118190f105c' '.github/workflows/push-email-notify.yml': From d32243f14bdde4a20ba14f6617de06ef71a6eb01 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:33 +0100 Subject: [PATCH 3/7] 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 6f6941232c8e816bf7df095babed1069e984ef7c 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:19 +0100 Subject: [PATCH 4/7] 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 | 10 ---------- scripts/check-lock-sync.sh | 8 +++++--- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 4f77d01..cd0bc1a 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -26,7 +26,6 @@ workflows: '.github/workflows/release.yml': - 'actions/checkout@v7.0.1' - 'actions/upload-artifact@v7.0.1' - - 'slsa-framework/slsa-github-generator@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' - 'softprops/action-gh-release@v3.0.3' '.github/workflows/rhodibot.yml': - 'actions/checkout@v7.0.1' @@ -135,15 +134,6 @@ dependencies: commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' owner_id: 18365890 repo_id: 220359305 - 'slsa-framework/slsa-github-generator@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a': - ref: 'f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' - commit: 'sha1-f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' - owner_id: 80431187 - repo_id: 475074978 - uses: - - 'actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08' - - 'slsa-framework/slsa-github-generator@v2.1.0' - - 'softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda' 'slsa-framework/slsa-github-generator@v2.1.0': ref: 'v2.1.0' commit: 'sha1-f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' 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 19012680dea239c398701f70dfe5d33aaa265557 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:39:30 +0100 Subject: [PATCH 5/7] Update scripts/check-lock-sync.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/check-lock-sync.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 38762fe..5640769 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -72,11 +72,12 @@ if [ ! -f "$LOCK" ]; then fi shopt -s nullglob -mapfile -t WORKFLOWS < <(printf '%s\n' "$WF_DIR"/*.yml "$WF_DIR"/*.yaml | sort -u) -if [ "${#WORKFLOWS[@]}" -eq 0 ]; then +CANDIDATES=("$WF_DIR"/*.yml "$WF_DIR"/*.yaml) +if [ "${#CANDIDATES[@]}" -eq 0 ]; then echo "check-lock-sync: FATAL: no workflow files under $WF_DIR" >&2 exit 1 fi +mapfile -t WORKFLOWS < <(printf '%s\n' "${CANDIDATES[@]}" | sort -u) read -r -d '' PROG <<'AWK' || true # owner/repo[/subpath...]@ref -> owner/repo@ref ("" if not an external ref) From 1dec9f09f580199cec50e1983e367c1c56f0111e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:39:49 +0100 Subject: [PATCH 6/7] Update scripts/check-lock-sync.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/check-lock-sync.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 5640769..8b64c3d 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -139,7 +139,7 @@ FILENAME == lockfile { next } if (match($0, /^ - '([^']+)'[[:space:]]*$/, m) && cur != "") { - lr = ck(m[1]); disp[lr] = m[1]; lock[cur, lr] = 1 + lr = ck(norm(m[1])); disp[lr] = m[1]; lock[cur, lr] = 1 lockcount[cur]++ want[lr] = 1 wantsrc[lr] = wantsrc[lr] " " cur From 996c19e5f2e5d733f1c954e7fa0b1f609143e7f3 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:45:10 +0000 Subject: [PATCH 7/7] fix(metadata): add checklist names, K9 header, and deployment pedigree name and version --- .machine_readable/policies/MAINTENANCE-CHECKLIST.a2ml | 1 + container/deploy.k9.ncl | 3 ++- docs/governance/MAINTENANCE-CHECKLIST.a2ml | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.machine_readable/policies/MAINTENANCE-CHECKLIST.a2ml b/.machine_readable/policies/MAINTENANCE-CHECKLIST.a2ml index eaee720..f46eece 100644 --- a/.machine_readable/policies/MAINTENANCE-CHECKLIST.a2ml +++ b/.machine_readable/policies/MAINTENANCE-CHECKLIST.a2ml @@ -2,6 +2,7 @@ # Cross-repo maintenance baseline (machine-readable canonical) [metadata] +name = "maintenance-checklist" version = "1.1.0" last-updated = "2026-02-24" scope = "cross-repo" diff --git a/container/deploy.k9.ncl b/container/deploy.k9.ncl index d3e6d21..aa01903 100644 --- a/container/deploy.k9.ncl +++ b/container/deploy.k9.ncl @@ -1,3 +1,4 @@ +K9! # SPDX-License-Identifier: MPL-2.0 # deploy.k9.ncl — Session Sentinel deployment component (Hunt level) # @@ -143,7 +144,7 @@ echo "K9: Rollback complete." # Export the component { - pedigree = component_pedigree, + pedigree = component_pedigree & { name = "session-sentinel-deploy", version = "0.1.0" }, deployment = deployment, scripts = scripts, diff --git a/docs/governance/MAINTENANCE-CHECKLIST.a2ml b/docs/governance/MAINTENANCE-CHECKLIST.a2ml index eaee720..f46eece 100644 --- a/docs/governance/MAINTENANCE-CHECKLIST.a2ml +++ b/docs/governance/MAINTENANCE-CHECKLIST.a2ml @@ -2,6 +2,7 @@ # Cross-repo maintenance baseline (machine-readable canonical) [metadata] +name = "maintenance-checklist" version = "1.1.0" last-updated = "2026-02-24" scope = "cross-repo"