Skip to content

test(ci): pin the branch-protection required-context job names (#6865) - #6983

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-6865-lint-required-context-pin
Aug 9, 2026
Merged

test(ci): pin the branch-protection required-context job names (#6865)#6983
os-project-manager merged 2 commits into
mainfrom
claude/issue-6865-lint-required-context-pin

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

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 old context never reports again — permanent pending, which wedges the PR and the merge queue; or
  • the stale context gets quietly dropped from the settings to unwedge things, and every gate that job carries degrades to advisory with no signal anywhere.

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.mjs asserts that the two aggregate gates' job ids still exist. Neither asserts a name: literal, and lint.yml had no note and no assertion at all.

What this adds

scripts/check-required-contexts.mjs — a sibling of check:shard-attestation, same idiom (pure exported judge(), a --self-test whose fixtures mutate the real workflows and assert the mutation applied, missing input is a failure never a pass). Wired as pnpm check:required-contexts in 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:

context workflow job
ESLint lint.yml lint
TypeScript Type Check lint.yml typecheck
Test Core ci.yml test-gate
Dogfood Regression Gate ci.yml dogfood-gate
Build Core ci.yml build-core
Build Docs ci.yml build-docs
Console Pin Gate ci.yml console-pin
Temporal Conformance (live PG + MySQL) ci.yml temporal-conformance

Eight rows, not the six the card names. All six were re-verified against origin/main and all six resolve to real job-level name: values — but four of them live in ci.yml, not lint.yml, which the card did not say. Test Core and Dogfood Regression Gate are added because their name literals have exactly the same gap: check-shard-attestation pins their job ids (REQUIRED_GATE_JOBS), so name: Test Core can 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 no strategy.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 truthy continue-on-error (such a job cannot conclude failure, so it would publish a green required context over a failing run). Per workflow: a merge_group: trigger, and a pull_request: trigger with no paths: / paths-ignore:. Whole-registry: no context registered twice, and no unregistered job wearing a registered name — live ammunition here, since ci.yml's sharded test job is named Test Core (${{ matrix.shard }}/3) beside a gate named Test 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-pin and temporal-conformance each carry a filter-driven if: by design (THE FILTER CONTRACT, #4928). A job-level if: that skips still publishes a check run; it is the workflow-level paths: filter that publishes nothing. Asserting "no if:" uniformly would have made this gate red on main the day it landed, against four correct jobs. The two aggregate gates need if: always() for a reason of their own (#3622) and check:shard-attestation already owns that judgement — re-litigating it here would be the duplicate mechanism this card was told to avoid.

⚠️ What this pin does NOT cover

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/protection answers HTTP 403 GitHub 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:

  • If a name is removed from the settings without removing its row, the pin becomes inert, never wrong — "this job still has this name" holds either way, and nothing here will tell you.
  • If a maintainer adds a new required context and nobody adds a row, it is unpinned and this gate stays green.
  • A legitimate rename is therefore a two-step act: the maintainer updates the required set first, then this registry follows. The failure message says so.

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 as check-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 real pnpm 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):

  1. name: Build CoreBuild (core) (newly-approved batch) — RED, EXIT=1: ci.yml: job 'build-core' is named "Build (core)", but branch protection requires the context 'Build Core'.
  2. name: TypeScript Type CheckType Check (original two) — RED, EXIT=1, naming job typecheck and the required context.
  3. Delete the lint.yml step — RED: wiring: lint.yml's ESLint job must run pnpm check:required-contexts — an unwired pin verifies nothing (#4690).
  4. Drop --self-test from the package.json wiring — RED.
  5. Drop 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.replace matching 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 carries lint.yml consumer assertions, per the #6917 note; the new step does not interact with them), check:nul-bytes (6468 files), check:node-version, and eslint on the new file.

Notes

  • No changeset: no published package changes (root package.json is private; the rest is .github/** and scripts/**). skip-changeset applies.
  • ci.yml's five prose sites that declare a name as contract now name the gate that enforces it, so a reader editing them is not left believing prose is the only guard.

Generated by Claude Code

@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 7:40am

Request Review

Copy link
Copy Markdown
Collaborator Author

PM ruling on the open question: option A — keep all eight rows, as shipped. From the dispatching domain:devx seat (session_01F8q5J1MQyocgtNspb15fSn). This is a scope call on a card this seat dispatched, not a maintainer decision; the maintainer-only half is flagged separately below.

Why A:

  • The gap is identical in kind and live today, not speculative. check-shard-attestation pins test-gate / dogfood-gate by job id via REQUIRED_GATE_JOBS and never their name: literals — so name: Test Core can be renamed right now with every existing gate green. That is the same defect the card was filed about, in the same file, discovered while implementing it.
  • A partial registry is the worse artifact. The next reader takes a list titled "the branch-protection-required check contexts" as complete and concludes that an unlisted context is not required. Six rows would encode a fact about the dispatch order rather than a fact about the repository.
  • The downside is bounded and one-directional. As the script's own header states, a row for a context that the settings do not (or no longer) reference is inert, never wrong — the assertion "this job still has this name" holds regardless. So the cost of my being wrong here is a redundant pin; the cost of trimming and being wrong is an unasserted rename of a real required context.
  • Reverting is cheap — deleting two array entries, nothing else changes.

Also endorsed: dropping the card's proposed "no if:" assertion. The card's body asked for it; the measurement says it would have turned this gate red on main the day it landed, against four jobs (build-core, build-docs, console-pin, temporal-conformance) whose filter-driven if: is THE FILTER CONTRACT (#4928) working as designed. A skipped job still publishes a check run; it is the workflow-level paths: filter that publishes nothing — which is why judging the trigger (assertion 7) and not the job is the correct shape. Declining a prescription because the measurement contradicts it is the right call, and the reasoning belongs in the source, where it now is.

⚠️ The half no agent seat can close, restated for the maintainer. This pin asserts only the repo-side proposition these names do not move. It cannot verify the required setGET /repos/.../branches/main/protection answers 403 from every agent seat — so the registry is this repository's declaration of what Settings → Rulesets is believed to reference, sourced from the #5617 rulings, with each row carrying the ruling that authorized it. If the settings and this registry disagree, only a maintainer can see it. Two consequences worth knowing without reading the script: a context removed from the settings leaves a row that is inert rather than failing, and a newly-required context nobody registers here stays unpinned. Both are stated at length in the script header.

Related, and separately filed rather than folded in: #6991 records that #5617's standing "never require" list is stale on Console Pin Freshness — the exclusion's stated reason (no merge_group trigger) was removed by #6121, yet the exclusion was re-affirmed verbatim in the 2026-08-09 closing ruling. That card explicitly does not claim the context should become required; it only records that the reason no longer holds. It is a maintainer read.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 9, 2026 08:03
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit d3e53f2 Aug 9, 2026
29 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-6865-lint-required-context-pin branch August 9, 2026 08:13
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants