Uh oh!
There was an error while loading. Please reload this page.
ci(code-quality): slice the gitleaks table with a here-string, not a pipe to head (backend#1778) - #228
Merged
Merged
Conversation
…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>saadqbal
approved these changes
Aug 12, 2026
saadqbal
left a comment
Collaborator
There was a problem hiding this comment.
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 👍
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The gitleaks job builds its findings table for
$GITHUB_STEP_SUMMARYwith:headcloses the pipe after row 50. Once jq's output no longer fits the pipebuffer, jq is still writing, takes SIGPIPE, and
pipefailmakes the wholepipeline 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:
set -uo pipefail(line 605) —-eis not spelled out, so itreads as though errexit were off;
run:block asbash -e {0}, so-eis onregardless;
defaults.run.shelland no per-stepshell:anywhere in thisworkflow. (The single
shell:hit in the file, at line 144, is a workflowinput named
shellthat 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 sitsbefore the annotation loop and before the soft-fail
exit 0. So one SIGPIPE costs:Treat anything detected here as compromised: **rotate it first**remediation block — the single most important text in the report;::error::/::warning::annotation is never emitted, so nothingsurfaces on the diff;
exit 0is skipped — a repo configuredsoft-fail: trueHARD-fails insteadof 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.ymlL121-133,version-bump-pr.ymlL217-219,version-bump-gate.ymlL344-350).The emit is guarded on a non-empty capture:
head -50 <<<""would print one blankline where the old pipeline printed nothing, which would break the Markdown table.
That guard is what keeps the 0-findings case byte-identical.
|| trueon the capture keeps a jq parse failure non-fatal — which is what thepre-existing
2>/dev/nullalready intended, and is near-unreachable anyway sinceCOUNT != 0means 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 arejq-internal pipes or
||operators, and the annotationjqwrites straight to stdoutwith no consumer. Nothing else to change.
Reproduction
Both step bodies were extracted programmatically from
code-quality.yml(old fromorigin/develop, new from this branch) and run underbash -e "$step"— exactly howActions invokes a
run:block — against synthetic gitleaks reports, withsoft-fail: trueand a stubbed/tmp/gitleaks. This is the real shipped code, not ahand-written approximation.
shais a checksum of the produced$GITHUB_STEP_SUMMARY. Byte-identical for everycase 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=141despitesoft-fail: true, a summary 6lines short, and zero annotations.
What the old form silently drops at 1000 findings:
Where it tips
Note the boundary at 250 is nondeterministic — same input, different exit codes
across runs. It is a race between jq's writes and
headexiting, so this class of bugpresents 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 versionactionlint.ymlpins — over the wholerepo: 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 -non the extracted step body → clean.Notes
a pathological count now reports instead of hard-failing.
backend#1778, matchingthe tone this repo already uses at its other SIGPIPE-hardened sites.
@main, so the change reaches everyrepo running
credential-scanonce 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 -epluspipefail, a large finding set could kill the step mid-summary, skip annotations, and turnsoft-fail: trueinto a hard failure. The table is now built by capturingjqoutput 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.