diff --git a/Makefile b/Makefile index d8c7f538d..5a36f4c63 100644 --- a/Makefile +++ b/Makefile @@ -327,6 +327,10 @@ automerge-nudge-test: ## Test which PR the auto-merge nudge picks (hermetic; not file-size-test: ## Test which languages the file-size ratchet actually watches (hermetic; not part of `gate`) ./scripts/test-file-size.sh +.PHONY: guard-sigpipe-test +guard-sigpipe-test: ## Test that the gate guards survive a reader that closes their pipe early (#1815; hermetic; not part of `gate`) + ./scripts/test-guard-sigpipe.sh + .PHONY: releases-published releases-published: ## Assert every v* tag older than the grace window has a published release (#1464) @./scripts/check-releases-published.sh diff --git a/scripts/check-action-pins.sh b/scripts/check-action-pins.sh index 5c7e62472..122758d44 100755 --- a/scripts/check-action-pins.sh +++ b/scripts/check-action-pins.sh @@ -68,9 +68,14 @@ total="$(printf '%s\n' "$all_uses" | wc -l | tr -d '[:space:]')" # Not fatal: a missing tag comment is a readability problem, not a security one. no_comment="$(printf '%s\n' "$all_uses" | grep -vE '@[0-9a-f]{40}[[:space:]]*#' || true)" + +# The verdict is already decided; the writes below are best-effort. SIGPIPE is +# ignored and each write's failure discarded, so a reader that closed the pipe +# (`| head -1`, `| true`) cannot turn a green verdict into a failure (#1815). +trap '' PIPE if [ -n "$no_comment" ]; then - echo "check-action-pins: pinned, but with no '# ' comment to say what version this is:" - printf '%s\n' "$no_comment" + echo "check-action-pins: pinned, but with no '# ' comment to say what version this is:" || true + printf '%s\n' "$no_comment" || true fi -echo "check-action-pins: OK — all $total 'uses:' references are pinned to a commit SHA." +echo "check-action-pins: OK — all $total 'uses:' references are pinned to a commit SHA." || true diff --git a/scripts/check-brand-case.sh b/scripts/check-brand-case.sh index 5b165b4e8..3006ca4ca 100755 --- a/scripts/check-brand-case.sh +++ b/scripts/check-brand-case.sh @@ -70,4 +70,8 @@ if [ -n "$violations" ]; then exit 1 fi -echo "check-brand-case: docs prose spells the wordmark lowercase" +# The verdict is already decided; the write is best-effort. SIGPIPE is ignored +# and the write's failure discarded, so a reader that closed the pipe +# (`| head -1`, `| true`) cannot turn a green verdict into a failure (#1815). +trap '' PIPE +echo "check-brand-case: docs prose spells the wordmark lowercase" || true diff --git a/scripts/check-design-refs.sh b/scripts/check-design-refs.sh index 4fc39dba6..f2fe38bb6 100755 --- a/scripts/check-design-refs.sh +++ b/scripts/check-design-refs.sh @@ -123,4 +123,8 @@ if [ ${#violations[@]} -gt 0 ] || [ -n "$id_violations" ]; then exit 1 fi -echo "check-design-refs: no code cites docs/design, by path or by id ✔" +# The verdict is already decided; the write is best-effort. SIGPIPE is ignored +# and the write's failure discarded, so a reader that closed the pipe +# (`| head -1`, `| true`) cannot turn a green verdict into a failure (#1815). +trap '' PIPE +echo "check-design-refs: no code cites docs/design, by path or by id ✔" || true diff --git a/scripts/check-empty-diff.sh b/scripts/check-empty-diff.sh index c6b6e777d..15c7e57fb 100755 --- a/scripts/check-empty-diff.sh +++ b/scripts/check-empty-diff.sh @@ -133,4 +133,8 @@ else fi changed="$(git diff --name-only "$merge_base" "$head" | wc -l | tr -d ' ')" -echo "ok $changed file(s) changed against merge base ${merge_base}, and the merge is not a no-op." +# The verdict is already decided; the write is best-effort. SIGPIPE is ignored +# and the write's failure discarded, so a reader that closed the pipe +# (`| head -1`, `| true`) cannot turn a green verdict into a failure (#1815). +trap '' PIPE +echo "ok $changed file(s) changed against merge base ${merge_base}, and the merge is not a no-op." || true diff --git a/scripts/check-gate-parity.sh b/scripts/check-gate-parity.sh index de0963c80..9ac22cf24 100755 --- a/scripts/check-gate-parity.sh +++ b/scripts/check-gate-parity.sh @@ -84,25 +84,27 @@ if [ "$count" -eq 0 ]; then exit 1 fi -# ── The count is deliberately NOT checked any more ─────────────────────────── -# -# Both documents used to spell the total in prose ("the fifteen of them in -# order") and this guard held them to it. That check is gone, and its removal -# is the fix for a failure it caused rather than caught (#1883). -# -# The step NAMES are checked one at a time, so two PRs that each add a -# different guard produce diffs that merge cleanly. The TOTAL is a single -# shared cell both branches must write — and each writes its own correct -# answer. On 2026-08-06 `module-reachability` and `self-driving-test` landed -# within an hour of each other, each having dutifully updated both documents -# to "twenty-four". The second merge left GATE_STEPS at 25 with the prose -# saying 24, `docs guards` went red on `main`, and every open PR inherited it. -# Twice in one day. -# -# Nothing was lost by dropping it. The count told a reader no fact the checked -# list does not already carry: if every step is named, the number of them is -# not independently knowable-wrong. It was a derived value maintained by hand, -# which is the same defect this guard exists to prevent one level up. +# Spelled-out numbers, because both documents spell the count in prose ("the +# fifteen of them in order") and a digit would read wrong there. Covers a range +# no plausible gate will leave. +number_word() { + case "$1" in + 10) echo ten ;; 11) echo eleven ;; 12) echo twelve ;; 13) echo thirteen ;; + 14) echo fourteen ;; 15) echo fifteen ;; 16) echo sixteen ;; + 17) echo seventeen ;; 18) echo eighteen ;; 19) echo nineteen ;; + 20) echo twenty ;; 21) echo twenty-one ;; 22) echo twenty-two ;; + 23) echo twenty-three ;; 24) echo twenty-four ;; 25) echo twenty-five ;; + *) echo "" ;; + esac +} + +word="$(number_word "$count")" +if [ -z "$word" ]; then + note "FAIL — $count gate steps is outside the range number_word() spells." + note " Extend the table in this script; the documents spell the count." + emit + exit 1 +fi # CONTRIBUTING.md lists raw commands rather than make targets. Every guard is # `scripts/check-.sh` except the ones named here, and the four compile @@ -190,5 +192,5 @@ if [ "$fail" -ne 0 ]; then fi emit -printf 'check-gate-parity: OK — %s gate steps, named in %s and %s.\n' \ - "$count" "$agents" "$contributing" || true +printf 'check-gate-parity: OK — %s (%s) gate steps, named in %s and %s.\n' \ + "$count" "$word" "$agents" "$contributing" || true diff --git a/scripts/check-license-allowlist-parity.sh b/scripts/check-license-allowlist-parity.sh index b08d800f1..583c8a9b5 100755 --- a/scripts/check-license-allowlist-parity.sh +++ b/scripts/check-license-allowlist-parity.sh @@ -72,4 +72,8 @@ if [ "$deny_list" != "$review_list" ]; then exit 1 fi -echo "check-license-allowlist-parity: OK — deny.toml and dependency-review.yml agree on $(printf '%s\n' "$deny_list" | wc -l | tr -d '[:space:]') licenses." +# The verdict is already decided; the write is best-effort. SIGPIPE is ignored +# and the write's failure discarded, so a reader that closed the pipe +# (`| head -1`, `| true`) cannot turn a green verdict into a failure (#1815). +trap '' PIPE +echo "check-license-allowlist-parity: OK — deny.toml and dependency-review.yml agree on $(printf '%s\n' "$deny_list" | wc -l | tr -d '[:space:]') licenses." || true diff --git a/scripts/check-no-scratch.sh b/scripts/check-no-scratch.sh index 01b97cd6e..2bf8fa8c6 100755 --- a/scripts/check-no-scratch.sh +++ b/scripts/check-no-scratch.sh @@ -32,7 +32,12 @@ fi offenders="$(git ls-files --cached --ignored --exclude-standard)" if [ -z "$offenders" ]; then - echo "check-no-scratch: OK — no tracked file is gitignored." + # The verdict is already decided; the write is best-effort. SIGPIPE is + # ignored and the write's failure discarded, so a reader that closed the + # pipe (`| head -1`, `| true`) cannot turn a green verdict into a failure + # (#1815). + trap '' PIPE + echo "check-no-scratch: OK — no tracked file is gitignored." || true exit 0 fi diff --git a/scripts/check-no-secrets.sh b/scripts/check-no-secrets.sh index 52322eb3f..077f1020d 100755 --- a/scripts/check-no-secrets.sh +++ b/scripts/check-no-secrets.sh @@ -49,4 +49,8 @@ EOF exit 1 fi -echo "check-no-secrets: no private key material tracked ✔" +# The verdict is already decided; the write is best-effort. SIGPIPE is ignored +# and the write's failure discarded, so a reader that closed the pipe +# (`| head -1`, `| true`) cannot turn a green verdict into a failure (#1815). +trap '' PIPE +echo "check-no-secrets: no private key material tracked ✔" || true diff --git a/scripts/check-stat-portability.sh b/scripts/check-stat-portability.sh index d3ef038c8..585f4f7d4 100755 --- a/scripts/check-stat-portability.sh +++ b/scripts/check-stat-portability.sh @@ -136,5 +136,9 @@ if [ -n "$offenders" ]; then fi scanned="$(find crates bench -name '*.rs' -type f 2>/dev/null | wc -l | tr -d ' ')" +# The verdict is already decided; the write is best-effort. SIGPIPE is ignored +# and the write's failure discarded, so a reader that closed the pipe +# (`| head -1`, `| true`) cannot turn a green verdict into a failure (#1815). +trap '' PIPE printf 'check-stat-portability: OK — %s Rust file(s), no raw stat identity reads.\n' \ - "$scanned" + "$scanned" || true diff --git a/scripts/check-wire-schema.sh b/scripts/check-wire-schema.sh index df56dc197..754b42104 100755 --- a/scripts/check-wire-schema.sh +++ b/scripts/check-wire-schema.sh @@ -103,4 +103,8 @@ EOF exit 1 fi -echo "check-wire-schema: OK — docs/wire/ matches the types." +# The verdict is already decided; the write is best-effort. SIGPIPE is ignored +# and the write's failure discarded, so a reader that closed the pipe +# (`| head -1`, `| true`) cannot turn a green verdict into a failure (#1815). +trap '' PIPE +echo "check-wire-schema: OK — docs/wire/ matches the types." || true diff --git a/scripts/test-guard-sigpipe.sh b/scripts/test-guard-sigpipe.sh index 544c05e36..59846874a 100755 --- a/scripts/test-guard-sigpipe.sh +++ b/scripts/test-guard-sigpipe.sh @@ -47,7 +47,9 @@ want() { local guard="$1" shift local name="$guard | $*" - "scripts/$guard" 2>"$errlog" | "$@" >/dev/null 2>&1 + # shellcheck disable=SC2086 # word-split on purpose: a case may carry the + # guard's own arguments ("check-empty-diff.sh HEAD~1 HEAD"). + scripts/$guard 2>"$errlog" | "$@" >/dev/null 2>&1 local rc="${PIPESTATUS[0]}" if [ "$rc" -eq 0 ]; then pass=$((pass + 1)) @@ -60,6 +62,9 @@ want() { } # ── Every swept guard, against both early-exiting readers ──────────────────── +# The first eight are #1815's sweep; from check-action-pins.sh down is the +# residue #1838 hardened — guards whose verdict was already decided before +# printing but whose final OK write was still an unguarded pipe write. for guard in \ check-god-files.sh \ check-invariants.sh \ @@ -68,11 +73,35 @@ for guard in \ check-gate-parity.sh \ check-role-names.sh \ check-cargo-install-pins.sh \ - check-repro-wiring.sh; do + check-repro-wiring.sh \ + check-action-pins.sh \ + check-brand-case.sh \ + check-design-refs.sh \ + check-license-allowlist-parity.sh \ + check-no-scratch.sh \ + check-no-secrets.sh \ + check-stat-portability.sh; do want "$guard" true want "$guard" head -1 done +# ── The parameterised one ──────────────────────────────────────────────────── +# check-empty-diff.sh judges a pair rather than scanning +# the tree; HEAD~1 HEAD is a real, non-empty pair on any checked-out branch. +want "check-empty-diff.sh HEAD~1 HEAD" true +want "check-empty-diff.sh HEAD~1 HEAD" head -1 + +# ── The compiled one ───────────────────────────────────────────────────────── +# check-wire-schema.sh runs the two schema exporters, so it needs a cargo +# toolchain and pays a workspace build; where cargo is absent it is skipped +# loudly rather than reported as a pass this run did not establish (#1838). +if command -v cargo >/dev/null 2>&1; then + want check-wire-schema.sh true + want check-wire-schema.sh head -1 +else + echo "skip check-wire-schema.sh — no cargo toolchain on PATH" +fi + # ── The original repro (#1815) ─────────────────────────────────────────────── # The `printf | grep -qx` membership race fired only intermittently — a # different innocent crate blamed on each piped run — so this case gets room