diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3f689709..ee91e805 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -120,6 +120,22 @@ 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" +# Lockfile closure (standards#963): every ref the staged actions.lock names +# needs a top-level record of its own. Runs where the repository carries +# scripts/close-lock.sh, like the canonical-names guard below. +LOCK_REL=".github/workflows/actions.lock" +if [[ -f "$REPO_ROOT/scripts/close-lock.sh" ]] \ + && git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -qxF "$LOCK_REL"; then + echo -e "${BLUE}[pre-commit]${NC} Checking actions.lock closure..." + STAGED_LOCK="$(mktemp)" + if ! git show ":$LOCK_REL" > "$STAGED_LOCK" 2>/dev/null \ + || ! bash "$REPO_ROOT/scripts/close-lock.sh" --check "$STAGED_LOCK"; then + echo -e "${RED}[pre-commit] staged actions.lock is not closed (see above)${NC}" >&2 + ERRORS=$((ERRORS + 1)) + fi + rm -f "$STAGED_LOCK" +fi + # Registry drift guard -- REMOVED 2026-09-15 (owner ruling R-H3). # It demanded that every commit stage .machine_readable/REGISTRY.a2ml, a generated # TOML-shaped artefact -- i.e. a specimen of the record dialect the owner ruled diff --git a/scripts/close-lock.sh b/scripts/close-lock.sh index 9ce855ca..1025227a 100755 --- a/scripts/close-lock.sh +++ b/scripts/close-lock.sh @@ -41,12 +41,27 @@ # `{"message"…` would be a present-but-unresolvable entry, which is fatal where # an absent one is merely dangling. A ref that cannot be resolved is SKIPPED, # reported, and makes the exit 3. +# CHECK MODE: `close-lock.sh --check [DIR | LOCKFILE]` reports the dangling +# edges the passes below would close, and does nothing else: no network, no +# writes. Exit 0 when the lock is closed, 1 when any edge dangles, 2 when there +# is no lock to read. Owner/repo compare case-insensitively, as GitHub's +# lockfile parser canonicalises them (#1294, #1314); refs compare exactly. +# Hypatia reports the same defect as invalid_actions_lock (#963). Run by +# lock-selfcheck.sh and the pre-commit hook; its contract is the corpus in +# scripts/tests/fixtures/actions-lock-closure/. +# # GH_BIN overrides the `gh` binary (the test suite uses an offline stub). set -euo pipefail +CHECK=0 +if [[ "${1:-}" == "--check" ]]; then CHECK=1; shift; fi WF="${1:-.github/workflows}" -L="$WF/actions.lock" +if [[ "$CHECK" -eq 1 && -f "$WF" ]]; then L="$WF"; else L="$WF/actions.lock"; fi GH_BIN="${GH_BIN:-gh}" -[ -f "$L" ] || { echo "close-lock: no $L" >&2; exit 1; } +if [[ ! -f "$L" ]]; then + echo "close-lock: no $L" >&2 + [[ "$CHECK" -eq 1 ]] && exit 2 + exit 1 +fi SC="$(mktemp -d)"; trap 'rm -rf "$SC"' EXIT TAB="$(printf '\t')" # keys: list the dependency record keys already present in the lock. @@ -80,6 +95,28 @@ reusable_calls() { } # keyfile KEY: a file name for KEY's owed nested refs. keyfile() { printf '%s' "$1" | sha256sum | cut -c1-32; } +# fold: lowercase the owner/repo part of each owner/repo@ref on stdin. +fold() { awk '{ at = index($0, "@"); if (at) print tolower(substr($0, 1, at - 1)) substr($0, at); else print tolower($0) }'; } + +if [[ "$CHECK" -eq 1 ]]; then + keys | fold | LC_ALL=C sort -u > "$SC/have" + { nested; wfrefs; } | LC_ALL=C sort -u > "$SC/want" + : > "$SC/miss" + while IFS= read -r k; do + [[ -n "$k" ]] || continue + grep -qxF "$(printf '%s\n' "$k" | fold)" "$SC/have" || printf '%s\n' "$k" >> "$SC/miss" + done < "$SC/want" + n=$(wc -l < "$SC/miss") + if [[ "$n" -eq 0 ]]; then + echo "close-lock: closed (no dangling edges) in $L" + exit 0 + fi + echo "close-lock: NOT closed: $n dangling edge(s) in $L" + sed 's/^/ /' "$SC/miss" + echo " Each is named under workflows: or in a record's nested uses: and has no" + echo " top-level record of its own. Repair: bash scripts/close-lock.sh (needs gh)." + exit 1 +fi # ── reusable workflows: read each called file once, transitively ───────────── mkdir -p "$SC/u"; : > "$SC/read"; : > "$SC/bad"; : > "$SC/more" diff --git a/scripts/lock-selfcheck.sh b/scripts/lock-selfcheck.sh index b6f9b1a8..89f12d8c 100755 --- a/scripts/lock-selfcheck.sh +++ b/scripts/lock-selfcheck.sh @@ -31,6 +31,10 @@ # Reads git objects ONLY (`git show`); never checks out, fetches, or writes. # Safe against a checkout shared with a live concurrent writer. # +# The lock at each SHA must also be CLOSED: every ref under workflows: and in a +# record's nested uses: has a top-level record (`close-lock.sh --check`, +# standards#963). A dangling edge is a third POISON verdict. +# # 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 @@ -55,6 +59,7 @@ if ! git -C "$STANDARDS_DIR" rev-parse --git-dir >/dev/null 2>&1; then exit 2 fi +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" TMP="${TMPDIR:-/tmp}/lock-selfcheck.$$" mkdir -p "$TMP" trap 'rm -rf "$TMP"' EXIT @@ -224,6 +229,14 @@ for SHA in "$@"; do n_self_sha=$(sort -u "$TMP/self_sha_used" 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) + # Closure (standards#963): close-lock.sh --check exits 1 and lists, two-space + # indented, every ref the lock names without a top-level record of its own. + n_dangling=0 + if ! bash "$SCRIPT_DIR/close-lock.sh" --check "$TMP/lock" > "$TMP/closure" 2>&1; then + n_dangling=$(/usr/bin/grep -c '^ [^ ][^ ]*@[^ ]*$' "$TMP/closure" || true) + # A failure that names no ref still fails: an unread lock is not a closed one. + [[ "$n_dangling" -gt 0 ]] || n_dangling=1 + fi 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}" @@ -237,8 +250,8 @@ for SHA in "$@"; do echo " VERDICT: UNEXAMINED — yq could not parse $(sort -u "$TMP/unparsed" | wc -l) workflow(s); their refs were not checked:" sort -u "$TMP/unparsed" | sed 's/^/ /' overall_rc=1 - elif [ "$n_missing" -eq 0 ] && [ "$n_self_sha" -eq 0 ]; then - echo " VERDICT: SELF-CONSISTENT — every action ref used is keyed in this SHA's own lock." + elif [[ "$n_missing" -eq 0 && "$n_self_sha" -eq 0 && "$n_dangling" -eq 0 ]]; then + echo " VERDICT: SELF-CONSISTENT — every action ref used is keyed in this SHA's own lock, and the lock is closed." echo " (Reachability at the remote is NOT proven by this check.)" else if [ "$n_missing" -gt 0 ]; then @@ -259,6 +272,12 @@ for SHA in "$@"; do echo " self-SHA ref ${key}" done fi + if [[ "$n_dangling" -gt 0 ]]; then + echo " VERDICT: POISON — ${n_dangling} dangling edge(s) in this SHA's own lock (standards#963)." + echo " Hypatia fails closed on it (invalid_actions_lock), and close-lock.sh records" + echo " GitHub refusing runs at startup for this shape (cicd-squabbler#101)." + sed 's/^/ /' "$TMP/closure" + fi overall_rc=1 fi done diff --git a/scripts/tests/close-lock-test.sh b/scripts/tests/close-lock-test.sh index fe6d6500..257e60fa 100755 --- a/scripts/tests/close-lock-test.sh +++ b/scripts/tests/close-lock-test.sh @@ -261,5 +261,29 @@ mkdir -p "$WORK/t7/.github/workflows" (cd "$WORK/t7" && GH_BIN="$WORK/gh" bash "$TARGET") >/dev/null 2>&1; st=$? [ "$st" = 1 ] && ok "no lockfile → exit 1" || bad "no lockfile → exit 1 (got $st)" +echo "=== --check: the closure corpus (#963 criterion 5) ===" +# expected.tsv is the contract Hypatia's ActionsLock.parse/1 also runs against +# (hyperpolymath/hypatia vendors a byte-identical copy). Each row: the exit +# code, and exactly the dangling refs the lock names, in the lock's spelling. +CORPUS="$SCRIPT_DIR/fixtures/actions-lock-closure" +printf '#!/usr/bin/env bash\necho "$*" >> "%s/gh-calls"; exit 1\n' "$WORK" > "$WORK/gh-trap" +chmod +x "$WORK/gh-trap"; : > "$WORK/gh-calls" +rows=0 +while IFS=$'\t' read -r file verdict dangling; do + case "$file" in ''|'#'*) continue ;; esac + rows=$((rows + 1)) + lock="$CORPUS/$file"; before=$(sha256sum < "$lock") + out=$(GH_BIN="$WORK/gh-trap" bash "$TARGET" --check "$lock" 2>&1); st=$? + got=$(sed -n 's/^ \([^ ][^ ]*@[^ ]*\)$/\1/p' <<<"$out" | LC_ALL=C sort | tr '\n' ' ' | sed 's/ $//') + want=$([[ "$dangling" == "-" ]] || printf '%s\n' $dangling | LC_ALL=C sort | tr '\n' ' ' | sed 's/ $//') + if [[ "$verdict" == "closed" ]]; then exp=0; else exp=1; fi + [[ "$st" == "$exp" && "$got" == "$want" && "$(sha256sum < "$lock")" == "$before" ]] \ + && ok "$file: $verdict${want:+ ($want)}" || bad "$file: want exit $exp [$want], got exit $st [$got] — $out" +done < "$CORPUS/expected.tsv" +[[ "$rows" -ge 5 ]] && ok "corpus has $rows rows" || bad "corpus has only $rows rows" +[[ ! -s "$WORK/gh-calls" ]] && ok "--check made no gh call" || bad "--check called gh: $(cat "$WORK/gh-calls")" +GH_BIN="$WORK/gh-trap" bash "$TARGET" --check "$WORK/no-such.lock" >/dev/null 2>&1; st=$? +[[ "$st" == 2 ]] && ok "--check with no lock → exit 2" || bad "--check with no lock → exit 2 (got $st)" + echo "close-lock-test: $pass passed, $fail failed" [ "$fail" -eq 0 ] diff --git a/scripts/tests/fixtures/actions-lock-closure/closed-case-variant.lock b/scripts/tests/fixtures/actions-lock-closure/closed-case-variant.lock new file mode 100644 index 00000000..6eee99b4 --- /dev/null +++ b/scripts/tests/fixtures/actions-lock-closure/closed-case-variant.lock @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: closed: owner/repo spelled in another case than its record (GitHub canonicalises owner/repo). Contract: expected.tsv in this directory. +version: 'v0.0.3' +workflows: + '.github/workflows/rust.yml': + - 'Example/Rust-Cache@5555555555555555555555555555555555555555' +dependencies: + 'example/rust-cache@5555555555555555555555555555555555555555': + ref: 'v2.9.2' + commit: 'sha1-5555555555555555555555555555555555555555' + owner_id: 101 + repo_id: 205 diff --git a/scripts/tests/fixtures/actions-lock-closure/closed-v0.0.2.lock b/scripts/tests/fixtures/actions-lock-closure/closed-v0.0.2.lock new file mode 100644 index 00000000..5a2d04c0 --- /dev/null +++ b/scripts/tests/fixtures/actions-lock-closure/closed-v0.0.2.lock @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: closed, format v0.0.2. Contract: expected.tsv in this directory. +version: 'v0.0.2' +workflows: + '.github/workflows/ci.yml': + - 'example/build@1111111111111111111111111111111111111111' + '.github/workflows/empty.yml': [] +dependencies: + 'example/build@1111111111111111111111111111111111111111': + ref: 'v1.0.0' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 101 + repo_id: 201 + uses: + - 'example/setup@2222222222222222222222222222222222222222' + 'example/setup@2222222222222222222222222222222222222222': + ref: 'v2.0.0' + commit: 'sha1-2222222222222222222222222222222222222222' + owner_id: 101 + repo_id: 202 diff --git a/scripts/tests/fixtures/actions-lock-closure/closed-v0.0.3.lock b/scripts/tests/fixtures/actions-lock-closure/closed-v0.0.3.lock new file mode 100644 index 00000000..381077f1 --- /dev/null +++ b/scripts/tests/fixtures/actions-lock-closure/closed-v0.0.3.lock @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: closed, format v0.0.3 with the optional hostname field. Contract: expected.tsv in this directory. +version: 'v0.0.3' +workflows: + '.github/workflows/ci.yml': + - 'example/build@1111111111111111111111111111111111111111' +dependencies: + 'example/build@1111111111111111111111111111111111111111': + ref: 'v1.0.0' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 101 + repo_id: 201 + hostname: 'github.com' + uses: + - 'example/setup@2222222222222222222222222222222222222222' + 'example/setup@2222222222222222222222222222222222222222': + ref: 'v2.0.0' + commit: 'sha1-2222222222222222222222222222222222222222' + owner_id: 101 + repo_id: 202 diff --git a/scripts/tests/fixtures/actions-lock-closure/dangling-nested.lock b/scripts/tests/fixtures/actions-lock-closure/dangling-nested.lock new file mode 100644 index 00000000..1b4fb80d --- /dev/null +++ b/scripts/tests/fixtures/actions-lock-closure/dangling-nested.lock @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: dangling: a record's nested uses: names a ref with no record (the standards#963 shape). Contract: expected.tsv in this directory. +version: 'v0.0.2' +workflows: + '.github/workflows/push.yml': + - 'example/push-signed@3333333333333333333333333333333333333333' +dependencies: + 'example/push-signed@3333333333333333333333333333333333333333': + ref: 'v1.3' + commit: 'sha1-3333333333333333333333333333333333333333' + owner_id: 101 + repo_id: 203 + uses: + - 'example/setup-python@v2' diff --git a/scripts/tests/fixtures/actions-lock-closure/dangling-workflow-ref.lock b/scripts/tests/fixtures/actions-lock-closure/dangling-workflow-ref.lock new file mode 100644 index 00000000..3bae2099 --- /dev/null +++ b/scripts/tests/fixtures/actions-lock-closure/dangling-workflow-ref.lock @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: dangling: a ref under workflows: with no record. Contract: expected.tsv in this directory. +version: 'v0.0.3' +workflows: + '.github/workflows/release.yml': + - 'example/build@1111111111111111111111111111111111111111' + - 'example/generator@4444444444444444444444444444444444444444' +dependencies: + 'example/build@1111111111111111111111111111111111111111': + ref: 'v1.0.0' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 101 + repo_id: 201 diff --git a/scripts/tests/fixtures/actions-lock-closure/expected.tsv b/scripts/tests/fixtures/actions-lock-closure/expected.tsv new file mode 100644 index 00000000..9e07a443 --- /dev/null +++ b/scripts/tests/fixtures/actions-lock-closure/expected.tsv @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Closure corpus for actions.lock (standards#963, criterion 5). Each lock is +# CLOSED when every ref under workflows: and every ref in a record's nested +# uses: has a top-level dependency record, comparing owner/repo +# case-insensitively and the ref exactly (GitHub's lockfile parser +# canonicalises owner/repo case; see #1294 and #1314). Otherwise it is +# DANGLING, and the third column lists the dangling refs as the lock spells +# them, space-separated. +# +# Two implementations must agree with this table: +# * scripts/close-lock.sh --check (scripts/tests/close-lock-test.sh) +# * Hypatia.Rules.ActionsLock.parse/1 in hyperpolymath/hypatia, which +# vendors a byte-identical copy of this directory. +# A third, GitHub's runner, is the reason the table exists. +# +# file verdict dangling +closed-v0.0.2.lock closed - +closed-v0.0.3.lock closed - +closed-case-variant.lock closed - +dangling-nested.lock dangling example/setup-python@v2 +dangling-workflow-ref.lock dangling example/generator@4444444444444444444444444444444444444444 diff --git a/scripts/tests/lock-selfcheck-test.sh b/scripts/tests/lock-selfcheck-test.sh index 4a143d1d..0cd06cdc 100755 --- a/scripts/tests/lock-selfcheck-test.sh +++ b/scripts/tests/lock-selfcheck-test.sh @@ -28,11 +28,19 @@ git -C "$REPO" init -q -b main . git -C "$REPO" config user.email t@example.com git -C "$REPO" config user.name T git -C "$REPO" config commit.gpgsign false +# A closed lock: the workflow ref has its own top-level record, as every lock +# GitHub and Hypatia accept must (standards#963). cat > "$WF/actions.lock" <> "$WF/actions.lock" <