From ab2d5f80961c3c4b961580c9a72fced7862e4ba1 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:49:24 +0100 Subject: [PATCH] ci(lock): gate every commit on its own actions.lock self-consistency A caller writes `uses: hyperpolymath/standards/.github/workflows/x.yml@`. GitHub resolves that at workflow STARTUP and validates the callee against the CALLEE repo's own `.github/workflows/actions.lock` AS IT EXISTS AT THAT SHA. If a reusable here names an action ref the lock at that SHA does not key, every caller pinned there dies before a single job exists. That death is nearly invisible. The run's conclusion is `failure`, NOT `startup_failure`; it has ZERO jobs; its `name` equals its `path`; and neither REST nor GraphQL carries a reason -- only the run page states one. A required context whose workflow dies this way never reports at all, so the gate reads as ABSENT rather than failing and the branch looks clean. That is how this class stays unnoticed while it spreads. Capability is therefore NOT monotonic in time, and it OSCILLATES. Measured across the 84 commits da2c748a..317101e0: 43 POISON / 41 SELF-CONSISTENT, alternating, because Dependabot bumps a `uses:` inside a reusable without regenerating the lock in the same commit. "Pin to HEAD" is the wrong reflex -- the current tip is itself poison. This gate makes `main` safe to pin to by construction rather than by luck. Contents: scripts/lock-selfcheck.sh Answers one question: is a given commit safe to pin a caller to? Reads git objects only (`git show`), never checks out, fetches or writes, so it is safe against a checkout shared with a live writer. Exit 0 all examined SHAs self-validate, 1 any POISON, 2 misuse. It distinguishes three states that are easy to conflate: POISON (the workflow is keyed but this ref is not listed -- startup-fatal), ABSENT (the workflow has no lock entry at all -- untidy, not fatal), and NO-LOCK (the commit pre-dates the lock regime -- not a defect). Only POISON fails. It compares case-insensitively, because the lockfile lowercases the owner/repo it keys while workflows carry upstream casing, and it normalises subpaths, because `github/codeql-action/upload-sarif@SHA` is keyed as `github/codeql-action@SHA`. Both of those produced false verdicts before they were fixed. .github/workflows/lock-selfcheck.yml Runs it against the commit under test. Deliberately carries NO `paths:` filter: this check is meant to be REQUIRED, and a required check that filters itself off does not report as passing -- it does not report at all, leaving the PR blocked on a context that can never arrive. Also repins three stale lock entries this check flags. codeql-reusable.yml, hypatia-scan-reusable.yml and scorecard-reusable.yml all use github/codeql-action@b96794f0 (v4.38.0, 2026-09-09) while the lock still keyed @cdf488f5 (v4.37.9, 2026-08-26). The workflows are newer and the lock is stale, so the lock is what moves: @cdf488f5 appears 6 times in the lock and is used by ZERO workflow files. The three other stale entries are left untouched on purpose -- they belong to workflows that call no codeql, so widening the diff would buy nothing. KNOWN RED, AND CORRECTLY SO: this gate still fails on a fourth ref. governance-reusable.yml uses denoland/setup-deno@22d081ff, which is both unkeyed and a banned runtime. It is not keyed here deliberately -- keying it would make a banned runtime a required lock key for every caller in the estate. Nor is the job deleted: its name is a required context estate-wide. The step is load-bearing (line 383 runs `deno run` against scripts/check-ts-allowlist.deno.js, which is 360 lines of compiled output from an AffineScript source), so removing it is a compiler-target change, not a text edit. That is task #15. Until #15 lands, this gate reports the deno ref red, and that red is accurate: a caller pinned at such a commit really does die. Landing this advisory-first and marking it required after #15 is a reasonable sequence; so is fixing #15 first. That ordering is an owner call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WgqXnnNWBkiKMyUeLqzcuN --- .github/workflows/actions.lock | 8 +- .github/workflows/lock-selfcheck.yml | 61 ++++++++ scripts/lock-selfcheck.sh | 221 +++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/lock-selfcheck.yml create mode 100755 scripts/lock-selfcheck.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 5a7db6cb5..d91456e50 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' @@ -63,6 +63,8 @@ workflows: '.github/workflows/lockfile-drift-detect.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + '.github/workflows/lock-selfcheck.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/makefile-blocker.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/mirror-reusable.yml': @@ -94,7 +96,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' diff --git a/.github/workflows/lock-selfcheck.yml b/.github/workflows/lock-selfcheck.yml new file mode 100644 index 000000000..f7729c8d8 --- /dev/null +++ b/.github/workflows/lock-selfcheck.yml @@ -0,0 +1,61 @@ +# This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 +# lock-selfcheck.yml — is THIS commit safe for a caller to pin to? +# +# `uses: hyperpolymath/standards/.github/workflows/x.yml@` is resolved at +# workflow STARTUP, and GitHub validates the callee against the CALLEE repo's +# own `.github/workflows/actions.lock` AS IT EXISTS AT THAT SHA. If a reusable +# here names an action ref that the lock at that SHA does not key, then every +# caller in the estate pinned at that SHA dies before a single job exists. +# +# That death is close to invisible: conclusion `failure` (NOT +# `startup_failure`), ZERO jobs, run `name` equal to the run `path`, and +# neither REST nor GraphQL carries a reason — only the run page states one. A +# required context whose workflow dies this way never reports at all, so the +# gate reads as ABSENT rather than failing and the branch looks clean. +# +# Capability is therefore NOT monotonic in time, and it OSCILLATES: measured +# across the 84 commits da2c748a..317101e0, 43 were POISON and 41 +# SELF-CONSISTENT, alternating, because Dependabot bumps a `uses:` inside a +# reusable without regenerating the lock in the same commit. "Pin to HEAD" is +# the wrong reflex. This gate exists so that a poisoned commit cannot reach +# main unannounced, and so `main` is a safe thing to pin to by construction. +# +# SCOPE: this proves INTERNAL CONSISTENCY of the commit under test. It does +# NOT prove the commit is reachable at the remote — that is a separate probe. +name: Lock Self-Check + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# Deliberately NO `paths:` filter. This check is intended to be REQUIRED, and +# a required check that filters itself off does not report as passing — it +# does not report at all, which leaves the PR blocked on a context that can +# never arrive. + +permissions: + contents: read + +jobs: + selfcheck: + name: Lockfile self-consistency + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout the commit under test + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check this commit validates against its own actions.lock + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + # State the population. A gate that does not say what it examined + # cannot report that it examined the wrong thing. + echo "Commit under test: $sha" + echo "Event: ${{ github.event_name }}" + # On `pull_request` this is the MERGE PREVIEW, i.e. the content that + # would land on main, which is exactly the content callers will pin. + bash scripts/lock-selfcheck.sh "$sha" diff --git a/scripts/lock-selfcheck.sh b/scripts/lock-selfcheck.sh new file mode 100755 index 000000000..9380078b8 --- /dev/null +++ b/scripts/lock-selfcheck.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# lock-selfcheck.sh — is a given `standards` commit SAFE TO PIN A CALLER TO? +# +# WHY THIS EXISTS +# --------------- +# `uses: org/repo/.github/workflows/x.yml@` is resolved at workflow +# STARTUP, and GitHub validates the callee against **the callee repo's own +# `.github/workflows/actions.lock` AS IT EXISTS AT THAT SHA**. If the reusable +# names an action ref the lock at that same SHA does not key, every caller dies +# before any job exists — and the failure is near-invisible: +# +# * conclusion is `failure`, NOT `startup_failure` +# * the run has ZERO jobs +# * the run's `name` equals its `path` +# * neither REST nor GraphQL carries a reason — only the run PAGE does +# +# Dependabot causes this routinely: it bumps a `uses:` inside a workflow and +# never regenerates the lockfile. So **capability is not monotonic in time** — +# the newest `standards` commit can be strictly less usable than an older one, +# and "bump every caller to HEAD" is exactly the wrong reflex. +# +# Before a pin sweep repairs N callers onto a target SHA, that SHA must pass +# this check. Otherwise the sweep converts N silently-ungated repos into N +# loudly-broken ones. +# +# USAGE +# lock-selfcheck.sh [...] # uses $STANDARDS_DIR or default +# STANDARDS_DIR=/path/to/standards lock-selfcheck.sh +# +# Reads git objects ONLY (`git show`); never checks out, fetches, or writes. +# Safe against a checkout shared with a live concurrent writer. +# +# EXIT: 0 if every SHA examined self-validates; 1 if any does not; 2 on misuse. +# +# ⚠ SCOPE LIMIT, STATED SO IT IS NOT MISREAD: this proves a commit is +# INTERNALLY CONSISTENT. It does NOT prove the commit is REACHABLE at the +# remote. A local object store can hold commits GitHub has since orphaned +# (a garbage-collected commit is the general case). Reachability is a +# SEPARATE probe and must be made against the remote. +# +# SPDX-License-Identifier: MPL-2.0 + +set -uo pipefail + +STANDARDS_DIR="${STANDARDS_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +LOCK_PATH='.github/workflows/actions.lock' + +if [ "$#" -eq 0 ]; then + echo "usage: $0 [...]" >&2 + exit 2 +fi + +if ! git -C "$STANDARDS_DIR" rev-parse --git-dir >/dev/null 2>&1; then + echo "FATAL: not a git repository: $STANDARDS_DIR" >&2 + exit 2 +fi + +TMP="${TMPDIR:-/tmp}/lock-selfcheck.$$" +mkdir -p "$TMP" +trap 'rm -rf "$TMP"' EXIT + +# Normalise a `uses:` value to the key shape the lockfile uses: +# github/codeql-action/upload-sarif@SHA -> github/codeql-action@SHA +# actions/checkout@SHA -> actions/checkout@SHA +# Local refs (./...) and docker:// refs are not lockfile-keyed and are skipped +# by the caller, not here. +normalise_ref() { + printf '%s\n' "$1" | awk -F'@' '{ + n = split($1, p, "/") + owner_repo = (n >= 2) ? p[1] "/" p[2] : $1 + print owner_repo "@" $2 + }' +} + +overall_rc=0 + +for SHA in "$@"; do + echo "==============================================================" + if ! git -C "$STANDARDS_DIR" cat-file -e "${SHA}^{commit}" 2>/dev/null; then + echo "SHA ${SHA}: ABSENT from local object store — cannot check" + overall_rc=1 + continue + fi + SHORT=$(git -C "$STANDARDS_DIR" rev-parse --short=12 "$SHA") + WHEN=$(git -C "$STANDARDS_DIR" show -s --format='%ci' "$SHA") + SUBJ=$(git -C "$STANDARDS_DIR" show -s --format='%s' "$SHA") + echo "SHA ${SHORT} ${WHEN}" + echo " ${SUBJ}" + + if ! git -C "$STANDARDS_DIR" show "${SHA}:${LOCK_PATH}" > "$TMP/lock" 2>/dev/null; then + # THIRD STATE, and not the same as POISON. With no lockfile there is + # nothing for GitHub to validate the callee against, so a caller pinned + # here does NOT die at startup -- it simply gets no immutable-action + # guarantee. Predates the lock regime (which begins ~2026-08-03). + # Reporting this as unsafe-to-pin would be a false alarm, so it does not + # set the failure exit code. + echo " VERDICT: NO-LOCK — ${LOCK_PATH} does not exist at this SHA." + echo " NOT startup-fatal: with no lock there is nothing to" + echo " validate against. But callers pinned here get no" + echo " action-pinning guarantee either. Pre-dates the lock regime." + continue + fi + + # Parse the lock into "\t" pairs. + awk ' + /^workflows:/ { in_wf = 1; next } + !in_wf { next } + # A workflow key line: \x27.github/workflows/foo.yml\x27: + /^[[:space:]]+\x27[^\x27]+\x27:/ { + line = $0 + sub(/^[[:space:]]+\x27/, "", line) + sub(/\x27:.*$/, "", line) + cur = line + next + } + # An entry line: - \x27owner/repo@sha\x27 + /^[[:space:]]*-[[:space:]]*\x27[^\x27]+\x27/ { + line = $0 + sub(/^[[:space:]]*-[[:space:]]*\x27/, "", line) + sub(/\x27.*$/, "", line) + if (cur != "") print cur "\t" line + } + ' "$TMP/lock" | sort -u > "$TMP/locked" + + locked_pairs=$(wc -l < "$TMP/locked") + locked_wfs=$(cut -f1 "$TMP/locked" | sort -u | wc -l) + + # Enumerate the workflow files present at this SHA. + git -C "$STANDARDS_DIR" ls-tree -r --name-only "$SHA" -- .github/workflows \ + | /usr/bin/grep -E '\.ya?ml$' | sort > "$TMP/wfs" + n_wfs=$(wc -l < "$TMP/wfs") + + # The lockfile LOWERCASES the owner/repo it keys ('swatinem/rust-cache') + # while the workflow writes the upstream casing ('Swatinem/rust-cache'). + # Same action, same SHA. Comparing case-sensitively invents defects, so + # fold both sides before matching. (This cost one false 'POISON' on + # rust-ci-reusable.yml before it was caught.) + tr 'A-Z' 'a-z' < "$TMP/locked" > "$TMP/locked.lc" + + : > "$TMP/missing" + : > "$TMP/unkeyed_wf" + : > "$TMP/scanned" + + while IFS= read -r wf; do + [ -n "$wf" ] || continue + git -C "$STANDARDS_DIR" show "${SHA}:${wf}" 2>/dev/null > "$TMP/wfbody" || continue + # Extract every `uses:` value, strip inline comments and quotes. + /usr/bin/grep -hoE '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*[^[:space:]#]+' "$TMP/wfbody" \ + | sed -E 's/.*uses:[[:space:]]*//; s/^["\x27]//; s/["\x27]$//' \ + | while IFS= read -r ref; do + [ -n "$ref" ] || continue + case "$ref" in + ./*|docker://*) continue ;; # local / docker: not lockfile-keyed + esac + case "$ref" in + *@*) : ;; + *) continue ;; # unpinned (no @) — a different defect + esac + # A reusable-workflow call carries a .yml before the @; the lockfile + # keys ACTIONS, not reusable calls. Report those separately. + before="${ref%@*}" + case "$before" in + *.yml|*.yaml) printf '%s\t%s\tREUSABLE-CALL\n' "$wf" "$ref" >> "$TMP/scanned"; continue ;; + esac + key=$(normalise_ref "$ref") + printf '%s\t%s\tACTION\n' "$wf" "$key" >> "$TMP/scanned" + key_lc=$(printf '%s' "$key" | tr 'A-Z' 'a-z') + wf_lc=$(printf '%s' "$wf" | tr 'A-Z' 'a-z') + # Two DIFFERENT questions, deliberately not conflated: + # (1) this workflow has NO entry in the lock at all -> UNKEYED + # (2) this workflow IS keyed, but not for this ref -> MISSING + # Only (2) is proven to kill a caller at startup. (1) is reported + # separately because the lock also carries explicit ': []' entries, + # which proves absence and "uses nothing" are distinct states the + # generator can express -- so absence means the generator never saw + # the file, not that the file is forbidden actions. + if ! /usr/bin/grep -qF "$(printf '%s\t' "$wf_lc")" "$TMP/locked.lc"; then + printf '%s\t%s\n' "$wf" "$key" >> "$TMP/unkeyed_wf" + elif ! /usr/bin/grep -qF "$(printf '%s\t%s' "$wf_lc" "$key_lc")" "$TMP/locked.lc"; then + printf '%s\t%s\n' "$wf" "$key" >> "$TMP/missing" + fi + done + done < "$TMP/wfs" + + n_actions=$(/usr/bin/grep -c 'ACTION$' "$TMP/scanned" 2>/dev/null || echo 0) + n_reusable=$(/usr/bin/grep -c 'REUSABLE-CALL$' "$TMP/scanned" 2>/dev/null || echo 0) + n_missing=$(sort -u "$TMP/missing" 2>/dev/null | wc -l) + n_unkeyed=$(sort -u "$TMP/unkeyed_wf" 2>/dev/null | wc -l) + n_unkeyed_wf=$(cut -f1 "$TMP/unkeyed_wf" 2>/dev/null | sort -u | wc -l) + + echo " workflows at this SHA: ${n_wfs} lockfile keys: ${locked_wfs} workflow(s), ${locked_pairs} pair(s)" + echo " action refs used: ${n_actions} reusable-workflow calls (not lock-keyed): ${n_reusable}" + if [ "$n_unkeyed" -gt 0 ]; then + echo " note: ${n_unkeyed_wf} workflow(s) have NO lock entry at all (${n_unkeyed} ref(s)) —" + echo " a separate question from the verdict below, and not counted in it:" + cut -f1 "$TMP/unkeyed_wf" | sort -u | sed 's/^/ /' + fi + + if [ "$n_missing" -eq 0 ]; then + echo " VERDICT: SELF-CONSISTENT — every action ref used is keyed in this SHA's own lock." + echo " (Reachability at the remote is NOT proven by this check.)" + else + echo " VERDICT: POISON — ${n_missing} action ref(s) used but NOT keyed in this SHA's own lock." + echo " Any caller pinned here dies at startup with 0 jobs and no stated reason." + sort -u "$TMP/missing" | while IFS=$'\t' read -r wf key; do + echo " ${wf}" + echo " uses ${key}" + locked_for=$(/usr/bin/grep -F "$(printf '%s\t' "$wf")" "$TMP/locked" | cut -f2 | sort -u | tr '\n' ' ') + echo " lock keys for this workflow: ${locked_for:-}" + done + overall_rc=1 + fi +done + +echo "==============================================================" +if [ "$overall_rc" -eq 0 ]; then + echo "ALL EXAMINED SHAs SELF-VALIDATE (examined: $#)" +else + echo "AT LEAST ONE SHA IS UNSAFE TO PIN TO (examined: $#)" +fi +exit "$overall_rc"