Uh oh!
There was an error while loading. Please reload this page.
fix(ci): collect every self-test verdict in a step, instead of masking after the first failure - #10887
Merged
Merged
Conversation
…g after the first failure GitHub runs a `run:` block as `bash -e <file>`, so the first non-zero exit aborts the step and every command after it is never reached -- neither green nor red, and nothing in the log tells those two apart. Measured, not hypothetical: lint.yml's `Shallow-history guard self-tests` ran three independent self-tests as a bare sequence with `git-history.mjs --self-test` first, and that one was red on `main` for about ten hours on 2026-08-21 (#10807). For the whole of that window `check-engine-split-ratio.mjs --self-test` and `collect-release-notes.sh --self-test` did not execute in CI once, on the step that gates every PR. Both were green, so the mask cost nothing that day; the expensive shape is the one it leaves behind, where a second regression lands unnoticed while the first is red and then reads as though the fix broke it. Both steps that had this shape now run every self-test unconditionally through a `run_self_test` collector, print a verdict per self-test, and exit non-zero at the end naming every one that failed. Not a split into one step per self-test: a plain split does not fix this at all, because Actions skips a job's remaining steps once a step fails, so the mask survives the split verbatim. Restoring the property would take an `if:` on each gate step, and a condition is a way for a PR to arrange that a gate does not run on it. Both gates that read step structure were checked and would have tolerated a split -- `check-shard-attestation` scans ci.yml only, and `check-required-contexts` pins job-level properties plus the single `check:required-contexts` step -- so the split was rejected on the merits, not because a gate refused it. `scripts/check-step-collectors.mjs` closes the class in both halves. Statically it requires any `run:` block invoking `--self-test` on two or more DISTINCT scripts to route them through a collector, and deliberately does not flag the `<gate> --self-test` + `<gate>` precondition shape that dominates this job. Dynamically -- the load-bearing half, since nothing static can tell a collector that propagates the exit code from one that swallows it -- its `--self-test` extracts each live block out of lint.yml and runs it as `bash -e <file>` against stubs with controlled exit codes, reading "did this command run" from the stub's own side effect rather than from the block's output. The same command list is also driven through the pre-fix bare sequence, which must mask. Census behind the narrow rule: 343 `run:` steps across 26 workflows, of which exactly two carried two or more distinct self-tests, both in lint.yml's `lint` job and both fixed here. Every other multi-command block is a precondition or a dependency sequence, where abort-on-first-failure is the correct semantics. Part of #10814 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
…r-broad #9465 fence The `Step-collector gate` step comment and the script docblock both claimed root `package.json` is "declared territory of the @changesets/cli v3 migration lane (#9465)". That reading is over-broad. The fence's own text scopes it to that file's `@changesets/cli` range and its `version` script -- the parenthetical in the issue body is scoping, not illustrative -- so the file itself is not fenced and a `check:step-collectors` key would have been allowed. The decision is unchanged and still correct: lint.yml already invokes several gates as `node scripts/...` directly, and dispatch-gates.mjs derives gate families from either spelling, so the direct form loses no discovery and adds no key to the root manifest. Only the stated REASON was wrong. Worth more than a silent reword, because it is this PR's own defect class one level up: a comment asserting a constraint nobody verified reads exactly like a verified one, and propagates to the next agent who reads it -- the same way a stale knownGap turns into a stale permission. So the accurate scope is now stated positively next to the decision rather than merely deleted. Only the two comments I authored are touched; the seven pre-existing instances of the same phrasing elsewhere in lint.yml are left alone as out of scope. Part of #10814 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
This was referenced Aug 21, 2026
os-zhuang
marked this pull request as ready for review
August 21, 2026 16:14
Uh oh!
There was an error while loading. Please reload this page.
Contributor
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32501894648 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
This was referenced Aug 21, 2026
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.
Fixes#10814
GitHub runs a
run:block asbash -e SCRIPT(a real shell, witherrexitset), so the first non-zero exit aborts the step and every command after it is never reached — neither green nor red, and nothing in the log tells those two apart.Measured, not hypothetical.
lint.yml's Shallow-history guard self-tests ran three independent self-tests as a bare sequence withgit-history.mjs --self-testfirst, and that one was red onmainfor ~10 h on 2026-08-21 (#10807). For that whole windowcheck-engine-split-ratio.mjs --self-testandcollect-release-notes.sh --self-testdid not execute in CI once — on the step that gates every PR. Both were green, so the mask cost nothing that day; the expensive shape is the one it leaves behind, where a second regression lands unnoticed while the first is red and then reads as though the fix broke it.What changed
Both steps carrying this shape now run every self-test unconditionally through a
run_self_testcollector, print a verdict per self-test, and exit non-zero at the end naming every one that failed.scripts/check-step-collectors.mjs(new) closes the class in two halves:run:block invoking--self-teston two or more distinct scripts must route them through a collector. Deliberately narrow: it does not flag the pairGATE --self-testthenGATE, this job's dominant shape, where the abort is the point (a checker whose own self-test failed has no verdict worth printing), norci.yml'smkdir -p/psql ALTER SYSTEMdependency sequences.--self-testextracts each live block out oflint.yml, writes it to a file, and runs it asbash -e SCRIPT— the invocation Actions uses — against stubs with controlled exit codes, reading "did this command run" from the stub's own side effect, never from the block's output. The block cannot vouch for itself.Evidence — predictions stated before running, at
05e3b98node scripts/check-step-collectors.mjs --self-test→ 59 assertions, 2 live blocks driven under a real errexit shell. Measured, for each of the two blocks (3 self-tests each):01111Both directions the card asked for: a failure in any one self-test no longer hides the others' verdicts, and the step still fails overall.
Ablation — the pre-fix shape, same command list, identical harness:
10A harness that cannot reproduce the defect cannot certify the fix, so that pair is pinned in the self-test too.
The census
Derived, not recalled: 343
run:steps across 26 workflows. Restricting tolint.yml+ci.yml, 21 of 200 steps hold two or more substantive commands (16 straight-line, 5 with control flow). Of all 343, exactly two sequenced two-or-more distinct self-tests — the two fixed here, both inlint.yml'slintjob:ts-parsesits first, is the slowest of the three and the only one that spawns real children, so it is the likeliest to be red: precisely the masking direction.Every other multi-command block is a precondition (
GATE --self-testthenGATE; anode -etimezone assert then the run) or a dependency (mkdir -p "$RUNNER_TEMP/…", thepsql ALTER SYSTEM/pg_reload_confpair), where abort-on-first-failure is the correct semantics. No pattern beyond these two, so no follow-up card.Why collect, and not split into three steps
⛔ A plain step split does not fix this at all — Actions skips a job's remaining steps once a step fails, so the mask survives the split verbatim. Restoring the property would take an
if:on each gate step, and a condition is a way for a PR to arrange that a gate does not run on it (the reason the required-context pin step carries none).The card's mandatory check, done anyway, and both gates would have tolerated a split — so this is a choice on the merits, not a constraint:
check-shard-attestation—scanWorkflowreadsjoin(root, '.github', 'workflows', 'ci.yml')and nothing else.lint.ymlis never opened, so no step edit here is visible to it. Its classification is also per step (stepsOf(job).some(...)), never over a job's joined text (check-shard-attestation.mjs misclassifies a shard job as an aggregate gate on a bare--verifysubstring anywhere in its run: text (false red, measured) #6589), so even inci.ymla split cannot move a job across the gate/attester boundary.check-required-contexts— asserts job-level properties (name:, nostrategy.matrix, no truthycontinue-on-error,merge_group:/pull_request:triggers) plus one step-level wiring assertion, which targets thepnpm check:required-contextsstep specifically. Neither is reachable from the shallow-history step's structure.Both re-run green against the edited file:
✓ check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s).and✓ check-required-contexts: 6 required context name(s) pinned across 2 workflow(s); 5 instruction surface(s) scanned against 2 retired name(s) (#9491).Gates run — union at
05e3b98, each quoting its own verdict lineDerived with
node scripts/pm/dispatch-gates.mjs(15 families) and re-derived unchanged after each commit; 14 of 15 run green, pluscheck:nul-bytes. Re-run in full at05e3b98, all green:pnpm check:type-check-debtwas not run. It refuses to measure rather than measuring a different world (#6376) — "--re-measure cannot run: 55 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk" — and clearing that refusal needs a full coldturbo run buildof every package in a shared container. This diff contains zero TypeScript (one workflow file, one.mjsunderscripts/), and the wiring half of the same script, which is what readslint.yml, ran green above. CI builds the closure before that step and measures it properly.lint.yml; re-checking immediately before the push found #10777 (open, draft) does. Its hunk is at ~line 1288, disjoint from these edits (~187 and ~600), and the step it adds —node scripts/pr-labels.mjs --self-test— is a single self-test, which the new gate does not flag. No conflict expected in either direction, but two branches are in flight on this file.Correction in
05e3b98(PM review)The second commit is comments only — no executable line changed, verified by filtering the diff to non-comment lines.
Both places where I explained the direct
node scripts/…invocation claimed rootpackage.jsonis "declared territory of the @changesets/cli v3 migration lane (#9465)". That reading is over-broad: the fence scopes to that file's@changesets/clirange and itsversionscript, not the file, so acheck:step-collectorskey would have been allowed. The decision is unchanged and stands on its own precedent — lint.yml already invokes several gates directly anddispatch-gatesderives families from either spelling — but the stated reason was wrong, and a comment asserting an unverified constraint reads exactly like a verified one and propagates to the next reader. That is this PR's own defect class one level up, so the accurate scope is now stated positively beside the decision rather than deleted. Only the two comments I authored were touched; the seven pre-existing instances of the same phrasing elsewhere in lint.yml are left alone as out of scope.skip-changeset: this PR is workflow +scripts/only and publishes nothing.Generated by Claude Code
Generated by Claude Code