Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 39 additions & 2 deletions scripts/close-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down
23 changes: 21 additions & 2 deletions scripts/lock-selfcheck.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}"
Expand All @@ -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
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions scripts/tests/close-lock-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
# 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
21 changes: 21 additions & 0 deletions scripts/tests/fixtures/actions-lock-closure/closed-v0.0.2.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
# 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
21 changes: 21 additions & 0 deletions scripts/tests/fixtures/actions-lock-closure/closed-v0.0.3.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
# 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
# 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'
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
# 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
23 changes: 23 additions & 0 deletions scripts/tests/fixtures/actions-lock-closure/expected.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
#
# 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
24 changes: 24 additions & 0 deletions scripts/tests/lock-selfcheck-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<EOF
version: 'v0.0.2'
workflows:
'.github/workflows/ci.yml':
- 'actions/checkout@$LOCKED'
dependencies:
'actions/checkout@$LOCKED':
ref: 'v7.0.1'
commit: 'sha1-$LOCKED'
owner_id: 1
repo_id: 2
EOF

pass=0 fail=0
Expand Down Expand Up @@ -143,6 +151,22 @@ EOF
)
expect "symbolic tag ref in dependencies is SELF-CONSISTENT" "$sha" 0 "VERDICT: SELF-CONSISTENT"

# The standards#963 shape: a record's nested uses: names a ref that has no
# record of its own. Every workflow ref is keyed, so only closure catches it.
cat >> "$WF/actions.lock" <<EOF
uses:
- 'actions/setup-python@v2'
EOF
sha=$(commit_ci <<EOF
jobs:
a:
steps:
- uses: actions/checkout@$LOCKED # v7.0.1
EOF
)
expect "a dangling nested edge is POISON (standards#963)" "$sha" 1 "dangling edge(s) in this SHA's own lock"
expect "the dangling ref is named" "$sha" 1 "actions/setup-python@v2"

echo
echo "lock-selfcheck: $pass passed, $fail failed"
[ "$fail" -eq 0 ]
Loading