From aa303a4d0df34177907aa515843bfae94fabcb48 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:30:41 +0000 Subject: [PATCH 1/2] feat(lock): check-only closure mode for actions.lock (#963) Owner decision on #963 criterion 5: Hypatia stays the authority for actions.lock closure. A deterministic local check guards the same property before push, and a shared corpus of test lockfiles binds the two implementations. scripts/close-lock.sh --check [DIR | LOCKFILE] Reports the dangling edges the repair passes would close, and nothing else: no gh call, no write. An edge dangles when a ref under workflows: or in a record's nested uses: has no top-level record of its own. Owner/repo compare case-insensitively and refs exactly, the same normalisation as GitHub's parser, #1294/#1314, and Hypatia's ActionsLock.normalise_ref/1. Exit 0 closed, 1 dangling, 2 no lock. scripts/lock-selfcheck.sh A dangling edge is a third POISON verdict, after unkeyed refs and self-SHA refs. It runs in the existing "Lockfile self-consistency" job, so no required check name changes. Only that job calls the script. .githooks/pre-commit When actions.lock is staged, the staged content is checked. Like the canonical-names guard, this runs only where the repository carries scripts/close-lock.sh. scripts/tests/fixtures/actions-lock-closure/ Five locks and expected.tsv: closed v0.0.2, closed v0.0.3 with hostname, closed case-variant, the #963 nested shape, and a dangling workflows: ref. Hypatia at aa34a4cc agrees on all five: ok, ok, ok, transitive_dependencies_missing, workflow_dependencies_missing. hyperpolymath/hypatia vendors a byte-identical copy for its own test. Tests close-lock-test: 34/34, 8 of them new. Each corpus row is checked by exit code and exact dangling list, the lock bytes must be unchanged, gh must not be called, and a missing lock exits 2. Two mutants each fail exactly one row: no case folding fails the case variant, and ignoring workflows: refs fails the dangling workflow ref. lock-selfcheck-test: 9/9, 2 of them new. Its base fixture lock had no dependencies: section, so it was not closed (Hypatia rejects that shape as workflow_dependencies_missing). It now carries the record. A mutant without the closure verdict fails both new cases. With gawk installed, these pass unchanged: regen-dependabot-locks 59, complete-job-refs 20, relock-sha-keys 12, prune-stale 18, check-lock-sync 13, validate-actions-lock 7. The hook blocks a staged lock with the #963 shape and names the ref. main's own lock is closed, and lock-selfcheck on HEAD is SELF-CONSISTENT. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TbbnKqskQA5mj5gfMniPzi --- .githooks/pre-commit | 16 ++++++++ scripts/close-lock.sh | 41 ++++++++++++++++++- scripts/lock-selfcheck.sh | 23 ++++++++++- scripts/tests/close-lock-test.sh | 24 +++++++++++ .../closed-case-variant.lock | 13 ++++++ .../actions-lock-closure/closed-v0.0.2.lock | 21 ++++++++++ .../actions-lock-closure/closed-v0.0.3.lock | 21 ++++++++++ .../actions-lock-closure/dangling-nested.lock | 15 +++++++ .../dangling-workflow-ref.lock | 14 +++++++ .../actions-lock-closure/expected.tsv | 23 +++++++++++ scripts/tests/lock-selfcheck-test.sh | 24 +++++++++++ 11 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 scripts/tests/fixtures/actions-lock-closure/closed-case-variant.lock create mode 100644 scripts/tests/fixtures/actions-lock-closure/closed-v0.0.2.lock create mode 100644 scripts/tests/fixtures/actions-lock-closure/closed-v0.0.3.lock create mode 100644 scripts/tests/fixtures/actions-lock-closure/dangling-nested.lock create mode 100644 scripts/tests/fixtures/actions-lock-closure/dangling-workflow-ref.lock create mode 100644 scripts/tests/fixtures/actions-lock-closure/expected.tsv diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3f689709a..78f9cac3e 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 9ce855ca0..ffc965fe2 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 b6f9b1a8d..127c1e33a 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 fe6d65005..e1c42fe84 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 000000000..6eee99b43 --- /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 000000000..5a2d04c04 --- /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 000000000..381077f1d --- /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 000000000..1b4fb80d9 --- /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 000000000..3bae2099f --- /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 000000000..9e07a443f --- /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 4a143d1d8..0cd06cdc3 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" < Date: Tue, 20 Oct 2026 12:38:32 +0000 Subject: [PATCH 2/2] style(lock): use [[ ]] for the tests #1318 adds (Sonar) SonarCloud reported 13 new issues on #1318. Its issue list is not reachable from here, but every test the PR adds uses [ ], which is the finding #1291 and #1305 cleared the same way. All twelve such lines are now [[ ]]. Lines this PR does not touch keep their [ ]. close-lock-test 34/34, lock-selfcheck-test 9/9. --check still exits 0, 1 and 2 for a closed lock, a dangling one and a missing one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TbbnKqskQA5mj5gfMniPzi --- .githooks/pre-commit | 2 +- scripts/close-lock.sh | 14 +++++++------- scripts/lock-selfcheck.sh | 6 +++--- scripts/tests/close-lock-test.sh | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 78f9cac3e..ee91e805f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -124,7 +124,7 @@ run_validator "Bot directives" "validate-bot-directives.sh" "staged" # 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" ] \ +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)" diff --git a/scripts/close-lock.sh b/scripts/close-lock.sh index ffc965fe2..1025227a3 100755 --- a/scripts/close-lock.sh +++ b/scripts/close-lock.sh @@ -53,13 +53,13 @@ # 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 +if [[ "${1:-}" == "--check" ]]; then CHECK=1; shift; fi WF="${1:-.github/workflows}" -if [ "$CHECK" -eq 1 ] && [ -f "$WF" ]; then L="$WF"; else L="$WF/actions.lock"; fi +if [[ "$CHECK" -eq 1 && -f "$WF" ]]; then L="$WF"; else L="$WF/actions.lock"; fi GH_BIN="${GH_BIN:-gh}" -if [ ! -f "$L" ]; then +if [[ ! -f "$L" ]]; then echo "close-lock: no $L" >&2 - [ "$CHECK" -eq 1 ] && exit 2 + [[ "$CHECK" -eq 1 ]] && exit 2 exit 1 fi SC="$(mktemp -d)"; trap 'rm -rf "$SC"' EXIT @@ -98,16 +98,16 @@ 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 +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 + [[ -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 + if [[ "$n" -eq 0 ]]; then echo "close-lock: closed (no dangling edges) in $L" exit 0 fi diff --git a/scripts/lock-selfcheck.sh b/scripts/lock-selfcheck.sh index 127c1e33a..89f12d8c3 100755 --- a/scripts/lock-selfcheck.sh +++ b/scripts/lock-selfcheck.sh @@ -235,7 +235,7 @@ for SHA in "$@"; do 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 + [[ "$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)" @@ -250,7 +250,7 @@ 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 ] && [ "$n_dangling" -eq 0 ]; then + 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 @@ -272,7 +272,7 @@ for SHA in "$@"; do echo " self-SHA ref ${key}" done fi - if [ "$n_dangling" -gt 0 ]; then + 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)." diff --git a/scripts/tests/close-lock-test.sh b/scripts/tests/close-lock-test.sh index e1c42fe84..257e60fa9 100755 --- a/scripts/tests/close-lock-test.sh +++ b/scripts/tests/close-lock-test.sh @@ -275,7 +275,7 @@ while IFS=$'\t' read -r file verdict dangling; do 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/ $//') + 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"