From 6eb3be54676a59aab8058b73dfe3c0396b8238e8 Mon Sep 17 00:00:00 2001 From: Jonathan Jewell Date: Tue, 22 Sep 2026 14:27:22 +0100 Subject: [PATCH 1/5] 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 | 114 +++++++++--- .github/workflows/lock-sync-gate.yml | 63 +++++++ scripts/check-lock-sync.sh | 265 +++++++++++++++++++++++++++ 3 files changed, 416 insertions(+), 26 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 2ac69b9..e264c21 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -15,7 +15,7 @@ workflows: - 'google/clusterfuzzlite@v1' '.github/workflows/codeql.yml': - 'actions/checkout@v7.0.1' - - 'github/codeql-action@v4.37.9' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' '.github/workflows/coq-build.yml': - 'actions/checkout@v7.0.1' '.github/workflows/doc-consonance.yml': @@ -32,13 +32,16 @@ workflows: '.github/workflows/ghcr-publish.yml': - 'actions/attest-build-provenance@v4.2.2' - 'actions/checkout@v7.0.1' - '.github/workflows/governance.yml': [] - '.github/workflows/hypatia-scan.yml': [] + '.github/workflows/governance.yml': + - 'hyperpolymath/standards@8f31a5a4ba591d544b65f91f6d78b136e07756f0' + '.github/workflows/hypatia-scan.yml': + - 'hyperpolymath/standards@cc58c0cb23f73fc2019ce85a56a468e5248a93b3' '.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@fcb8669169b4e9f5d9848608df880ae5fae812b4' '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/deploy-pages@v5.0.1' @@ -50,12 +53,21 @@ workflows: - 'actions/upload-artifact@v7.0.1' - 'dtolnay/rust-toolchain@stable' - 'rustsec/audit-check@v2.0.0' - - 'taiki-e/install-action@v2.87.13' - '.github/workflows/scorecard.yml': [] - '.github/workflows/secret-scanner.yml': [] - '.github/workflows/security-scan.yml': [] - '.github/workflows/spark-theatre-gate.yml': [] + - 'taiki-e/install-action@v2.87.15' + '.github/workflows/scorecard.yml': + - 'hyperpolymath/standards@8750b94ac1bbe8c51ad13fe106669b13478f0b62' + '.github/workflows/secret-scanner.yml': + - 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a' + '.github/workflows/security-scan.yml': + - 'hyperpolymath/panic-attack@27b3d93b11fbfc03cee695e791904d741ce2b24b' + '.github/workflows/spark-theatre-gate.yml': + - 'hyperpolymath/standards@fcb8669169b4e9f5d9848608df880ae5fae812b4' dependencies: + 'Swatinem/rust-cache@v2.8.2': + ref: 'v2.8.2' + commit: 'sha1-779680da715d629ac1d338a641029a2f4372abb5' + owner_id: 580492 + repo_id: 298565987 'actions/attest-build-provenance@v4.2.2': ref: 'v4.2.2' commit: 'sha1-4d101475d8b20a2381f78447822ac1eab6504dd8' @@ -73,6 +85,16 @@ dependencies: 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@v4.3.1': + ref: 'v4.3.1' + commit: 'sha1-34e114876b0b11c390a56381ad16ebd13914f8d5' + owner_id: 44036562 + repo_id: 197814629 'actions/checkout@v7.0.1': ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -100,19 +122,29 @@ dependencies: repo_id: 496012378 uses: - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + 'dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c': + ref: '2c7215f132e9ebf062739d9130488b56d53c060c' + commit: 'sha1-2c7215f132e9ebf062739d9130488b56d53c060c' + owner_id: 1940490 + repo_id: 260749683 'dtolnay/rust-toolchain@stable': ref: 'stable' commit: 'sha1-6bed0761d98439e5a578e2877258200ad565ba87' owner_id: 1940490 repo_id: 260749683 + 'dtolnay/rust-toolchain@v1': + ref: 'v1' + commit: 'sha1-02cb101ec7c40f2c49e1d9714d64511d8e1b74de' + owner_id: 1940490 + repo_id: 260749683 'erlef/setup-beam@v1.24.1': ref: 'v1.24.1' commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' owner_id: 47606891 repo_id: 331103973 - 'github/codeql-action@v4.37.9': - ref: 'v4.37.9' - commit: 'sha1-cdf488f595d80d6e07e03d4674febd5ab45fa938' + 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': + ref: 'v4.38.0' + commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' owner_id: 9919 repo_id: 259445878 'google/clusterfuzzlite@v1': @@ -120,6 +152,46 @@ dependencies: commit: 'sha1-884713a6c30a92e5e8544c39945cd7cb630abcd1' owner_id: 1342004 repo_id: 400046858 + 'hyperpolymath/panic-attack@27b3d93b11fbfc03cee695e791904d741ce2b24b': + ref: '27b3d93b11fbfc03cee695e791904d741ce2b24b' + commit: 'sha1-27b3d93b11fbfc03cee695e791904d741ce2b24b' + owner_id: 6759885 + repo_id: 1152434997 + uses: + - 'actions/checkout@v4.3.1' + - 'dtolnay/rust-toolchain@v1' + - 'Swatinem/rust-cache@v2.8.2' + 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a': + ref: '84355587cb2a1f86e6882de83514a32db2646e7a' + commit: 'sha1-84355587cb2a1f86e6882de83514a32db2646e7a' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + 'hyperpolymath/standards@8750b94ac1bbe8c51ad13fe106669b13478f0b62': + ref: '8750b94ac1bbe8c51ad13fe106669b13478f0b62' + commit: 'sha1-8750b94ac1bbe8c51ad13fe106669b13478f0b62' + owner_id: 6759885 + repo_id: 1116521501 + 'hyperpolymath/standards@8f31a5a4ba591d544b65f91f6d78b136e07756f0': + ref: '8f31a5a4ba591d544b65f91f6d78b136e07756f0' + commit: 'sha1-8f31a5a4ba591d544b65f91f6d78b136e07756f0' + owner_id: 6759885 + repo_id: 1116521501 + 'hyperpolymath/standards@cc58c0cb23f73fc2019ce85a56a468e5248a93b3': + ref: 'cc58c0cb23f73fc2019ce85a56a468e5248a93b3' + commit: 'sha1-cc58c0cb23f73fc2019ce85a56a468e5248a93b3' + owner_id: 6759885 + repo_id: 1116521501 + 'hyperpolymath/standards@fcb8669169b4e9f5d9848608df880ae5fae812b4': + ref: 'fcb8669169b4e9f5d9848608df880ae5fae812b4' + commit: 'sha1-fcb8669169b4e9f5d9848608df880ae5fae812b4' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' 'peter-evans/repository-dispatch@v4.0.1': ref: 'v4.0.1' commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' @@ -130,23 +202,13 @@ dependencies: commit: 'sha1-69366f33c96575abad1ee0dba8212993eecbe998' owner_id: 25397242 repo_id: 523199201 - 'taiki-e/install-action@v2.87.13': - ref: 'v2.87.13' - commit: 'sha1-26e9283f268b880168bdbd2c545dfcd60ec2c6ab' + 'taiki-e/install-action@v2.87.15': + ref: 'v2.87.15' + commit: 'sha1-4076c08d76dba979c11a7285295b0716c1d67908' owner_id: 43724913 repo_id: 442947557 - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': - ref: 'v7.0.1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 44036562 - repo_id: 197814629 - 'dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c': - ref: 'stable' - commit: 'sha1-2c7215f132e9ebf062739d9130488b56d53c060c' - owner_id: 1940490 - repo_id: 260749683 '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..6ab01cc --- /dev/null +++ b/scripts/check-lock-sync.sh @@ -0,0 +1,265 @@ +#!/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; 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 uses: must be locked under THIS path --- + nu = split(useslist[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 refs:%s\n", key, missing + else + printf "FAIL %s\n refs missing from the lockfile:%s\n", key, missing + bad = 1 + } + + # --- 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 +} +AWK + +"$AWK" -v lockfile="$LOCK" "$PROG" "$LOCK" "${WORKFLOWS[@]}" From 0467a21c02cb7ff78886cc72fa3e92a92eb18445 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:50:29 +0000 Subject: [PATCH 2/5] docs(ci): clarify action reference normalization in lock-sync check --- scripts/check-lock-sync.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 6ab01cc..334fd32 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -77,7 +77,9 @@ 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 the owner/repository pin for an external action or reusable workflow, +# discarding any subpath. Return an empty string for local, malformed, or +# non-repository references. 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 35d127acdb5c5378a5949272a5ad10911da4c5e9 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:53:11 +0000 Subject: [PATCH 3/5] chore(rust-core): Format A2ML, Groove, and RAM promotion code --- rust-core/verisim-api/src/a2ml.rs | 77 +++++++++++--------- rust-core/verisim-api/src/groove.rs | 63 ++++++++-------- rust-core/verisim-octad/src/ram_promotion.rs | 62 +++++++++++----- 3 files changed, 115 insertions(+), 87 deletions(-) diff --git a/rust-core/verisim-api/src/a2ml.rs b/rust-core/verisim-api/src/a2ml.rs index a8c8bbf..459df2e 100644 --- a/rust-core/verisim-api/src/a2ml.rs +++ b/rust-core/verisim-api/src/a2ml.rs @@ -37,12 +37,7 @@ pub const A2ML_CONTENT_TYPE: &str = "text/a2ml; charset=utf-8"; /// Wrap an A2ML body string in an Axum [`Response`] with the correct /// `Content-Type`. pub fn a2ml_response(status: StatusCode, body: String) -> Response { - ( - status, - [(header::CONTENT_TYPE, A2ML_CONTENT_TYPE)], - body, - ) - .into_response() + (status, [(header::CONTENT_TYPE, A2ML_CONTENT_TYPE)], body).into_response() } /// Convenience alias so callers can `use crate::a2ml::String` implicitly. @@ -126,20 +121,36 @@ pub fn proof_attempts_to_a2ml(rows: &[serde_json::Value]) -> String { let mut out = String::new(); out.push_str("@proof-attempts():\n"); for v in rows { - let Some(attempt_id) = v["attempt_id"].as_str() else { continue }; - let Some(obligation_id) = v["obligation_id"].as_str() else { continue }; - let Some(repo) = v["repo"].as_str() else { continue }; - let Some(file) = v["file"].as_str() else { continue }; - let Some(claim) = v["claim"].as_str() else { continue }; - let Some(obligation_class) = v["obligation_class"].as_str() else { continue }; - let Some(prover_used) = v["prover_used"].as_str() else { continue }; - let Some(outcome) = v["outcome"].as_str() else { continue }; - let duration_ms = v["duration_ms"].as_u64().unwrap_or(0); - let confidence = v["confidence"].as_f64().unwrap_or(0.0); - let parent_attempt_id = v["parent_attempt_id"].as_str(); - let strategy_tag = v["strategy_tag"].as_str().unwrap_or(""); - let started_at = v["started_at"].as_str().unwrap_or(""); - let completed_at = v["completed_at"].as_str().unwrap_or(""); + let Some(attempt_id) = v["attempt_id"].as_str() else { + continue; + }; + let Some(obligation_id) = v["obligation_id"].as_str() else { + continue; + }; + let Some(repo) = v["repo"].as_str() else { + continue; + }; + let Some(file) = v["file"].as_str() else { + continue; + }; + let Some(claim) = v["claim"].as_str() else { + continue; + }; + let Some(obligation_class) = v["obligation_class"].as_str() else { + continue; + }; + let Some(prover_used) = v["prover_used"].as_str() else { + continue; + }; + let Some(outcome) = v["outcome"].as_str() else { + continue; + }; + let duration_ms = v["duration_ms"].as_u64().unwrap_or(0); + let confidence = v["confidence"].as_f64().unwrap_or(0.0); + let parent_attempt_id = v["parent_attempt_id"].as_str(); + let strategy_tag = v["strategy_tag"].as_str().unwrap_or(""); + let started_at = v["started_at"].as_str().unwrap_or(""); + let completed_at = v["completed_at"].as_str().unwrap_or(""); let row = ProofAttemptRowA2ml { attempt_id, @@ -210,10 +221,10 @@ pub fn parse_recommendations(text: &str) -> Vec { .filter_map(|line| { let v: serde_json::Value = serde_json::from_str(line).ok()?; Some(RecommendationA2ml { - prover: v["prover_used"].as_str()?.to_string(), - success_rate: v["success_rate"].as_f64().unwrap_or(0.0), + prover: v["prover_used"].as_str()?.to_string(), + success_rate: v["success_rate"].as_f64().unwrap_or(0.0), avg_duration_ms: v["avg_duration_ms"].as_f64().unwrap_or(0.0), - total_attempts: v["total_attempts"].as_u64().unwrap_or(0), + total_attempts: v["total_attempts"].as_u64().unwrap_or(0), }) }) .collect() @@ -257,9 +268,9 @@ pub fn parse_certs(text: &str) -> Vec { .filter_map(|line| { let v: serde_json::Value = serde_json::from_str(line).ok()?; Some(CertRowA2ml { - prover_used: v["prover_used"].as_str()?.to_string(), - status: v["status"].as_str().unwrap_or("pending").to_string(), - success_rate: v["success_rate"].as_f64().unwrap_or(0.0), + prover_used: v["prover_used"].as_str()?.to_string(), + status: v["status"].as_str().unwrap_or("pending").to_string(), + success_rate: v["success_rate"].as_f64().unwrap_or(0.0), total_attempts: v["total_attempts"].as_u64().unwrap_or(0), }) }) @@ -310,14 +321,12 @@ mod tests { #[test] fn strategy_to_a2ml_format() { - let recs = vec![ - RecommendationA2ml { - prover: "echidna".to_string(), - success_rate: 0.95, - avg_duration_ms: 120.5, - total_attempts: 100, - }, - ]; + let recs = vec![RecommendationA2ml { + prover: "echidna".to_string(), + success_rate: 0.95, + avg_duration_ms: 120.5, + total_attempts: 100, + }]; let s = strategy_to_a2ml(&recs); assert!(s.starts_with("@strategy-recommendations():\n")); assert!(s.contains("prover=\"echidna\"")); diff --git a/rust-core/verisim-api/src/groove.rs b/rust-core/verisim-api/src/groove.rs index 4ed1e0d..74df3cd 100644 --- a/rust-core/verisim-api/src/groove.rs +++ b/rust-core/verisim-api/src/groove.rs @@ -501,8 +501,12 @@ fn probe_mesh_peers(mesh: &MeshState) { match std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)) { Ok(mut stream) => { use std::io::{Read, Write}; - stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok(); - stream.set_write_timeout(Some(std::time::Duration::from_millis(500))).ok(); + stream + .set_read_timeout(Some(std::time::Duration::from_millis(500))) + .ok(); + stream + .set_write_timeout(Some(std::time::Duration::from_millis(500))) + .ok(); let request = format!( "GET /.well-known/groove/status HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n\r\n", @@ -649,10 +653,18 @@ pub struct FeedbackRequest { pub source_service: String, } -fn default_feedback_type() -> String { "feedback".to_string() } -fn default_verisimdb() -> String { "verisimdb".to_string() } -fn default_other() -> String { "other".to_string() } -fn default_unknown() -> String { "unknown".to_string() } +fn default_feedback_type() -> String { + "feedback".to_string() +} +fn default_verisimdb() -> String { + "verisimdb".to_string() +} +fn default_other() -> String { + "other".to_string() +} +fn default_unknown() -> String { + "unknown".to_string() +} /// POST /.well-known/groove/feedback — Receive feedback from the Groove mesh. async fn groove_feedback_handler( @@ -704,10 +716,12 @@ async fn groove_feedback_handler( } /// GET /.well-known/groove/feedback — List stored feedback entries. -async fn groove_feedback_list_handler( - State(store): State, -) -> impl IntoResponse { - let entries = store.entries.lock().unwrap_or_else(|e| e.into_inner()).clone(); +async fn groove_feedback_list_handler(State(store): State) -> impl IntoResponse { + let entries = store + .entries + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); Json(serde_json::json!({ "count": entries.len(), "entries": entries, @@ -731,14 +745,8 @@ pub fn groove_router() -> Router { // Connection lifecycle sub-router (uses GrooveState). let connection_router = Router::new() - .route( - "/.well-known/groove", - get(groove_manifest_handler), - ) - .route( - "/.well-known/groove/connect", - post(groove_connect_handler), - ) + .route("/.well-known/groove", get(groove_manifest_handler)) + .route("/.well-known/groove/connect", post(groove_connect_handler)) .route( "/.well-known/groove/disconnect", post(groove_disconnect_handler), @@ -747,18 +755,12 @@ pub fn groove_router() -> Router { "/.well-known/groove/heartbeat", get(groove_heartbeat_handler), ) - .route( - "/.well-known/groove/status", - get(groove_status_handler), - ) + .route("/.well-known/groove/status", get(groove_status_handler)) .with_state(groove_state); // Health mesh sub-router (uses MeshState). let mesh_router = Router::new() - .route( - "/.well-known/groove/mesh", - get(groove_mesh_handler), - ) + .route("/.well-known/groove/mesh", get(groove_mesh_handler)) .with_state(mesh_state); // Feedback sub-router (uses FeedbackStore). @@ -769,9 +771,7 @@ pub fn groove_router() -> Router { ) .with_state(feedback_store); - connection_router - .merge(mesh_router) - .merge(feedback_router) + connection_router.merge(mesh_router).merge(feedback_router) } // --- Helpers --- @@ -874,10 +874,7 @@ mod tests { #[test] fn test_capability_matching() { - let peer_consumes = vec![ - "octad-storage".to_string(), - "unknown-cap".to_string(), - ]; + let peer_consumes = vec!["octad-storage".to_string(), "unknown-cap".to_string()]; let matched: Vec = peer_consumes .iter() diff --git a/rust-core/verisim-octad/src/ram_promotion.rs b/rust-core/verisim-octad/src/ram_promotion.rs index bb58982..8d93f5f 100644 --- a/rust-core/verisim-octad/src/ram_promotion.rs +++ b/rust-core/verisim-octad/src/ram_promotion.rs @@ -38,9 +38,9 @@ // - RAM promotion: tmpfs overlay for reads, writes go to WAL first // - On crash: WAL replays, RAM overlay is gone, no data loss +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; -use serde::{Serialize, Deserialize}; /// Hard limit: maximum octads promoted to RAM simultaneously. /// Conservative limit (2 not 3) for crash safety — fewer in-flight @@ -70,9 +70,14 @@ pub enum Modality { impl Modality { pub fn all() -> Vec { vec![ - Modality::Graph, Modality::Vector, Modality::Tensor, - Modality::Semantic, Modality::Document, Modality::Temporal, - Modality::Provenance, Modality::Spatial, + Modality::Graph, + Modality::Vector, + Modality::Tensor, + Modality::Semantic, + Modality::Document, + Modality::Temporal, + Modality::Provenance, + Modality::Spatial, ] } @@ -103,7 +108,10 @@ struct PromotedState { #[derive(Debug, Clone)] pub enum PromotionDecision { /// Promote — significant benefit expected. - Promote { modalities: Vec, estimated_speedup: f64 }, + Promote { + modalities: Vec, + estimated_speedup: f64, + }, /// Skip — benefit too small to justify RAM usage. Skip { reason: String }, /// Blocked — already at MAX_PROMOTED limit. @@ -202,7 +210,8 @@ impl PromotionManager { } // Check 3-octad limit. - let new_count = modalities.iter() + let new_count = modalities + .iter() .filter(|m| !self.promoted.contains_key(m)) .count(); if self.promoted.len() + new_count > MAX_PROMOTED { @@ -256,7 +265,8 @@ impl PromotionManager { if self.promoted.len() >= MAX_PROMOTED { return Err(format!( "Cannot promote: already at limit ({}/{})", - self.promoted.len(), MAX_PROMOTED + self.promoted.len(), + MAX_PROMOTED )); } @@ -271,12 +281,15 @@ impl PromotionManager { )); } - self.promoted.insert(modality.clone(), PromotedState { - modality: modality.clone(), - promoted_at: Instant::now(), - estimated_size_bytes: estimated_size, - operation_count: 0, - }); + self.promoted.insert( + modality.clone(), + PromotedState { + modality: modality.clone(), + promoted_at: Instant::now(), + estimated_size_bytes: estimated_size, + operation_count: 0, + }, + ); self.ram_used += estimated_size; self.history.push(PromotionEvent { @@ -316,7 +329,9 @@ impl PromotionManager { /// Check for expired promotions and force-demote them. pub fn enforce_timeouts(&mut self) { - let expired: Vec = self.promoted.iter() + let expired: Vec = self + .promoted + .iter() .filter(|(_, state)| state.promoted_at.elapsed() > MAX_PROMOTION_DURATION) .map(|(m, _)| m.clone()) .collect(); @@ -345,7 +360,9 @@ impl PromotionManager { } impl Default for PromotionManager { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } } fn epoch_ms() -> u64 { @@ -393,8 +410,10 @@ mod tests { let mut pm = PromotionManager::new(); pm.enable(10_000_000); - pm.promote(&Modality::Graph, 1000).expect("TODO: handle error"); - pm.promote(&Modality::Vector, 1000).expect("TODO: handle error"); + pm.promote(&Modality::Graph, 1000) + .expect("TODO: handle error"); + pm.promote(&Modality::Vector, 1000) + .expect("TODO: handle error"); pm.demote(&Modality::Graph); assert_eq!(pm.promoted_count(), 1); @@ -437,8 +456,10 @@ mod tests { let mut pm = PromotionManager::new(); pm.enable(10_000_000); - pm.promote(&Modality::Graph, 1000).expect("TODO: handle error"); - pm.promote(&Modality::Vector, 1000).expect("TODO: handle error"); + pm.promote(&Modality::Graph, 1000) + .expect("TODO: handle error"); + pm.promote(&Modality::Vector, 1000) + .expect("TODO: handle error"); pm.demote_all(); assert_eq!(pm.promoted_count(), 0); @@ -451,7 +472,8 @@ mod tests { let mut pm = PromotionManager::new(); pm.enable(10_000_000); - pm.promote(&Modality::Graph, 1000).expect("TODO: handle error"); + pm.promote(&Modality::Graph, 1000) + .expect("TODO: handle error"); pm.demote(&Modality::Graph); assert_eq!(pm.history().len(), 2); From 1c77fd18751f16be85e7f4132ad60706ba8c567c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:04:45 +0100 Subject: [PATCH 4/5] 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 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 334fd32..58d087b 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -70,11 +70,12 @@ if [ ! -f "$LOCK" ]; then fi shopt -s nullglob -mapfile -t WORKFLOWS < <(printf '%s\n' "$WF_DIR"/*.yml "$WF_DIR"/*.yaml | sort -u) +WORKFLOWS=("$WF_DIR"/*.yml "$WF_DIR"/*.yaml) if [ "${#WORKFLOWS[@]}" -eq 0 ]; then echo "check-lock-sync: FATAL: no workflow files under $WF_DIR" >&2 exit 1 fi +mapfile -t WORKFLOWS < <(printf '%s\n' "${WORKFLOWS[@]}" | sort -u) read -r -d '' PROG <<'AWK' || true # Return the owner/repository pin for an external action or reusable workflow, From 0bc3c432367a60b97d65dee34e0ecc1a7058e7ea Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:09:51 +0000 Subject: [PATCH 5/5] fix(ci): Allow unpinned self-repository paths in lock-sync checks --- scripts/check-lock-sync.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 58d087b..0b286d4 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -157,7 +157,9 @@ FNR == 1 { wf = FILENAME } raw = m[1] gsub(/^["']|["']$/, "", raw) gsub(/[[:space:]]+$/, "", raw) - if (raw ~ /^\$\//) { dollar[wf] = dollar[wf] " " raw; next } # known corruption + # Self-repository paths (`$/...`) are valid as written. Only an appended + # ref is invalid: this syntax must not pin a self-repository path. + if (raw ~ /^\$\/.*@[^@]+$/) { dollar[wf] = dollar[wf] " " raw; next } n = norm(raw) if (n != "") { uses[wf, ck(n)] = 1; useslist[wf] = useslist[wf] " " n } } @@ -173,7 +175,7 @@ END { 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] + printf "FAIL %s\n self-repository paths must not have a trailing @ref suffix:%s\n", key, dollar[wf] bad = 1 }