Symptom
A shell check such as curl -s "$URL" | grep -q 'expected' fails although the text is in the output. Depending on the producer and the host you see one of:
- nothing at all — the pipeline just returns non-zero, so the script reports "not found";
seq: write error: Broken pipe (or <producer>: write error: Broken pipe) and exit status 1;curl: (23) Failure writing output to destination, passed 16384 returned 0 (only with curl -S; with -s it is silent) and exit status 23;- exit status 141 (128 + SIGPIPE) when you print
$? / ${PIPESTATUS[*]}, e.g. PIPESTATUS=141 0.
The same check passes when run alone and fails inside a long test run, or passes on a laptop and fails on the CI runner. The inverse also happens: if ! producer | grep -q MARK; then echo clean; fi reports clean although MARK was there (false pass).
When it happens
All three conditions:
set -o pipefail is on (common in set -euo pipefail scripts and in GitHub Actions bash steps you configured that way).- The pipeline ends in a reader that stops early:
grep -q, grep --quiet/--silent, grep -m1, head -1. - The producer still has data to write after the match: the output is larger than the pipe buffer (64 KiB on Linux) or it is written in several chunks (
curl, docker logs, git show, seq).
Measured on 2026-09-30, seq 1 N | grep -q '^1$' (match on the first line), 30 runs each, Linux kernel 6.18 (WSL2):
| output size | grep -q false misses | grep … >/dev/null misses |
|---|
| 3.9 KB | 0/30 | 0/30 |
| 48.9 KB | 0/30 | 0/30 |
| 66.9 KB | 0/30 | 0/30 |
| 108.9 KB | 6/30 | 0/30 |
| 589 KB | 30/30 | 0/30 |
| 6.9 MB | 30/30 | 0/30 |
curl -s of a 3.4 MB page from a local nginx piped into grep -q missed 20/20. Around the 64 KiB boundary it is timing-dependent, which is why it looks flaky: a page that grows over time (a listing with more rows after more tests ran) crosses the threshold and the "flake" becomes permanent.
On a GitHub Actions self-hosted runner, jobs run with SIGPIPE ignored, so the producer is not killed by the signal; its write() gets EPIPE, it prints write error: Broken pipe and exits 1. Reproduced with trap '' PIPE: PIPESTATUS=1 0. That 1 is indistinguishable from "grep found nothing".
Git Bash on Windows showed 141 only for outputs around a megabyte and did not reproduce the smaller cases — a pass on Windows says nothing about Linux.
Cause
grep -q exits as soon as it sees the first match and closes the read end of the pipe. The producer's next write fails (SIGPIPE → 141, or EPIPE → the program's own error exit: curl 23, coreutils 1). With pipefail, the pipeline's status is the rightmost non-zero status, i.e. the producer's failure, not grep's success.
False pass: ! a | grep -q X or a | grep -q X && LEAK=1 — the "found" case is turned into a failure, so the negation says "not found".
Fix
Let grep read to the end and discard the output:
# before
curl -s "$URL" | grep -q 'listed'
# after
curl -s "$URL" | grep 'listed' >/dev/null
Drop only the q from combined flags: grep -Eq → grep -E … >/dev/null, grep -qx → grep -x … >/dev/null. Replace | head -1 with | sed -n 1p (sed reads everything). Measured: grep -m1 failed 30/30 and head -1 failed 30/30 on 6.9 MB, sed -n 1p 0/30.
Both GNU grep (3.11, 3.12) and BusyBox grep (1.37.0) without -q consumed the whole input when stdout was /dev/null (0/30 misses each). grep -c … >/dev/null also works but is slower to read.
If you need the match itself, capture first: out=$(curl -s "$URL") then grep -q X <<<"$out" — a here-string/file is fine because there is no producer to cut off.
To keep it from coming back, add a lint step that fails on a quiet grep at the end of a pipe in any tracked *.sh or workflow file, e.g.:
git ls-files '*.sh' '.github/workflows/*' | xargs grep -nE '\|\s*grep\s+(-[a-zA-Z]*q|--quiet|--silent)' && exit 1 || true
(adapt the regex to your style; ours also checks that four known spellings are caught and four look-alikes are not).
Verify
docker run --rm ubuntu:24.04 bash -c 'set -o pipefail
seq 1 1000000 | grep -q "^1$"; echo "quiet: $?"
seq 1 1000000 | grep "^1$" >/dev/null; echo "fixed: $?"'
# quiet: 141
# fixed: 0
Simulating an Actions runner: prefix with trap "" PIPE; → seq: write error: Broken pipe, quiet: 1.
Notes
- Retrying the test "fixed" it for a while each time; the real trigger was output growth.
curl -sf does not help; -f concerns HTTP status, not the write error.set +o pipefail around the line works but hides real producer failures (e.g. curl could not connect); reading to the end keeps both signals correct.- It hit us in three separate fixes before we rewrote all 100 such pipelines in 43 scripts at once and added the lint.
The full body — free, open to anyone, no key.
Source: Diagnosed and fixed in WITAN's own CI regression suites (2026-09), where it caused both false failures and a latent false pass; reproduced on Ubuntu 24.04 (bash 5.2.21, GNU grep 3.11), Alpine (BusyBox 1.37.0, GNU grep 3.12, curl 8.22.0) on 2026-09-30.