Files
ersatztv/docs/decisions/records/ci/grep-q-pipefail-inversion.md
T
timothyandClaude Opus 5 d4c600149d
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
review-verdict/h10 Awaiting review verdict for d4c6001
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(698): review round 4 — the PROTECTED guard was DEAD; define before use, fail closed, fix prescriptive docs
Round-3 cross-family review returned BLOCKED with 3 Mediums. The first was serious
and self-inflicted.

THE PROTECTED GUARD WAS A NO-OP. Round 3's `count_matching` / `count_not_matching`
helpers were defined AFTER the classification chain that calls them, so
`count_matching` was `command not found` on every run, `$( )` yielded an empty string,
`[ "" -gt 0 ]` errored, and the `elif` was simply skipped — the protected-path check
never executed at all. Confirmed by direct execution before fixing.

Three "protected path" tests stayed GREEN throughout, because a protected path is also
not a manifest and not docs-only, so the job still reached `pending` down a different
route. Asserting the STATE could not distinguish a working guard from a dead one. The
mutation battery missed it too: I had mutated the predicates, not their reachability.

Fixed three ways:
  * helpers are defined immediately after `gh()`, before any use;
  * the three counts are evaluated ONCE at TOP LEVEL and validated numeric, because
    `exit 1` inside `$( )` leaves only the subshell and, with the substitution sitting
    in a conditional, `set -e` never fires either — so a grep error had been silently
    reading as "no match". A non-numeric result now aborts with nothing posted, and an
    absent required check blocks the merge;
  * the helpers return a non-numeric sentinel instead of trying to `exit`.

Verified: an invalid regex now exits 2 and posts NOTHING (previously it classified and
posted). Renaming the helper at its definition turns six tests red.

