Skip to content

ci(code-quality): slice the gitleaks table with a here-string, not a pipe to head (backend#1778) - #228

Merged
LukasWodka merged 1 commit into
developfrom
fix/1778-code-quality-sigpipe
Aug 12, 2026
Merged

ci(code-quality): slice the gitleaks table with a here-string, not a pipe to head (backend#1778)#228
LukasWodka merged 1 commit into
developfrom
fix/1778-code-quality-sigpipe

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

The gitleaks job builds its findings table for $GITHUB_STEP_SUMMARY with:

jq -r '.[] | "| \(.RuleID) | ... |"' /tmp/gitleaks.json 2>/dev/null | head -50

head closes the pipe after row 50. Once jq's output no longer fits the pipe
buffer, jq is still writing, takes SIGPIPE, and pipefail makes the whole
pipeline return 141. Errexit then aborts the step mid-summary.

Errexit is genuinely on at this site — that was the first thing checked, and it
is the part that is easy to get wrong:

  • the step runs set -uo pipefail (line 605) — -e is not spelled out, so it
    reads as though errexit were off;
  • but Actions launches every run: block as bash -e {0}, so -e is on
    regardless;
  • there is no defaults.run.shell and no per-step shell: anywhere in this
    workflow. (The single shell: hit in the file, at line 144, is a workflow
    input named shell that toggles the shellcheck job — not a shell override.)

So both errexit and pipefail are in effect. The site is real, the same class as
.github#173 and the PII gate's #1409.

Why it is worse than a short table

The abort happens inside the { ... } >> "$GITHUB_STEP_SUMMARY" group, which sits
before the annotation loop and before the soft-fail exit 0. So one SIGPIPE costs:

  1. the summary loses the Treat anything detected here as compromised: **rotate it first** remediation block — the single most important text in the report;
  2. every ::error:: / ::warning:: annotation is never emitted, so nothing
    surfaces on the diff;
  3. exit 0 is skipped — a repo configured soft-fail: true HARD-fails instead
    of reporting.

Severity: latent, with a real backstop

This is not a live outage. It needs roughly 700+ findings to fill a 64K pipe
buffer on the Linux runner, and gitleaks findings normally sit at zero — the whole
point of the job. It would tip on a first-time full-history scan of a dirty repo, or
a baseline that stopped matching. Filing it as a latent hazard, not an incident.

The fix

Capture the producer whole, then slice the capture with a here-string, so jq
always runs to completion and the exit status is its own. This is the house idiom
already used at comparable sites (conformance-gate.yml L121-133,
version-bump-pr.yml L217-219, version-bump-gate.yml L344-350).

The emit is guarded on a non-empty capture: head -50 <<<"" would print one blank
line
where the old pipeline printed nothing, which would break the Markdown table.
That guard is what keeps the 0-findings case byte-identical.

|| true on the capture keeps a jq parse failure non-fatal — which is what the
pre-existing 2>/dev/null already intended, and is near-unreachable anyway since
COUNT != 0 means jq already parsed the same file once.

I checked the rest of the step for other early-exit pipe consumers: there is exactly
one real shell pipeline in it, the one fixed here. The other | occurrences are
jq-internal pipes or || operators, and the annotation jq writes straight to stdout
with no consumer. Nothing else to change.

Reproduction

Both step bodies were extracted programmatically from code-quality.yml (old from
origin/develop, new from this branch) and run under bash -e "$step" — exactly how
Actions invokes a run: block — against synthetic gitleaks reports, with
soft-fail: true and a stubbed /tmp/gitleaks. This is the real shipped code, not a
hand-written approximation.

######## REAL step bodies from code-quality.yml, soft-fail: true ########
--- 0 findings ---
OLD rc=0 summary_lines=4 annotations=0 sha=fb3974269e
NEW rc=0 summary_lines=4 annotations=0 sha=fb3974269e
--- 1 findings ---
OLD rc=0 summary_lines=13 annotations=1 sha=50b4979d66
NEW rc=0 summary_lines=13 annotations=1 sha=50b4979d66
--- 50 findings ---
OLD rc=0 summary_lines=62 annotations=50 sha=c65233f53d
NEW rc=0 summary_lines=62 annotations=50 sha=c65233f53d
--- 51 findings ---
OLD rc=0 summary_lines=62 annotations=51 sha=a35fdb9653
NEW rc=0 summary_lines=62 annotations=51 sha=a35fdb9653
--- 200 findings ---
OLD rc=0 summary_lines=62 annotations=200 sha=b822efef54
NEW rc=0 summary_lines=62 annotations=200 sha=b822efef54
--- 1000 findings ---
OLD rc=141 summary_lines=56 annotations=0 sha=571c07cb65
NEW rc=0 summary_lines=62 annotations=1000 sha=a4466c8e6c

sha is a checksum of the produced $GITHUB_STEP_SUMMARY. Byte-identical for every
case up to 200 findings
— including the empty case, i.e. no spurious blank line —
with identical exit codes and identical annotation counts. Behaviour only diverges at
1000, where the old form is broken: rc=141 despite soft-fail: true, a summary 6
lines short, and zero annotations.

What the old form silently drops at 1000 findings:

$ diff <(cut -c1-40 summary.old) <(cut -c1-40 summary.new)
56a57,62
> > Treat anything detected here as compromi
> remove it from the code. Rewriting histo
> value was already pushed. A deliberate f
> `.gitleaks.toml` (allowlist) or in a com
> 

Where it tips

=== OLD form: exit code vs finding count (repeated runs) ===
100 findings 9717 bytes of rows -> old rc: 0 0 0
150 findings 14617 bytes of rows -> old rc: 0 0 0
200 findings 19517 bytes of rows -> old rc: 0 0 0
220 findings 21477 bytes of rows -> old rc: 0 0 0
250 findings 24417 bytes of rows -> old rc: 0 141 141 <-- race, nondeterministic
300 findings 29317 bytes of rows -> old rc: 141 141 141
400 findings 39117 bytes of rows -> old rc: 141 141 141
1000 findings 97934 bytes of rows -> old rc: 141 141 141

Note the boundary at 250 is nondeterministic — same input, different exit codes
across runs. It is a race between jq's writes and head exiting, so this class of bug
presents as CI flake before it presents as a consistent failure. Thresholds measured
on darwin; the Linux runner's 64K pipe buffer puts the real-world tipping point higher,
around ~700 findings.

Verification

  • actionlint1.7.12 — the exact version actionlint.yml pins — over the whole
    repo: exit 0, zero findings. (This is a hard blocking gate in this repo, and
    actionlint runs shellcheck over every run: block.)
  • python3 -c "import yaml; yaml.safe_load(open('.github/workflows/code-quality.yml'))"
    → parses; jobs ['ruff', 'format', 'shellcheck', 'gitleaks', 'house-rules', 'action-pins'].
  • bash -n on the extracted step body → clean.
  • Differential behaviour test above.

Notes

  • No behaviour change for every realistic finding count; the only difference is that
    a pathological count now reports instead of hard-failing.
  • Comment at the site explains the here-string and references backend#1778, matching
    the tone this repo already uses at its other SIGPIPE-hardened sites.
  • This is a reusable workflow consumed org-wide at @main, so the change reaches every
    repo running credential-scan once it promotes.

Refs: backend#1778


Note

Low Risk
Narrow CI shell fix in summary formatting only; no change to scan logic or normal finding counts. Latent hazard only triggers at hundreds of findings.

Overview
Fixes a latent SIGPIPE abort in the gitleaks job summary when truncating findings with jq | head -50.

Under Actions' bash -e plus pipefail, a large finding set could kill the step mid-summary, skip annotations, and turn soft-fail: true into a hard failure. The table is now built by capturing jq output fully, then slicing with a guarded here-string so normal counts stay byte-identical.

Reviewed by Cursor Bugbot for commit b91ff54. Bugbot is set up for automated code reviews on this repo. Configure here.

…pipe to head
`jq ... /tmp/gitleaks.json | head -50` builds the gitleaks findings table for
the step summary. The step runs `set -uo pipefail`, and Actions launches every
`run:` block as `bash -e {0}` — so errexit is ON despite the `set -uo` reading
as though it were off. `head` closes the pipe after row 50, jq takes SIGPIPE
once the output no longer fits the pipe buffer, pipefail turns that into a
pipeline status of 141, and errexit aborts the step mid-summary.
The short table is the least of it. The abort happens before the
`::error::`/`::warning::` annotations are emitted and before the soft-fail
`exit 0`, so a repo configured `soft-fail: true` HARD-fails instead of
reporting — and the summary loses the "rotate it first" remediation guidance
entirely.
Capture the rows into a variable and slice the capture with a here-string, so
jq always runs to completion and the exit status is its own. The emit is
guarded on a non-empty capture, because `head <<<""` would print one blank
line where the pipeline printed nothing.
Latent, not live: it needs roughly 700+ findings to fill a 64K pipe buffer,
and gitleaks findings normally sit at zero. Measured on darwin it tips from
~250 findings (~24K of rows), nondeterministically right at the boundary
because it is a race.
Verified against the real step body extracted from this workflow: at 0/1/50/
51/200 findings old and new produce byte-identical summaries, the same exit
code and the same annotation count; at 1000 findings old returns 141 with 0
annotations, new returns 0 with all 1000. actionlint 1.7.12 (the version CI
pins) reports zero findings.
Refs: backend#1778
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 12, 2026

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct and careful fix — the SIGPIPE-under-pipefail-with-errexit analysis holds (default runner shell is bash -e -o pipefail), the here-string sidesteps it cleanly, the empty-ROWS guard preserves the old no-blank-line behavior, and there's no interface change for consumers. Nice 👍

@LukasWodka
LukasWodka merged commit d75a1e9 into developAug 12, 2026
11 checks passed
@LukasWodka
LukasWodka deleted the fix/1778-code-quality-sigpipe branch August 14, 2026 13:53
Sign up for freeto 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.

2 participants

@LukasWodka@saadqbal