test(ci): pin the branch-protection required-context job names (#6865) - #6983
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
…on (#6865) 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
|
|
PM ruling on the open question: option A — keep all eight rows, as shipped. From the dispatching Why A:
Also endorsed: dropping the card's proposed "no
Related, and separately filed rather than folded in: #6991 records that #5617's standing "never require" list is stale on 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 #6865
The defect
A GitHub required status check is matched by check-run name, and a job's check-run name is its
name:value. Nothing in a workflow file marks such a string as load-bearing, so renaming a job is a one-line edit with a silent, repo-wide consequence:The second shape is #5617 verbatim: PR #5584 merged with the ESLint job red for 19 minutes, because the job carrying 25
check:*gates was not in the required set at all. Four more merges repeated it the same night (#6067 / #6096 / #6051 / #6103) before the settings were fixed on 2026-08-07.ci.yml already writes this contract down in prose in eight places, and
check-shard-attestation.mjsasserts that the two aggregate gates' job ids still exist. Neither asserts aname:literal, and lint.yml had no note and no assertion at all.What this adds
scripts/check-required-contexts.mjs— a sibling ofcheck:shard-attestation, same idiom (pure exportedjudge(), a--self-testwhose fixtures mutate the real workflows and assert the mutation applied, missing input is a failure never a pass). Wired aspnpm check:required-contextsin the ESLint job, next to the shard-attestation gate.One registry maps workflow file + job id to the exact context name, each row carrying the ruling that authorized it:
ESLintlintTypeScript Type ChecktypecheckTest Coretest-gateDogfood Regression Gatedogfood-gateBuild Corebuild-coreBuild Docsbuild-docsConsole Pin Gateconsole-pinTemporal Conformance (live PG + MySQL)temporal-conformanceEight rows, not the six the card names. All six were re-verified against
origin/mainand all six resolve to real job-levelname:values — but four of them live in ci.yml, not lint.yml, which the card did not say.Test CoreandDogfood Regression Gateare added because their name literals have exactly the same gap:check-shard-attestationpins their job ids (REQUIRED_GATE_JOBS), soname: Test Corecan be renamed today with every gate still green. They are not invented membership — ci.yml declares both as required contexts in its own prose, #5617's audit marked them "should be required", and the closing ruling lists them as confirmation items rather than candidates. Drop the two rows if you disagree; nothing else changes.Per entry the pin asserts: the job id still exists; its
name:is exactly the registered literal; it declares nostrategy.matrix(a matrix job's check-run name has the matrix values appended, so a bare name never reports — and a skipped matrix job publishes the name unexpanded); and no truthycontinue-on-error(such a job cannot concludefailure, so it would publish a green required context over a failing run). Per workflow: amerge_group:trigger, and apull_request:trigger with nopaths:/paths-ignore:. Whole-registry: no context registered twice, and no unregistered job wearing a registered name — live ammunition here, since ci.yml's shardedtestjob is namedTest Core (${{ matrix.shard }}/3)beside a gate namedTest Core, and deleting the suffix collides them into one context whose conclusion is whichever finished last.The workflow-level assertions are the machine-readable form of #5617's audit exclusions, so enrolling one of the ⛔ contexts by mistake (
Console Pin Freshness,Spec property liveness,Validate Package Dependencies) fails here rather than in the merge queue.The card's proposed "no
if:" assertion was dropped, deliberately. #6865's body proposed it; it is right for lint.yml's two and wrong for four of the six added on 2026-08-09 —build-core,build-docs,console-pinandtemporal-conformanceeach carry a filter-drivenif:by design (THE FILTER CONTRACT, #4928). A job-levelif:that skips still publishes a check run; it is the workflow-levelpaths:filter that publishes nothing. Asserting "noif:" uniformly would have made this gate red on main the day it landed, against four correct jobs. The two aggregate gates needif: always()for a reason of their own (#3622) andcheck:shard-attestationalready owns that judgement — re-litigating it here would be the duplicate mechanism this card was told to avoid.It cannot verify that any of these names is actually in main's required set, or in the merge queue's check set. That configuration lives in Settings → Rulesets and no agent seat can read it:
GET /repos/.../branches/main/protectionanswers HTTP 403GitHub access is not enabled for this session. A gate claiming to check the required set would be a gate that cannot read the thing it names — the #4690 phantom-check shape.So the registry is this repo's declaration of what the settings are believed to reference, sourced from the #5617 rulings, and the pin enforces only the half that lives here: these names do not move. Concretely:
Reconciling registry against settings stays a human read; every row names its authorizing ruling so that read is one pass, not archaeology.
There is one further residual, recorded rather than implied: the wiring assertion is run by the step it pins, so a PR deleting both the step and the script is not caught — that is a deletion plainly visible in a
.github/**diff rather than a silent no-op, and closing it entirely needs a gate outside this file asserting this file's wiring. Same shape and same honesty ascheck-empty-changeset's consumer block (#6509).Verification
--self-test: 42 assertions, green. Beyond the in-test fixtures, five ablations were run against the real workflow files through the realpnpm check:required-contexts, direction predicted as RED before each run (exact-literal equality against a registry constant — no??chain and no count, so neither the "more diagnostics" nor the "inverted" direction applies here):name: Build Core→Build (core)(newly-approved batch) — RED,EXIT=1:ci.yml: job 'build-core' is named "Build (core)", but branch protection requires the context 'Build Core'.name: TypeScript Type Check→Type Check(original two) — RED,EXIT=1, naming jobtypecheckand the required context.wiring: lint.yml's ESLint job must run pnpm check:required-contexts — an unwired pin verifies nothing (#4690).--self-testfrom the package.json wiring — RED.merge_group:from lint.yml — RED, naming both contexts it would strand.Each restored cleanly to green afterwards. Ablations 1 and 2 additionally turned the self-test red via its anchor-staleness guard (a
String.replacematching nothing would otherwise leave the fixture judging the pristine workflow), which is the guard behaving correctly, reported here so the double red is not read as noise. No build artifacts are involved, so the stale-dist/trap does not apply.Neighbouring gates re-run green after the change:
check:shard-attestation(92 assertions),check:workflow-status-functions(23 workflows, 42 jobs),check:changeset-gate-self-tests(64 + 107 assertions — this is the one that carrieslint.ymlconsumer assertions, per the #6917 note; the new step does not interact with them),check:nul-bytes(6468 files),check:node-version, andeslinton the new file.Notes
package.jsonis private; the rest is.github/**andscripts/**).skip-changesetapplies.Generated by Claude Code