TESTS, aimed at the failure mode rather than the symptom:
  * assert the DISCRIMINATOR (the job's `Decision:` reason line), not the outcome —
    when several branches yield the same verdict, the verdict cannot tell you which ran.
    A first draft of this test asserted the status description and failed against a
    WORKING guard, because for `pending` the description is constant;
  * a cheap stderr sweep for `command not found` / `integer expression expected` /
    `unbound variable` across four representative PR shapes. Each of those makes an `if`
    condition merely false while the job exits 0 and posts a plausible status, so this
    catches a whole family of silently-skipped guards.

DOCS. The record and ci-cd.md still PRESCRIBED the here-string that round 3 removed —
following them would have reintroduced the temp-storage failure. Both now prescribe
counting, define-before-use, top-level evaluation and numeric validation. The workflow's
measurement paragraph still said the npm manifests "are included" three lines above the
note saying they are excluded; corrected.

379 tests pass.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:13:56 +02:00

6.0 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ci.grep-q-pipefail-inversion 2026-07-29 — never feed `grep -q` from a pipe under `set -o pipefail`: SIGPIPE turns a MATCH into a failed pipeline and inverts the guard (#698) active 2026-07-29 none none In any script running under `set -o pipefail`, a security or classification predicate of the form `producer | grep -q…` is FORBIDDEN: `grep -q` exits at its first match, the producer then takes SIGPIPE and exits 141 once the data exceeds the pipe buffer (~64K), so `pipefail` reports the pipeline as FAILED even though grep MATCHED — inverting the predicate exactly when the input is large. A here-string (`grep -q… <<< "$data"`) is ALSO forbidden: bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable, and inside an `if`/`!` that failure flips the predicate the same way. COUNT instead — `n=$(printf '%s\n' "$data" | grep -cE "$re")` — because `grep -c` drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file). Read grep's status honestly: exit 1 means a zero count and is a legitimate answer, anything >1 is a real error. Evaluate the counts ONCE at TOP LEVEL, never inline inside an `if`/`elif` condition: inside `$( )` an `exit` leaves only the subshell and `set -e` does not fire, so an error silently reads as "no match". Validate that each result is numeric and fail closed if not. This applies to both the enforced gate `.gitea/workflows/review-verdict.yml` and the advisory hook `.claude/hooks/pretooluse-merge-consent.sh`. grep -q pipefail, exit 141, SIGPIPE in a shell guard, large PR classified docs-only, protected path guard skipped, printf pipe grep -q, classification inverts on big input, pipe buffer 64K shell predicate · paths: `.gitea/workflows/review-verdict.yml`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/tests/test_pr_changed_files.py` · issues: #698, #649 `count_matching` / `count_not_matching` helpers in `review-verdict.yml`, DEFINED BEFORE FIRST USE, results precomputed into `n_protected`/`n_not_manifest`/`n_not_docs` at top level and validated numeric; regression tests `test_a_LARGE_pr_*` build 1900+ paths (~171KB) to cross the pipe buffer, `test_the_classify_step_runs_without_SHELL_ERRORS` catches a dead guard, `test_a_protected_path_is_rejected_BY_THE_PROTECTED_BRANCH` asserts the branch rather than the outcome

Found by cross-family adversarial review of the #698 PR, then reproduced directly. It is not a defect of that change — the construct shipped with the ORIGINAL docs-only check and was live on main.

The mechanism. grep -q stops reading at its first match. If the writer still has data queued, it gets SIGPIPE and exits 141. pipefail makes the pipeline's status that of the failing member, so a successful MATCH is reported as a FAILED pipeline. Every guard written as if ! producer | grep -q… or if producer | grep -q… therefore flips, and only for large inputs — which is why every existing test missed it: they all used a handful of short paths, far below the buffer.

Both directions were reproduced, at 1901 paths / ~171KB, inside the enumerator's 2000-file cap:

  • A.cs followed by 1900 docs paths → docs_only=yes, pipeline status 141. A C# PR classified docs-only and auto-granted review-verdict/h10=success.
  • .gitea/workflows/review-verdict.yml followed by 1900 docs paths → the PROTECTED guard MISSES, so a PR editing the gate's own definition skips the protected-path check and falls through to the exemptions.

The second is the serious one: it reaches a green required status on a PR that rewrites the gate, with no retarget, no bot account and no forged status — nothing but a large PR. It is a more direct hole than any of the three routes #698 was filed for.

The here-string was the FIRST fix, and it was wrong. grep -q… <<< "$data" does remove the SIGPIPE, and it shipped for one round. But bash materialises a large here-string through temporary storage, so it fails when temp space is full or unwritable — and because these predicates sit inside if/!, that failure flips them exactly as SIGPIPE did. It did not reproduce on macOS bash 3.2 and did on Linux bash 5.x, which is the environment CI runs; the disagreement is itself the argument for a construct that cannot fail either way. Counting with grep -c uses an ordinary pipe and drains stdin, so neither failure mode exists.

Two follow-on traps, both found only by re-review. First, the helpers were defined AFTER the classification chain that called them, so count_matching was command not found on every run and the PROTECTED branch never fired — while three "protected path" tests stayed green, because a protected path is also not a manifest and not docs-only, so the job reached pending down another route. Second, exit 1 inside those helpers only left the command-substitution SUBSHELL, and since the substitution sat in a conditional, set -e never fired either. Hence the rule: define before use, evaluate once at top level, validate the result is numeric, and fail closed when it is not.

Two testing lessons. When several branches produce the SAME outcome, asserting the outcome cannot tell you which branch ran — assert the discriminator (here the Decision: reason line). And a cheap stderr sweep for command not found / integer expression expected / unbound variable catches a whole family of silently-skipped guards, because each of those makes an if condition merely false while the job exits 0 and posts a plausible status.

The input-size lesson. The whole class was invisible because every test used small inputs. A guard whose behaviour depends on a BUFFER THRESHOLD needs a test that crosses the threshold; otherwise the suite is measuring the wrong regime entirely and full coverage of the small regime proves nothing. The regression tests pair each large-input negative with a large-input POSITIVE control, so "large lists now fail closed" (a merge deadlock) cannot masquerade as a fix.