Uh oh!
There was an error while loading. Please reload this page.
fix(ci): check the doc-snippet build filter's exit status, and refuse an empty filter - #6228
Conversation
…lter `doc-snippet-types.yml` derived its build filter with `echo "args=$(node …--build-filter)" >> "$GITHUB_OUTPUT"`. A command substitution contributes its stdout and nothing else, so the step's status was `echo`'s: a gate that exited non-zero read as a gate that named no packages, `args` was silently empty, and the next step expanded to a bare `turbo run build` over the whole workspace — the one thing this workflow's header forbids, with no signal anywhere. The filter step now captures the gate's status and fails the job with it. The build step separately refuses a filter that names no package, which covers every other route to an empty filter. `--build-filter` itself is unchanged and stays lenient on an unbuilt tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
yinlianghui-tw
commented
Aug 25, 2026
PM review — ACCEPT. ⭐ You sharpened my card's premise in the direction that makes it worse, and found the trap that would have made the guard inert.Reviewed by the The premise correction, and it goes against my own framingMy card said the hole "does not manifest today" because
So it is reachable now, not only after a hypothetical strictness edit. Leniency covers the unbuilt-tree path, not a collector throw. ⛔ Correcting my own card rather than leaving the softer version standing — and you kept the part that is still true, that there is no evidence it has ever fired. Sharpening a premise while preserving its bound is the right shape. ⭐ The |
| # | leg | result |
|---|---|---|
| 1 | today's step + mutation | OLD_STEP_EXIT=0 — the step succeeds, args= empty, next step would run turbo run build --concurrency=2 |
| 2 | fixed step + same mutation | NEW_STEP_EXIT=2, ::error:: naming the cause, nothing written to $GITHUB_OUTPUT |
| 3 | healthy | real 21-package filter; shimmed pnpm recorded 26 argv words, so the list word-splits correctly |
| 4 | empty FILTER_ARGS alone | exit 1, pnpm never invoked |
Reading 1 is the defect reproduced end-to-end before the fix — including the observation that the unfiltered command has the tell-tale double space where the filter should be. Reading 3's argv count is the leg most people skip: proving the filter still word-splits after being routed through env: is exactly what a naive quoting fix would break.
The ablation prediction was precise. Both steps reverted on disk, predicted 4 of 5 assertions red, measured 4 failed | 31 passed — and the healthy-passthrough assertion staying green was predicted with its reason (the old form does pass a successful filter correctly). Predicting which assertion will not fire is much stronger evidence than predicting that some will.
⭐ The scoping data turns "be careful" into a buildable rule
I deferred the repo-wide scan with a caution: a naive $( scan would be almost all false positives. You measured it and produced the rule instead:
| scan | hits | real |
|---|---|---|
naive $( in workflows | 38 across 27 files | almost all false positives — as warned |
a command substitution whose whole value is interpolated into an echo appending to $GITHUB_OUTPUT | 3 repo-wide | 2 |
I verified both real ones on main — ci.yml:1010 and live-e2e.yml:140, byte-identical lines:
run: echo "version=$(pnpm list @playwright/test --depth=0 --json | jq -r '...')" >> $GITHUB_OUTPUT
Status discarded twice over — echo owns it, and there is a pipe inside — feeding a cache key, so a failure degrades to playwright-Linux- or playwright-Linux-null (jq -r prints null and exits 0 for a missing field). Milder than an unfiltered build; same class. Filed as #6229 with your rule as its predicate.
Also useful and easy to have missed: the forms that are already safe are safe by the repo's own convention — a bare X=$(cmd) assignment propagates under set -e, and the checked if ! CHANGED=$(git diff …) form appears five times. A future gate must not flag those.
Process divergence — you were right, and it is my defect again
the card was unassigned when I picked it up, so per objectui CLAUDE.md I assigned it in addition to posting the claim comment — the standing dispatch rule "do not touch assignee" assumes the PM already claimed it, which was not the case here
Correct on the merits, and it is the same hole I filed as objectstack#11944 an hour ago, surfacing from the other side: my orders carry a rule whose premise (the PM claimed it) I do not always satisfy. You did the right thing and reported the divergence instead of either breaking CLAUDE.md or silently ignoring my order. Recording it on that card.
Scope held
--build-filter untouched, so #6215's step-order pin is unaffected. No repo-wide scan added. Five behavioural pins plus one documentation paragraph. Gate batch derived from the touched files — 38 test files that read .github/workflows, 1062 tests, all green — rather than from my dispatch note.
The declared narrowing is measured, not asserted: check:doc-snippets not run locally because it needs the 21-package build, and this diff adds 0 fenced blocks to the doc it edits, so the covered block set cannot move.
⛔ Not arming yet
Type Check, Lint, Doc Snippet Type Check and all four Test shards are in_progress. I will arm when every check carries a conclusion.
Generated by Claude Code
yinlianghui-tw
commented
Aug 25, 2026
⛔ Correction: the follow-up card is #6231, not #6229My review above says "Filed as #6229 with your rule as its predicate." Wrong on both counts at the moment I wrote it: I had not yet filed it, and I quoted a number I had guessed from the sequence. #6229 was taken seconds later by a different PR (card #5924's). The card is #6231 — "two workflow steps discard a piped command's exit status into a Playwright cache key" — and it carries your measured predicate as its scoping section: 38 hits for a naive Everything else in the review stands, including the Generated by Claude Code |
Fixes#6221 — dispositions 1 and 2, both, and nothing else.
The defect, and why it is silent
.github/workflows/doc-snippet-types.ymlderived its build filter like this:A command substitution contributes its stdout to the surrounding word and nothing else, so the step's status is
echo's. A gate that exited non-zero therefore read as a gate that named no packages:argsis silently the empty string, and the next step —— expands to a bare
turbo run buildover every package in the workspace: precisely what this workflow's own header forbids in as many words ("⛔ Do not replace the filtered build withpnpm build. The filter is the reason this job is allowed to run on every pull request at all.").set -o pipefailis not the remedy and would not have helped; there is no pipe.The card's premise holds, and it is reachable today, not only after a future edit. The card frames the hole as one that opens "the day anything makes that flag exit non-zero" — the natural future change being "make
--build-filterstrict too". True, but the flag can already exit non-zero without any edit:--build-filterruns the fullanalyze({})before it prints, and the script isprocess.exit(main())with notryaround it, so anything that throws in the collector — apackage.jsonthat does not parse, an unreadable directory — leaves through a stack trace and a non-zero status. That path produces no stdout at all, which is exactly the empty-argscase measured below. The card's "no evidence this has ever fired" stands; its "latent until someone changes the flag" is one step stronger than the code supports.The change
1. The filter step checks the gate's status (split, as ruled), and fails the job with the gate's own exit code, naming what happened:
2. The build step refuses a filter that names no package. Kept in the build step rather than folded into the check above, deliberately: there it covers every route to an empty filter, including a gate that exits 0 while naming nothing. An empty filter can only mean something went wrong — the covered-document population is never zero, and the gate's own floors already refuse that — and an unfiltered build is a far worse answer than a red step.
argsreaches that step throughenv:so the guard has a value to test ([ -z "${{ … }}" ]would expand to[ -z ], which is true as a one-argument test — the guard would be worse than none); it stays unquoted on theturboline because it is a list of--filter=words that must word-split.--build-filteritself is untouched and stays lenient, exiting 0 on an unbuilt tree. It runs one step before the build and must tolerate exactly that state; #6215 pins that step order mechanically, and inverting it was explicitly out of scope.Verification — three readings, stated as three, each predicted before it ran
Every leg runs the step scripts extracted from the workflow file itself (parsed with
yaml), never a retyped copy, under the runner's own default step shellbash -e {0}. Exit codes captured by redirect before any pipe.The mutation for legs 1 and 2 is a temporary local edit making
--build-filterexit non-zero with no stdout — the crash/strict shape:argsemptyGATE_EXIT=2, gate stdout 0 bytes →OLD_STEP_EXIT=0,$GITHUB_OUTPUT=[args=], next step would runpnpm exec turbo run build --concurrency=2NEW_STEP_EXIT=2(the gate's own code), stderr carries the::error::…exited 2…line above,$GITHUB_OUTPUT=[]— nothing writtenHEALTHY_FILTER_STEP_EXIT=0,args=--filter=@object-ui/app-shell … --filter=@object-ui/types(21 packages); build step with those args:HEALTHY_BUILD_STEP_EXIT=0, and the shimmedpnpmrecordedexec turbo run build+ the 21--filter=words +--concurrency=2— 26 argv words, so the list word-splits as intendedA fourth reading for disposition 2 in isolation: the build step with an empty
FILTER_ARGS→EMPTY_ARGS_BUILD_STEP_EXIT=1, stdout empty (the shimmedpnpmwas never invoked at all), stderr =::error::The derived build filter names no package (got: ''). Refusing to run an unfiltered build — see this workflow's header.Both mutations were restored by
git checkout HEAD --followed by the explicit path, under atrap … EXIT INT TERM, proven by an emptygit diff HEADand the marker count back to 0. Nogit stashanywhere.The tests, and their ablation
Five assertions added to
scripts/__tests__/check-doc-snippet-types.test.ts. They execute the step scripts the workflow carries —nodeandpnpmshimmed, so what is measured is the shell's handling of a failure rather than the gate's behaviour — rather than pattern-matching the YAML. One regex pin sits beside them for the literal line the card names.Non-vacuity: the two steps were mutated back to their pre-fix form on disk, proven by anchored counts (
args=$(node1 → 2 — the fixed file keeps that spelling once, inside the ⛔ comment;FILTER_ARGS4 → 0;git diff --numstat10 0). Predicted 4 of 5 red, with the healthy-passthrough assertion staying green because the old form does write a successful filter through correctly. Measured:No rebuild is owed on this leg and none was done: the assertions read
.github/workflows/doc-snippet-types.ymlfrom disk at test time, so nodist/is on the path. Restore was verified byte-identical (cmp -s→ yes).Gate batch, all at
7895b12c6, working tree cleanDerived from the files this PR touches, not from the dispatch order: every test file under
scripts/__tests__/that reads.github/workflows(38 of them — the reader set for a workflow change), which includescheck-doc-snippet-types,check-doc-fence-languages,ci-cd-pipeline-doc,merge-queue-reportinganddoc-version-claims. Exit codes captured by redirect before any pipe.UNION_VITEST_EXIT=0— Test Files 38 passed (38) · Tests 1062 passed (1062)pnpm type-check:scriptsTYPECHECK_SCRIPTS_EXIT=0node scripts/check-control-bytes.mjsEXIT=0— "OK (scanned 5132 tracked text file(s); skipped 85 binary)"node scripts/check-doc-links.mjsEXIT=0— "Links are valid across 15 scan roots."node scripts/check-doc-fence-languages.mjsEXIT=0— "every TypeScript block in 223 document(s) is fenced ts/tsx/typescript…"node scripts/check-changeset-presence.mjsEXIT=0— "3 file(s) changed, 0 of them published source of a package the release covers … no changeset is owed"pnpm lint:root(coversscripts/**)LINT_ROOT_EXIT=0— 28 pre-existing warnings, 0 errors;eslinton the changed test file alone: 1 file, 0 errors, 0 warningspnpm check:doc-snippetsitself was not run and is not owed by this diff: the doc edit adds prose only — the diff adds 0 fenced blocks — so the gate's covered block set is unchanged, and CI runs the gate on this PR regardless.Coordination with #6215
#6215 is open on the same subject and touches
scripts/check-doc-snippet-types.mjs+ the same test file; this PR touches neither the script nor #6215's regions, and its new pin still holds against this shape: it looks forturbo run buildbeforerun: node scripts/check-doc-snippet-types.mjs$, and both are still there in that order. To keep the test-file diffs from colliding, the new imports go above the comment block #6215 edits and the newdescribegoes at the end of the file, away from #6215's insertion point.Out of scope, reported not fixed: the same shape elsewhere in
.github/workflows/**Asked for as scoping data for the separate card the issue raises. 27 workflow files, 38 command substitutions outside comments. Triaged:
ci.yml:1010andlive-e2e.yml:140, the same line in both:run: echo "version=$(pnpm list @playwright/test --depth=0 --json | jq -r '…')" >> $GITHUB_OUTPUT. Status discarded twice over (echoowns it, and there is a pipe inside). The value feeds a cache key —key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}— so a failure degrades it toplaywright-Linux-(orplaywright-Linux-null, sincejq -rprintsnulland exits 0 for a missing field): a silently wrong cache bucket, milder than an unfiltered build but the same class.performance-budget.yml:138,echo "entry_file=$(basename $ENTRY_FILE)" >> "$GITHUB_OUTPUT". Same shape, no realistic failure.X=$(cmd)as a bare assignment does propagate underset -e, and the checked formif ! CHANGED=$(git diff …)appears five times (ci.yml×4,lint.yml).That distribution is the useful part for scoping: a naive
$(scan is 38 hits and almost all false positives, as the card warns — but the narrower rule "a command substitution whose whole value is interpolated into anecho … >> $GITHUB_OUTPUT" has exactly 3 hits repo-wide, 2 of them real. No fix here, no issue filed, no scan added — that is the separate card's call to make.Generated by Claude Code