From 8b5c9f2077eda0d55c461218fd672e4be37f5aa9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:04:11 +0100 Subject: [PATCH 1/5] fix(hooks): unblock commits blocked by staged-mode validator defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `standards` could not accept ANY commit through its own pre-commit chain. Two independent defects, both in staged mode only: 1. validate-spdx.sh — scan mode applies an extension allowlist; staged mode applied NONE, so every staged file was judged by a rule written for source files. The machine-generated .github/workflows/actions.lock ("Do not edit by hand") carries no SPDX header and has any added header stripped on the next regeneration, so this blocked EVERY commit touching the lockfile — which is why a Dependabot-caused lockfile desync could sit unrepaired. Same fault hit README.adoc and any other non-source file. Cure: one is_source_file() predicate, applied in BOTH modes. 2. validate-a2ml.sh — the registry-drift gate in .githooks/pre-commit FAILS every commit until you regenerate and stage .machine_readable/REGISTRY.a2ml, and this validator then REJECTED that very file. No ordering satisfied both gates; --no-verify was the only exit. Cure: skip files that declare themselves generated. Narrow by construction — 2 of 222 tracked .a2ml files carry the marker, and the pattern is anchored (^# GENERATED FILE ... DO NOT EDIT BY HAND) so a prose mention cannot spoof the exemption. Mutation-tested, 7 cases: T1 lockfile alone rc=0 (was 1) cured T2 real .sh missing SPDX rc=1 guard not disarmed T3 full-scan mode rc=1 PRE-EXISTING at HEAD (215 errors); patched output is byte-identical, diff clean T4 REGISTRY.a2ml rc=0 (was 1) cured T5 hand-written .a2ml, no fields rc=1 guard not disarmed T6 MUTANT: prose mention of marker rc=1 spoof killed by the anchor T7 MUTANT: exemption removed rc=1 proves the exemption acts Known and deliberately not fixed here: 215 tracked files lack SPDX headers, so the full-scan gate must stay non-blocking in CI until that backlog is cleared. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim --- .githooks/validate-a2ml.sh | 12 +++++++++++- .githooks/validate-spdx.sh | 19 +++++++++++++++++-- .machine_readable/REGISTRY.a2ml | 6 +++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.githooks/validate-a2ml.sh b/.githooks/validate-a2ml.sh index fb06f810..997f9910 100755 --- a/.githooks/validate-a2ml.sh +++ b/.githooks/validate-a2ml.sh @@ -10,7 +10,17 @@ ERRORS=0 validate_file() { local file="$1" - + + # A machine-generated manifest is the generator's responsibility, not the + # committer's. Skipping it breaks a hard deadlock in .githooks/pre-commit: + # the registry-drift gate FAILS every commit until you regenerate and stage + # .machine_readable/REGISTRY.a2ml, and this validator then REJECTED that very + # file -- so no ordering satisfied both gates and --no-verify was the only + # exit. Narrow by construction: 2 of 222 tracked .a2ml files are generated. + if head -20 "$file" | grep -qE '^#[[:space:]]*GENERATED FILE.*DO NOT EDIT BY HAND'; then + return 0 + fi + # Check required fields if ! grep -qE '^(agent-id|pedigree):' "$file"; then echo "[validate-a2ml] ERROR: $file missing agent-id or pedigree" >&2 diff --git a/.githooks/validate-spdx.sh b/.githooks/validate-spdx.sh index 49d0baaa..f60df4f4 100755 --- a/.githooks/validate-spdx.sh +++ b/.githooks/validate-spdx.sh @@ -7,11 +7,25 @@ SCAN_PATH="${INPUT_PATH:-.}" STAGED_FILES="${INPUT_STAGED_FILES:-}" ERRORS=0 -# If staged files provided, only check those +# The extension allowlist is the SINGLE source of truth for "is this a source +# file we require an SPDX header on". It MUST be applied in both modes: staged +# mode previously passed $STAGED_FILES through unfiltered, so any commit that +# touched a non-source file was judged by a rule written for source files. The +# machine-generated .github/workflows/actions.lock ("Do not edit by hand") +# carries no SPDX header and has any added header stripped on the next +# regeneration, so that omission blocked EVERY commit touching the lockfile -- +# which is why a Dependabot-caused lockfile desync could sit unrepaired. +is_source_file() { + case "$1" in + *.rs|*.res|*.js|*.ts|*.sh|*.bash|*.zig|*.ex|*.exs|*.gleam) return 0 ;; + *.ml|*.mli|*.adb|*.ads|*.ncl|*.toml|*.json|*.yaml|*.yml) return 0 ;; + *) return 1 ;; + esac +} + if [ -n "$STAGED_FILES" ]; then FILES_TO_CHECK=$STAGED_FILES else - # Check all source files FILES_TO_CHECK=$(find "$SCAN_PATH" -path '*/.git/*' -prune -o -path '*/node_modules/*' -prune -o \ -type f \( -name '*.rs' -o -name '*.res' -o -name '*.js' -o -name '*.ts' -o -name '*.sh' \ -o -name '*.bash' -o -name '*.zig' -o -name '*.ex' -o -name '*.exs' -o -name '*.gleam' \ @@ -24,6 +38,7 @@ fi for file in $FILES_TO_CHECK; do [ -f "$file" ] || continue + is_source_file "$file" || continue # Check for SPDX header in first 10 lines if ! head -10 "$file" | grep -qE '^# SPDX-License-Identifier:'; then diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index 984a2171..cb39c921 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -63,7 +63,7 @@ name = "META.a2ml spec" stream = "foundation" home = "meta-a2ml/" canonical_doc = "meta-a2ml/README.adoc" -source_hash = "sha256:1c5337a9782e37cf24d869109d798d0bf82c67fe62c24f94314b235ea8f173b4" +source_hash = "sha256:a058855d1c8019ccf1814a9386ba406b7e8df3698fd17c9fd342a5134f6a2eb0" route = "architecture decisions / governance metadata format" [[spec]] @@ -126,7 +126,7 @@ name = "0-AI Gatekeeper Protocol" stream = "protocol" home = "0-ai-gatekeeper-protocol/" canonical_doc = "0-ai-gatekeeper-protocol/README.adoc" -source_hash = "sha256:369bd762d903006a10f75e924be5c07d082554d88824fbf035ed32f3f67d05c2" +source_hash = "sha256:41f60acfb75bc32b0a3fc13cf3642f2e3f553bb4b7ddc9911b1e23d17e205ef3" route = "the AI-agent entry/gating protocol behind 0-AI-MANIFEST" [[spec]] @@ -207,7 +207,7 @@ name = "RSR — Rhodium Standard Repositories" stream = "governance" home = "rhodium-standard-repositories/" canonical_doc = "rhodium-standard-repositories/README.adoc" -source_hash = "sha256:dbd52c26f0ca4964683db06045feda29710e44289baf5eb573ebf195a3ed44a9" +source_hash = "sha256:35b2a2d8a9b4e33d7f73c663f0d811d054a16e8601f62a20c9ec54114cd43b54" route = "the repository-compliance standard every repo is graded against" [[spec]] From e4f6e55635c8025a266531bd06e072c0ac97aed6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:14:39 +0100 Subject: [PATCH 2/5] fix(lock): re-key codeql-action so reusable callers validate again Dependabot bumped github/codeql-action from cdf488f5 to b96794f0 inside codeql-reusable.yml, scorecard-reusable.yml and hypatia-scan-reusable.yml on 09-12 but never regenerated .github/workflows/actions.lock. GitHub validates a reusable workflow against the CALLEE repo's own actions.lock, so every caller of those three reusables -- the whole 159-repo population -- failed with `failure` (not `startup_failure`), 0 jobs, and name == path. REST and GraphQL carry no reason for this; only the run page shows it. actionlint passes on the files either way, which is why it went unnoticed. Repair is lockfile-only and deliberately scoped to the three affected workflows, passed as explicit paths so the tool never sees the others: gh actions-lock --no-migrate-local-actions --no-narrow Two refs stay absent from the lock ON PURPOSE, not by oversight: denoland/setup-deno@22d081ff (governance-reusable.yml) Keying it would make a BANNED runtime a required lock key for every caller. Estate doctrine is bun-only; the cure is to remove the consumer, not to satisfy it. Tracked separately. hyperpolymath/a2ml-ecosystem/secrets-check-action@f7a40a4d A2ML is dead; do not connect new machinery to it. Control, per the known rewrite hazard in this tool: the *.yml diff is EMPTY. `gh actions-lock` in fix mode prepends a duplicate "managed by gh actions-lock" banner above the SPDX line (its idempotence check reads only line 1 -- scorecard-reusable.yml already carries the banner on both line 1 and line 3 from a previous run); those two cosmetic hunks were reverted so this commit touches the lockfile alone. Measured: `gh actions-lock --verify-local --no-migrate-local-actions` went from 4 of 45 workflows failing (5 stale) to 1 of 45 (2 stale). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim --- .github/workflows/actions.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 5a7db6cb..e8498cff 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -22,7 +22,7 @@ workflows: '.github/workflows/changelog.yml': [] '.github/workflows/codeql-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' '.github/workflows/codeql.yml': [] '.github/workflows/debt-measure.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -50,7 +50,7 @@ workflows: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' - - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' '.github/workflows/hypatia-scan.yml': [] '.github/workflows/instant-sync.yml': - 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697' @@ -94,7 +94,7 @@ workflows: '.github/workflows/scorecard-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' - - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' '.github/workflows/secret-scanner-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -181,9 +181,9 @@ dependencies: commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' owner_id: 47606891 repo_id: 331103973 - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938': - ref: 'cdf488f595d80d6e07e03d4674febd5ab45fa938' - commit: 'sha1-cdf488f595d80d6e07e03d4674febd5ab45fa938' + 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': + ref: 'v4.38.0' + commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' owner_id: 9919 repo_id: 259445878 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406': From a85c0a1dd0b694f0a914a58203a00fbff0739861 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:17:51 +0100 Subject: [PATCH 3/5] fix(lock): onboard check-suite-monitor.yml, absent from the lockfile check-suite-monitor.yml had NO section in .github/workflows/actions.lock at all -- it was never onboarded, so actions/github-script@3a2844b7 and actions/checkout@3d3c42e5 were unlocked. Same failure class as the codeql-action desync in the previous commit, different cause: not a Dependabot bump that skipped the lock, but a workflow added without one. security-gate-pr-target.yml is the other github-script consumer and is ALSO unlocked, but it is deliberately left alone: it uses the dead hyperpolymath/a2ml-ecosystem/secrets-check-action, and onboarding it would key that action into the lock. A2ML is dead; the cure is to remove the consumer, not to lock it in. Same reasoning as denoland/setup-deno. Lockfile-only; the *.yml diff is empty (the tool's duplicate banner insertion was reverted). The actions/checkout dependency record gains ref: v7.0.1 in place of a bare SHA -- a symbolic-ref annotation, not a de-pin: the commit field is unchanged and no workflow ref moved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim --- .github/workflows/actions.lock | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index e8498cff..d2f30fb5 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -20,6 +20,9 @@ workflows: '.github/workflows/changelog-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/changelog.yml': [] + '.github/workflows/check-suite-monitor.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' '.github/workflows/codeql-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' @@ -113,7 +116,7 @@ dependencies: owner_id: 44036562 repo_id: 215566462 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': - ref: '3d3c42e5aac5ba805825da76410c181273ba90b1' + ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' owner_id: 44036562 repo_id: 197814629 @@ -137,6 +140,11 @@ dependencies: commit: 'sha1-3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' owner_id: 44036562 repo_id: 192626254 + 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3': + ref: 'v9.0.0' + commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3' + owner_id: 44036562 + repo_id: 205262760 'actions/setup-python@v2': ref: 'v2' commit: 'sha1-e9aba2c848f5ebd159c070c61ea2c4e2b122355e' From 75fea61bb9f31401c12a15edcac03fc2928fcade Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:24:43 +0100 Subject: [PATCH 4/5] feat(ci): gate actions.lock coverage in CI, not only pre-commit Adds .githooks/validate-actions-lock.sh (uses subset-of actions.lock) and wires it into BOTH .githooks/pre-commit and a new CI workflow. CI is the placement that matters. Every validator in .githooks ran only in pre-commit, and no workflow in this repo invoked any of them -- so Dependabot, which never runs pre-commit, bypassed the entire suite. That is precisely how the codeql-action bump reached three reusable workflows without the lockfile, poisoning every caller. A pre-commit hook cannot gate a bot. Why not `gh actions-lock --verify-local`, measured on v0.1.6: * It WRITES. It rewrote `uses: ./.github/actions/signed-push` to the invalid `uses: $/.github/actions/signed-push` -- a ref that kills the workflow at startup -- while in a mode its own help text calls read-only and "ideal for pre-commit hooks". * --no-migrate-local-actions stops that, but then the tool no longer descends into local composite actions and misreports their real dependencies as stale. * Its coverage is REPO-scoped, not SHA-exact: bumping ONE of a workflow's two refs to the same action leaves the old key still referenced by the other, so nothing is stale, nothing is missing, the check is green and the workflow is broken. That mutant survives the tool and dies here. Mutation-tested rather than merely run; 5 killed, 2 negative controls: M1 partial bump (the one the tool misses) rc=1 KILLED M2 full Dependabot-shaped bump rc=1 KILLED M3 new unlocked action added rc=1 KILLED M4 lockfile key deleted rc=1 KILLED M6 empty lockfile (parser control) rc=1 KILLED N1 case flip Swatinem -> SWATINEM rc=0 green N2 reusable-workflow ref bumped (out of scope) rc=0 green The script states its scope instead of implying it, counts what it checked so a silent collapse to zero is visible, and fails closed if it parses no keys -- an empty lockfile reports a PARSER failure, never "every ref is missing". Two refs are allow-listed as deliberately absent, in data with reasons, not by pattern: denoland/setup-deno (banned runtime -- remove the consumer, do not lock it in) and hyperpolymath/a2ml-ecosystem (dead). An allow-list entry no longer used is reported stale, so it cannot rot. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim --- .githooks/pre-commit | 1 + .githooks/validate-actions-lock.sh | 189 ++++++++++++++++++++++++ .github/workflows/actions-lock-gate.yml | 36 +++++ .github/workflows/actions.lock | 2 + 4 files changed, 228 insertions(+) create mode 100755 .githooks/validate-actions-lock.sh create mode 100644 .github/workflows/actions-lock-gate.yml diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 475355db..9a1c9e7f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -73,6 +73,7 @@ run_validator "SPDX headers" "validate-spdx.sh" "staged" # Workflow validation run_validator "Workflow SPDX headers" "validate-spdx-workflows.sh" "staged" run_validator "Workflow SHA-pinning" "validate-sha-pins.sh" "staged" +run_validator "Actions lockfile coverage" "validate-actions-lock.sh" "staged" run_validator "Workflow permissions" "validate-permissions.sh" "staged" run_validator "CodeQL configuration" "validate-codeql.sh" "staged" run_validator "Bot directives" "validate-bot-directives.sh" "staged" diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh new file mode 100755 index 00000000..0ee89b8b --- /dev/null +++ b/.githooks/validate-actions-lock.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Actions lockfile coverage: every SHA-pinned `uses:` ref in .github/workflows/ +# must have a matching entry in .github/workflows/actions.lock. +# +# WHY THIS EXISTS +# +# GitHub validates a reusable workflow against the CALLEE repo's own +# actions.lock. A lockfile that has drifted from its workflows therefore +# does not break this repo -- it breaks every repo that calls into it, with +# `failure` (not `startup_failure`), 0 jobs, and no reason in either the +# REST or the GraphQL payload; only the run page says why. actionlint +# passes either way. Dependabot bumps a `uses:` SHA without regenerating +# the lock, and Dependabot never runs pre-commit -- so CI is the only place +# this is caught before it reaches the callers. +# +# WHY NOT `gh actions-lock --verify-local`, measured 2026-09-15 on v0.1.6: +# +# * It WRITES. It rewrote `uses: ./.github/actions/signed-push` to the +# invalid `uses: $/.github/actions/signed-push` -- a ref that kills the +# workflow at startup -- while running in a mode whose own help text +# calls it read-only and "ideal for pre-commit hooks". +# --no-migrate-local-actions suppresses that, but then the tool stops +# descending into local composite actions and misreports their +# dependencies as stale. Safety and accuracy are in conflict there. +# * Its coverage is REPO-scoped, not SHA-exact. Bumping ONE of a workflow's +# two refs to the same action leaves the old lock key still referenced by +# the other: nothing reported stale, nothing reported missing, check +# green, workflow broken. That mutant survives it and dies here. +# +# SCOPE -- stated, not implied: +# * Only 40-hex SHA-pinned refs are checked. Tag refs (@v2, @main) are a +# different question, gated by validate-sha-pins.sh. +# * Reusable WORKFLOW refs (owner/repo/.github/workflows/x.yml@sha) are +# skipped: the lockfile models actions and keys no entry for them. +# * Local refs (./...) are skipped; they carry no SHA. +# * Sub-path actions normalise to their repo -- codeql-action/init@X is +# keyed once as codeql-action@X, never once per sub-path. +# * Comparison is case-insensitive: workflows say Swatinem/rust-cache while +# the lockfile stores swatinem/rust-cache. + +set -euo pipefail + +REPO_ROOT="${INPUT_PATH:-.}" +LOCKFILE="$REPO_ROOT/.github/workflows/actions.lock" +WORKFLOW_DIR="$REPO_ROOT/.github/workflows" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' + +# Refs deliberately absent from the lockfile. Each entry is an exact +# normalised owner/repo@sha plus the reason it is absent. This is an explicit +# list, never a pattern: anything not named here fails, so the list cannot +# quietly widen to cover an accident. An entry that stops being used is +# reported as stale, so it cannot rot either. +EXPECTED_ABSENT=( + # Estate doctrine is bun-only; deno is banned. Keying it would make a + # BANNED runtime a required lockfile key for every caller of + # governance-reusable.yml. The cure is to remove the consumer -- port the + # governance scripts to bun -- not to satisfy it. + "denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed" + # A2ML is dead. security-gate-pr-target.yml still calls its + # secrets-check-action; locking it in would connect new machinery to it. + "hyperpolymath/a2ml-ecosystem@f7a40a4d5cc82b2e73f861119baa6818d77a448d" +) + +if [ ! -f "$LOCKFILE" ]; then + echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE not found${NC}" >&2 + exit 1 +fi + +# An unreadable lockfile must abort, never silently pass. +if [ ! -r "$LOCKFILE" ]; then + echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE unreadable${NC}" >&2 + exit 1 +fi + +# Parse the lockfile's keys into an explicit set ONCE, rather than substring +# -matching against the whole file. A substring test over a blob fails open in +# ways that are hard to see: if the blob is empty or truncated, every ref is +# reported "missing" and the operator is told to regenerate a lockfile that +# was actually fine. An explicit set can be counted, and is counted below. +mapfile -t LOCK_KEYS < <( + grep -oE "'[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[0-9a-fA-F]{40}'" "$LOCKFILE" \ + | tr -d "'" | tr '[:upper:]' '[:lower:]' | sort -u +) + +# Positive control: the lockfile is known to key at least one action. If the +# parse yields nothing, the FILE FORMAT changed or the read failed -- say so, +# rather than reporting every ref in the repo as missing. +if [ "${#LOCK_KEYS[@]}" -eq 0 ]; then + echo -e "${RED}[validate-actions-lock] ERROR: parsed 0 keys from $LOCKFILE${NC}" >&2 + echo " The lockfile exists but no 'owner/repo@sha' keys were found." >&2 + echo " This is a parser/format failure, NOT a coverage failure." >&2 + exit 1 +fi + +lock_has() { + local needle="$1" k + for k in "${LOCK_KEYS[@]}"; do + [ "$k" = "$needle" ] && return 0 + done + return 1 +} + +ERRORS=0 +CHECKED=0 +declare -a SEEN_ABSENT=() + +# Collect every SHA-pinned uses: ref across all workflow files. +mapfile -t RAW < <( + grep -rhoE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^[:space:]#]+@[0-9a-fA-F]{40}' \ + "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml 2>/dev/null \ + | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//' \ + | sort -u +) + +# A zero-input pass is the classic fake green: if ref extraction ever breaks, +# this script would report success having checked nothing. If the lockfile +# already names workflows, finding no refs is a contradiction, not an empty +# repo -- fail instead of passing vacuously. The success line below also +# prints the number checked, so a silent collapse to near-zero is visible. +if [ "${#RAW[@]}" -eq 0 ]; then + if grep -qE "^ '\\.github/workflows/" "$LOCKFILE"; then + echo -e "${RED}[validate-actions-lock] ERROR: no SHA-pinned refs extracted, yet the lockfile names workflows -- extraction is broken${NC}" >&2 + exit 1 + fi + echo -e "${YELLOW}[validate-actions-lock] no SHA-pinned refs found -- nothing to check${NC}" + exit 0 +fi + +for ref in "${RAW[@]}"; do + case "$ref" in + ./*|.\\*) continue ;; # local action, carries no SHA + */.github/workflows/*) continue ;; # reusable workflow, not keyed + esac + case "$ref" in + *.yml@*|*.yaml@*) continue ;; # reusable workflow, any path + esac + + sha="${ref##*@}" + path="${ref%@*}" + owner="${path%%/*}" + rest="${path#*/}" + repo="${rest%%/*}" + [ -n "$owner" ] && [ -n "$repo" ] || continue + norm="$(printf '%s/%s@%s' "$owner" "$repo" "$sha" | tr '[:upper:]' '[:lower:]')" + + skip=0 + for ex in "${EXPECTED_ABSENT[@]}"; do + ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" + if [ "$norm" = "$ex_lc" ]; then + SEEN_ABSENT+=("$ex_lc") + skip=1 + break + fi + done + [ "$skip" -eq 1 ] && continue + + CHECKED=$((CHECKED + 1)) + if ! lock_has "$norm"; then + echo -e "${RED}[validate-actions-lock] ERROR: not in actions.lock: ${ref}${NC}" >&2 + echo " normalised to: $norm" >&2 + ERRORS=$((ERRORS + 1)) + fi +done + +# A doctrine exception that is no longer used must be removed, or the list +# becomes a place where real coverage gaps can hide. +for ex in "${EXPECTED_ABSENT[@]}"; do + ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" + found=0 + for s in ${SEEN_ABSENT[@]+"${SEEN_ABSENT[@]}"}; do + [ "$s" = "$ex_lc" ] && found=1 && break + done + if [ "$found" -eq 0 ]; then + echo -e "${YELLOW}[validate-actions-lock] WARNING: stale exception, no workflow uses ${ex} -- remove it from EXPECTED_ABSENT${NC}" >&2 + fi +done + +if [ "$ERRORS" -gt 0 ]; then + echo -e "${RED}[validate-actions-lock] ${ERRORS} ref(s) missing from the lockfile${NC}" >&2 + echo " Regenerate with the LOCKFILE ONLY, and verify the *.yml diff is empty:" >&2 + echo " gh actions-lock --no-migrate-local-actions --no-narrow" >&2 + echo " git diff --stat -- '.github/workflows/*.yml' # MUST be empty" >&2 + exit 1 +fi + +echo -e "${GREEN}[validate-actions-lock] ✅ ${CHECKED} SHA-pinned ref(s) found among ${#LOCK_KEYS[@]} lockfile keys, ${#EXPECTED_ABSENT[@]} doctrine exception(s)${NC}" +exit 0 diff --git a/.github/workflows/actions-lock-gate.yml b/.github/workflows/actions-lock-gate.yml new file mode 100644 index 00000000..34c5d266 --- /dev/null +++ b/.github/workflows/actions-lock-gate.yml @@ -0,0 +1,36 @@ +# This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 +# Fail closed when .github/workflows/actions.lock drifts from the workflows. +# +# This repo's .githooks validators ran ONLY in pre-commit, and Dependabot +# never runs pre-commit -- which is exactly how a codeql-action bump landed +# in three reusable workflows without regenerating the lockfile, poisoning +# every repo that calls them. A pre-commit hook cannot gate a bot; this can. +name: Actions Lockfile Gate + +on: + pull_request: + paths: + - '.github/workflows/**' + - '.githooks/validate-actions-lock.sh' + push: + branches: [main] + paths: + - '.github/workflows/**' + - '.githooks/validate-actions-lock.sh' + workflow_dispatch: + +permissions: + contents: read + +jobs: + lockfile-coverage: + name: uses ⊆ actions.lock + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: "Every SHA-pinned uses: must be in the lockfile" + run: bash .githooks/validate-actions-lock.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index d2f30fb5..5abc4ad7 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -3,6 +3,8 @@ # Docs: https://gh.io/actions-lockfile version: 'v0.0.2' workflows: + '.github/workflows/actions-lock-gate.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/affinescript-verify.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'ocaml/setup-ocaml@e89b2ded52a6e13f50162220cf5fe47290162032' From 43081fd10b2715f34f90b1ca88430b2a5b7578b5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:38:01 +0100 Subject: [PATCH 5/5] fix(ci): make the lockfile gate requirable, and scan composite actions Two defects in the gate as first landed. 1. The `paths:` filter makes it unrequirable. A required check whose workflow is skipped by path filtering never reports a conclusion, so the PR sits on "Expected -- waiting for status" indefinitely: every PR that does not touch .github/** becomes permanently unmergeable. The gate is worth nothing until it is required, and it cannot be required with the filter on. The job is a checkout plus a few seconds of bash; it now runs on every PR and every push to main. 2. The validator scanned only .github/workflows/. The two dependencies of the local composite action .github/actions/signed-push/action.yml are keyed in the lockfile, and Dependabot bumps them like any other ref -- so a bump there drifted the lock with the gate looking the other way. Scanning .github/actions/*/action.yml closes that: the ref count goes 21 to 23, and a mutant bumping push-signed-commits inside the composite action now dies (it survived before). Also states the gate's own limit in its header: membership is global, not per-workflow-section, so a workflow carrying SHA-pinned refs but no lock section of its own is not detected here. No `uses:` ref moved, so actions.lock is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim --- .githooks/validate-actions-lock.sh | 15 +++++++++++++-- .github/workflows/actions-lock-gate.yml | 11 +++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh index 0ee89b8b..d6699726 100755 --- a/.githooks/validate-actions-lock.sh +++ b/.githooks/validate-actions-lock.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 # Actions lockfile coverage: every SHA-pinned `uses:` ref in .github/workflows/ -# must have a matching entry in .github/workflows/actions.lock. +# AND in .github/actions/*/action.yml must have a matching entry in +# .github/workflows/actions.lock. # # WHY THIS EXISTS # @@ -38,12 +39,21 @@ # keyed once as codeql-action@X, never once per sub-path. # * Comparison is case-insensitive: workflows say Swatinem/rust-cache while # the lockfile stores swatinem/rust-cache. +# * Local COMPOSITE actions (.github/actions/*/action.yml) are scanned too. +# Their deps are keyed in the lock under the workflow that uses them, and +# Dependabot bumps them like any other ref; scanning only the workflow +# directory leaves that drift invisible. +# * Membership is GLOBAL, not per-workflow-section. A ref present in some +# other workflow's lock section satisfies this check. A workflow with +# SHA-pinned refs but no lock section of its own is therefore NOT +# detected here -- see the PR notes. set -euo pipefail REPO_ROOT="${INPUT_PATH:-.}" LOCKFILE="$REPO_ROOT/.github/workflows/actions.lock" WORKFLOW_DIR="$REPO_ROOT/.github/workflows" +ACTIONS_DIR="$REPO_ROOT/.github/actions" RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' @@ -109,7 +119,8 @@ declare -a SEEN_ABSENT=() # Collect every SHA-pinned uses: ref across all workflow files. mapfile -t RAW < <( grep -rhoE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^[:space:]#]+@[0-9a-fA-F]{40}' \ - "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml 2>/dev/null \ + "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \ + "$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml 2>/dev/null \ | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//' \ | sort -u ) diff --git a/.github/workflows/actions-lock-gate.yml b/.github/workflows/actions-lock-gate.yml index 34c5d266..ea4c6498 100644 --- a/.github/workflows/actions-lock-gate.yml +++ b/.github/workflows/actions-lock-gate.yml @@ -8,16 +8,15 @@ # every repo that calls them. A pre-commit hook cannot gate a bot; this can. name: Actions Lockfile Gate +# NO `paths:` FILTER, deliberately. This check is meant to be REQUIRED, and a +# required check whose workflow is skipped by path filtering never reports: +# the PR sits on "Expected -- waiting for status" forever. Every PR that does +# not touch .github/** would be permanently unmergeable. The job is a +# checkout plus a few seconds of bash, so it runs on everything. on: pull_request: - paths: - - '.github/workflows/**' - - '.githooks/validate-actions-lock.sh' push: branches: [main] - paths: - - '.github/workflows/**' - - '.githooks/validate-actions-lock.sh' workflow_dispatch: permissions: