Skip to content

ci: remove pipefail/SIGPIPE races from shell pipelines - #10

Merged
AminDhouib merged 1 commit into
mainfrom
ci/hygiene-pipefail-crlf
Sep 14, 2026
Merged

AminDhouib merged 1 commit into
mainfrom
ci/hygiene-pipefail-crlf

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

grep -q (also head -N, grep -m1) stops reading stdin the moment it is satisfied.
The stage upstream then takes EPIPE/SIGPIPE and exits 141, and under set -o pipefail
that makes the whole pipeline non-zero even though the consumer succeeded — a
confident, specific, wrong failure.

Where pipefail is actually in effect:

  • a script with set -o pipefail / set -euo pipefail;
  • any workflow step with an explicit shell: bash, which GitHub runs as
    bash --noprofile --norc -eo pipefail {0}. The unspecified default is bash -e {0}
    with no pipefail, which is why this hides for so long.

Not theoretical. marka's scripts/check-root-allowlist.sh:25 reported
lighthouserc-docs.cjs as "root file not in allowlist" on a commit where that file was
both present and allowlisted, alongside printf: write error: Broken pipe. Reproduced
400/400 runs against a 200k-line producer while the match was present.

What changed here — the pipe is removed; the failure is not masked:

  • PRODUCER | grep -q P -> grep -q P <<<"$(PRODUCER)", collapsing to <<<"$VAR" where
    the producer was only echo/printf of a variable;
  • ... | head -N -> ... | awk 'NR<=N'. awk never exits early, so the upstream stage
    never takes SIGPIPE, and a real producer failure is still caught by pipefail;
  • allowlist membership done in-process rather than printf ... | grep -qx.

|| true was deliberately not used: it would also swallow genuine failures.

Verified before opening: every touched file passes bash -n (for workflows, a YAML parse
plus bash -n on each run: block) and contains zero CR bytes. Old and new forms were
differentially tested for identical exit status and identical output.

`grep -q` (also `head -N`, `grep -m1`) stops reading stdin the moment it is satisfied.
The stage upstream then takes EPIPE/SIGPIPE and exits 141, and under `set -o pipefail`
that makes the whole pipeline non-zero **even though the consumer succeeded** — a
confident, specific, wrong failure.

Where pipefail is actually in effect:

* a script with `set -o pipefail` / `set -euo pipefail`;
* any workflow step with an explicit `shell: bash`, which GitHub runs as
  `bash --noprofile --norc -eo pipefail {0}`. The *unspecified* default is `bash -e {0}`
  with no pipefail, which is why this hides for so long.

Not theoretical. marka's `scripts/check-root-allowlist.sh:25` reported
`lighthouserc-docs.cjs` as "root file not in allowlist" on a commit where that file was
both present and allowlisted, alongside `printf: write error: Broken pipe`. Reproduced
400/400 runs against a 200k-line producer while the match was present.

What changed here — the pipe is removed; the failure is **not** masked:

* `PRODUCER | grep -q P` -> `grep -q P <<<"$(PRODUCER)"`, collapsing to `<<<"$VAR"` where
  the producer was only `echo`/`printf` of a variable;
* `... | head -N` -> `... | awk 'NR<=N'`. awk never exits early, so the upstream stage
  never takes SIGPIPE, and a *real* producer failure is still caught by pipefail;
* allowlist membership done in-process rather than `printf ... | grep -qx`.

`|| true` was deliberately not used: it would also swallow genuine failures.

Verified before opening: every touched file passes `bash -n` (for workflows, a YAML parse
plus `bash -n` on each `run:` block) and contains zero CR bytes. Old and new forms were
differentially tested for identical exit status and identical output.
@AminDhouib
AminDhouib merged commit 3138e21 into main Sep 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant