fix(ci): run the changeset family's --self-test in lint.yml, out of reach of skip-changeset (#6509) - #6917
Conversation
…each of skip-changeset (#6509) `check-empty-changeset.mjs` and `check-adr-0087-registration.mjs` were called from exactly one place in the repository: pr-automation.yml's `changeset-check` job, which a PR carrying `skip-changeset` is exempted from wholesale. A PR that edits a CI-internal script is the textbook case for that label, so a PR editing these two checkers was routinely the PR that skipped their own fixtures. The self-test halves are pure static checks with no merge-base dependency, so they move to lint.yml's ESLint job as one unconditional step. The real scans stay in `changeset-check`, which is where $MERGE_BASE exists — running them in a job with no branch point would be #6129 in the false-RED direction. The `chunks.length === 5` job invariant is untouched. `check-empty-changeset.mjs`'s consumer block gains 11 assertions pinning the new wiring: the step exists exactly once, carries no `if:`, lint.yml reads no label and carries no paths filter, the gates are invoked only through the self-test-only pnpm script, and that script covers both checkers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…#6509 Weakens the live allow-major matcher in pr-automation.yml from `grep -qxF` to `grep -qF`, i.e. reintroduces exactly the #5620-class defect that PR #6876's consumer assertions were added to catch — and which never executed once on #6876's own CI, because that PR carried skip-changeset. This PR carries skip-changeset too. Predicted direction: `Check Changeset` skips its self-test steps again, and the NEW lint.yml step goes RED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
…tcher (#6509) Restores `grep -qxF 'allow-major'` in pr-automation.yml, undoing 56f7018. That commit existed only to prove on CI that the new lint.yml step goes RED on a PR carrying skip-changeset; run 31294323300 is the evidence and this returns the branch to the intended state. pr-automation.yml is now byte-identical to origin/main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
|
Note on the red
That is the reverse verification the card asks for: a green step proves only that it ran, not that it can fail. It is not drift on The read is intact on No assertion was deleted, loosened or skipped to reach green. The ablation was reverted instead. Full run-by-run accounting, including an honest correction (I predicted 1 failure and got 3, because two further assertions locate that step by the same matcher), is in the PR body's "CI evidence" section. Generated by Claude Code Generated by Claude Code |
…6984) (objectstack-ai#7104) `scan()` 原本只枚举 `git ls-files`,也就是索引。一个刚写出来、还没 `git add` 的文件根本不在扫描集里,门禁照样 exit 0 并打印它惯常的成功 行 —— 读起来是「这棵树没有裸控制字节」,而不是「你刚写的那个文件没被 看过」。 这个洞的形状恰好等于本门禁自己的事故源:每一例裸字节都来自编辑工具在 作者「正在写关于这个字节的内容」时把转义落成真字节(objectstack-ai#4763 / objectstack-ai#4890 / PR objectstack-ai#5140 / objectstack-ai#5460 的 issue 正文),而落点必然是一个**新文件**。于是 agent 指令要求的那次「push 前跑一遍」,正是最可能在看一个索引枚举够不 到的文件的那次运行。objectstack-ai#6984 实测过:一个未跟踪文件里躺着裸 0x1b, AGENTS.md 的 self-scan 找得到,本门禁绿,唯一的差别是索引。 - 扫描集改为「索引 + `--others --exclude-standard`」,即 git 既不跟踪 也不忽略的工作区文件。 - 成功行改为同时报出两半:`scanned N text file(s) -- N tracked, M untracked-not-ignored`。M 为 0 时**照样打印**,否则 CI 的绿和 objectstack-ai#6984 之前那种绿会渲染成同一句话,而后者正是本单要消掉的假信心。 - 未跟踪的命中在报错行上标 `[untracked]`——「还没 add」是作者拿到 一个被拒文件后第一个需要知道的事实。 - `--list` 逐条列出未跟踪的那一半路径。 三项实测,不是断言: 1. `--exclude-standard` 让本地垃圾留在外面。本仓库在完整 `pnpm install` 之后,未跟踪路径 76122 条,过 `--exclude-standard` 之后 0 条;它是剪 枝而不是遍历,16ms vs 索引枚举的 5ms。EXCLUDED 仍叠加在上面,所以有 人把 node_modules 取消忽略也点不红。 2. CI 里是 no-op:workflow 检出的是一个 commit,全树受跟踪,未跟踪那一 半恒为空。`--self-test` 用「把样本 stage 掉再扫一遍」证明,而不是在 注释里声称。 3. 反向:回退 `enumerate()` 的未跟踪那一半,6 条新断言立刻转红。 `--self-test` 断言数 48 → 75。临时仓库额外把 `core.excludesFile` 指向 一个空文件,否则开发者的全局 ignore 会让「EXCLUDED 仍然生效」那条样本 因为另一个原因通过。 工具链改动,不涉及任何 package,不发版 —— 走 `skip-changeset`,与近期 scripts/ + workflow-only PR 的做法一致(objectstack-ai#7048 / objectstack-ai#7008 / objectstack-ai#6983 / objectstack-ai#6917)。 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Fixes #6509
The defect
scripts/check-empty-changeset.mjsandscripts/check-adr-0087-registration.mjswere called from exactly one place in the repository: thechangeset-checkjob of.github/workflows/pr-automation.yml(:681-682). That job is exempted wholesale when a PR carriesskip-changeset— job-levelif:plus the two per-step label reads. And "this PR edits a CI-internal script" is the textbookskip-changesetcase: such a PR releases nothing, so by the workflow's own prescription it takes the label.The consequence is the exact inversion of #4690: a PR that edits these two checkers is the PR most likely to skip their own fixtures.
Premise re-verified on
origin/main@6968885efgrep -rlnover.github/workflows/for all three script namespr-automation.ymlonly, for all threegrep -c "run: pnpm check:" .github/workflows/lint.ymlcheck:empty-changesetnorcheck:adr-0087-registrationis among themskip-changesetand added 5 consumer assertions31289894461, jobCheck Changeset(93185167327): step 12Reject an empty-frontmatter changeset added by this PR— the only thing that runs--self-test— reportsconclusion: skipped. Steps 13/14/15 likewise. Every one of those 5 assertions was verified locally and by nothing else.Both directions of the gap are real and they are not symmetric: a broken assertion is merely deferred onto the next unlabelled PR (an unrelated author eats the red), while a deleted one is silent forever, because nothing afterwards remembers it existed.
Direction taken: candidate 1, and why it survives contact with the code
The card's candidate 1 and #6883's variant A converge: wire the
--self-testhalves intolint.yml, leave the real scans inchangeset-checkwhere$MERGE_BASElives. Measured before implementing, not assumed:scripts/and.github/into a directory that is not a git repository at all (git rev-parsethere:fatal: not a git repository) and running both self-tests from there: both exit 0 (53 and 107 assertions). They build their own throwaway repos in$TMPDIRand read two files. ~0.8s + ~5.0s.pr-automation.yml.lint.ymlis the right host:on: pull_request:with nopaths:filter and anon: merge_group:trigger, so the step runs on every PR and every queue build, and the workflow reads no PR label anywhere.⛔ The
chunks.length === 5job invariant is untouched. The consumer block scopes that count to thechangeset-checkslice ofpr-automation.yml; this PR adds no step to that job and adds no failable step anywhere near it. The alternative that redraws that boundary was not needed and was not taken.pr-automation.ymlis byte-identical toorigin/mainon this branch (git diff origin/main --name-onlylists only the three files below).check-changeset-no-major.mjsis deliberately not wired: it has no--self-testto run. The new pin says so in its own assertion message, so the omission does not read as an oversight. Its lack of fixtures is filed separately as #6923.What changed (3 files, +167)
package.json— one new script, following the established self-test-only pattern already used bycheck:release-body,check:stall-guardandcheck:objectui-changeset:"check:changeset-gate-self-tests": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-adr-0087-registration.mjs --self-test".github/workflows/lint.yml— one step in the ESLint job,- name: Changeset-family gate self-tests, deliberately with noif:. An exemptable self-test is the card itself.scripts/check-empty-changeset.mjs— 11 new consumer assertions (53 → 64) pinning the new wiring, in the block that already pins this family's workflow shape (Check Changeset 会因为「别人的 PR 合进了 main」而变绿 —— merge ref 里的他人 changeset 被算成本 PR 新增的 #6129/Check Changeset 的首跑对 skip-changeset 路线结构性必红:job 在 PR 打开瞬间读标签,而标签只能在创建之后打上(今日实测复现 21 次) #6378/观察单:Resolve the diff base是 Check Changeset 里唯一只认快路径标签读取的可失败步骤,标签晚到 + git 基准不可用时会红一个本该豁免的 PR #6434/pr-automation.yml 的 allow-major 步骤仍从事件载荷读标签:#5580 同款竞态的孪生体(pre-mode 期间休眠) #5620 all live there, so the next reader has one place to remember).CI evidence — the gap, and the gap closing, on this PR's own runs
This PR carries
skip-changeset: it edits.github/, rootscripts/and aprivate: trueroot manifest, so it releases nothing and route 2 of the workflow's own prescription applies. That makes it the exact PR shape the card is about, so the card's demand — did your own new wiring actually execute on your own CI? — is answered by measurement here, not by prose.1. The defect, reproduced on this PR (commit
7a0e452b0)Check Changesetjob93196347946(run31294147110):Steps 12/13 were the only two places in the repository that ran
--self-test. Identical to PR #6876's run31289894461. On the two later commits the whole job is skipped at job level (the label is in the payload by then).2. The wiring executing — same commit, same label
ESLintjob93196348012(run31294147084), conclusion success:Same commit, same PR, same label: skipped in
changeset-check, executed inlint. That is the whole fix, measured.3. Reverse verification ON CI — a deliberate red, predicted in advance
A green step only proves it ran; it does not prove it can fail. So commit
56f701810deliberately reintroduced a real #5620-class regression intopr-automation.yml— one character class,grep -qxF 'allow-major'togrep -qF 'allow-major'— and commit0290e7747reverted it. That ablation targets an assertion PR #6876 added and never executed, on a PR carrying the same label #6876 carried.Predicted before pushing:
Check Changesetskips its self-test steps again, and the newlint.ymlstep goes RED.Observed —
ESLintjob93196831253(run31294323300), conclusion failure:and in the same run,
Check Changesetjob93196809939: skipped. Nothing else in the repository could have caught it.The failure text:
Honest correction to the prediction: I predicted 1 failure and got 3. Direction and primary message were right, count was not — because two further assertions locate the allow-major step by that same matcher, so removing it makes them fail too rather than pass vacuously. Recorded as observed rather than reshaped to fit the prediction.
This red was not drift on
main, and the check is cheap to redo:The read is intact on
main, and the commit that introduced it is PR #6876 itself — the specimen this card is built on. So the loop closes exactly: the assertion #6876 shipped without ever running is now demonstrated to be live, and to catch a regression to the very line #6876 added, on a PR carrying the label that hid it. No assertion was deleted, loosened or skipped to reach green — the ablation was reverted instead, andpr-automation.ymlis byte-identical toorigin/main.Not a stale-artifact green either (AGENTS.md §9): these are
.mjsfiles run directly bynode, nodist/anywhere in the path, and the ablation moved the step from green to red and back.Reverse verification — the five local ablations, direction predicted before each run
Run against a sandbox copy of
scripts/+.github/+package.json, all five predicted RED:lint.ymlstepskip-changesetlabelif:pinif:pin and the "lint.yml must not readskip-changeset" pin)pnpm check:empty-changeset(the full check)paths:tolint.yml'spull_request:triggerResidual, recorded rather than implied
The new assertion is run by the step it pins, so a PR that deletes that step and carries
skip-changesetis still not caught — both places that would have run it are gone in the same diff. That is a strictly smaller hole than the one it replaces (which swallowed every labelled PR, including one that merely edits an assertion), it is a deletion plainly visible in a.github/**diff rather than a silent no-op, and closing it entirely would need a gate outside this family asserting this family's wiring — a coupling with its own cost. The comment in the source says the same thing, so the next reader inherits the fact and not a false sense of closure.Gates run locally
pnpm check:changeset-gate-self-testspnpm check:workflow-status-functionsif:(proves the editedlint.ymlstill parses)pnpm check:node-versionpnpm check:nul-bytesnode scripts/check-type-check-coverage.mjs --self-testlint.ymland the root manifest; unaffecteddoc-authoring,docs-audit-scope,role-word,quick-reference-counts,adr-anchors,org-identifier,release-notes,release-body,shard-attestation,published-files,merge-driver,stall-guard,objectui-changeset)pnpm exec eslint scripts/check-empty-changeset.mjs --no-inline-configBoundaries
⛔ Untouched on the final tree:
docs/adr/**,content/docs/releases/**,pr-automation.yml(byte-identical toorigin/main), thechangeset-checkjob and itschunks.length === 5invariant, the counting/merge-base logic,check-changeset-no-major.mjs, and every existing assertion in either checker.Out-of-scope finding filed, not fixed here: #6923 —
check-changeset-no-major.mjshas no--self-test, and its enforcing half is unexecuted on every run today because Changesets is in pre-mode.Generated by Claude Code