Uh oh!
There was an error while loading. Please reload this page.
ci(governance): require pinned human approval for governed-surface merges - #6739
Merged
Conversation
…rges The governed surface (AGENTS.md, CLAUDE.md, .claude/**, skills/**, docs/adr/**) is merged by a human, not by the queue. That rule lived only in prose until PR #6183: an AGENTS.md change was correctly parked as a draft, an update_pull_request call passing only `reviewers` silently set draft:false, the pull request entered the merge queue and landed as 5b3290f with no human approval, and converting it back to a draft did not dequeue it. Nothing in CI could have refused that. Adds the refusal, split by event because the split is the design: on a pull request the check is deliberately green and prints an early warning (a governed PR parked as a draft is the healthy end state, and a check red on the healthy case is a permanently red check); on a merge-queue build the same finding refuses unless an APPROVED review by an authorized approver is pinned to the pull request's current head sha. The path test runs before any request is built, so an ordinary diff costs zero API calls; an unreadable review list is a refusal with its own exit code, never a pass. Written objectui-native rather than registered as a pinned port: upstream splits the mechanism over two files whose register half is mostly a provenance engine for generated artifacts inside governed paths, and this tree has none of those artifacts. A port would have to declare that deletion, and check-upstream-port-parity's validatePin refuses a divergence whose ported side is empty, so a pin cannot express one. Making the context required is a branch-protection setting only the maintainer can flip; until then the queue leg reports without stopping anything. What the repository can write down is REQUIRED_CONTEXTS, and the check name is registered there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MnijPVVDakqK2J335JoJtq
os-zhuang
approved these changes
Aug 29, 2026
os-zhuang
approved these changes
Aug 29, 2026
os-zhuang
marked this pull request as ready for review
August 29, 2026 10:03
Uh oh!
There was an error while loading. Please reload this page.
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#6596
Session: https://claude.ai/code/session_01MnijPVVDakqK2J335JoJtq (durable copy — a body edit downgrades the footer link).
Maintainer ruling 2026-08-27 (「同意,并继续」, accepting Option A + C on #6325). The governed surface —
AGENTS.md,CLAUDE.md,.claude/**,skills/**,docs/adr/**(2026-08-18 definition) — is merged by a human, not by the queue. Until now that rule lived only in prose.Which shape, and why (measured, not assumed)
The card offered two candidates. Both have their enforcement half in branch protection, which no agent can flip, so the choice turned on what each one lands as repo files today:
--test PATH…, offline, zero APIThe last two rows decided it. #6183's mechanism was a PR whose state changed under a review nobody had given; a clearance that is not bound to specific bytes cannot see that class at all. And this repo already has a
merge_group-subscribing gate family with a written-down required-context register, so shape 2 slots into machinery that exists rather than needing new machinery of its own.Sibling parity was a supporting reason, not the deciding one: objectstack has run this mechanism since 2026-08-28.
The design: split by event
merge_group— a governed diff with no authorizedAPPROVEDreview pinned to the PR's current head is a refusal. The queue build is the last thing between a speculative merge andmain, and it is the path docs(agents): record how the Actions workflow registry is keyed, and the paginated-listing rule #6183 took.pull_request— the identical finding is an early warning that exits 0.The PR leg must not redden, and not out of politeness. A governed PR parked as a draft for the maintainer to merge by hand is this regime's healthy end state, so a check red on it would be red on the healthy case forever — and a permanently red check is one everybody learns to ignore. Red on the queue build is red on the anomaly.
types:namesready_for_reviewalongside the three defaults (namingtypes:replaces GitHub's default set rather than extending it). That is the addition that matters: flipping a governed draft to ready is the first move of the exact sequence this interrupts, and it is not in the default set.Two properties held in tension and reconciled by order, not tolerance: a diff touching nothing governed must never be blocked by an API hiccup, and an unreadable review list must never read as a pass.
runGuardreturns a verdict beforefetchReviewsexists as a possibility, so a clear diff costs zero API calls; on a governed diff an unreadable head or review list is a refusal with its own exit code (4, distinct from 3 for "nobody approved"). The self-test measures the first with a spy that throws if the API is touched at all — a mock returning[]would have passed against a version that called it.The port / parity decision: objectui-native, not a pinned port
Two measurements, both taken rather than argued:
check-governed-queue-guard.mjs(1,381 lines) andcheck-governed-merges.mjs(2,614 lines). The register half is mostly a multi-repo post-merge audit plus a provenance-recompute engine for generated artifacts sitting inside governed paths. This tree has none:.claude/workflows/does not exist here, andskills/carries no generator output (noreferences/_index.md, no react-blocks contract) — checked against the tree. Every row of that register is inapplicable.validatePininscripts/check-upstream-port-parity.mjsrefuses a divergence whoseportedside is empty. Driven directly against the shipped function with a deletion divergence, it returns the single problemfiles[0].divergences[0].ported is empty— exit 2, "the pin itself is unusable". A pinned port here is structurally impossible, not merely undesirable.So the divergence is declared in prose, in the file header, where a reader can act on it, and the obligation the pin would have carried is stated instead: when the sibling's predicate changes, this file is a hand re-read, not an automatic re-sync.
scripts/upstream-port-pin.jsonis untouched, andcheck:upstream-port-paritystill reports its two pinned files green.The toggle the maintainer must flip
Everything else is landed. The one remaining half is a repository-settings change:
Settings → Rules → Rulesets → the
mainruleset → "Require status checks to pass" → addGoverned Surface Queue Guard.It must also be in the merge queue's required set on the same ruleset — that is the set the
merge_groupbuild is judged by, and the refusal lives on that leg. Adding it to the PR-side required set alone changes nothing, because the PR leg is green by design.Until it is flipped, the queue leg reports without stopping anything. What this repository can write down it now does:
Governed Surface Queue Guardis registered inREQUIRED_CONTEXTS(scripts/dependabot-merge-gate.mjs), which is whatmerge-queue-reporting.test.tsderives themerge_groupsubscription floor from.GOVERNED_APPROVERSisos-zhuangandhotlong, and it is inherited, not ruled for this repo: it is the roster the sibling's maintainer ruled for its own governed surface on 2026-08-27 (「os-zhuang hotlong 批准算数」). objectui's card rules the mechanism; the roster is carried across because it is the same maintainer and the same surface. Nothing is blocked either way — the remedy the refusal prints first is back-to-draft plus a human merge, which needs no approver at all.Scope kept to the ruling
Two widenings deliberately not taken, and pinned so a later edit has to be a decision rather than a drift:
.github/workflows/**is not governed, so this guard does not govern its own workflow. Promoting CI config to a governed surface is a strictly larger rule than the one ruled.examples/AGENTS.mdand any vendored template copy stay ordinary source.Sibling-repo mirroring is explicitly not this card.
Honest note on the draft status: run against its own file list, the new gate reports this PR
NOT GOVERNED— 7 paths checked, none matched, exit 0. It lands as a draft for human merge because the card requires it, not because this guard demands it of itself.Files
scripts/check-governed-queue-guard.mjs--test,--self-test.github/workflows/governed-surface-guard.ymlfetch-depth: 0, self-test firstscripts/__tests__/check-governed-queue-guard.test.tsscripts/dependabot-merge-gate.mjsREQUIRED_CONTEXTSscripts/__tests__/merge-queue-reporting.test.tsMUST_SUBSCRIBE_MERGE_GROUPcontent/docs/guide/ci-cd-pipeline.mdci-cd-pipeline-doc.test.ts)package.jsoncheck:governed-queue-guard,governed⛔ No
paths:filter on either leg, and that is the load-bearing omission: a skipped job counts as success in branch protection, so a filter that mis-scoped would hand the queue a green verdict from a job that never ran — on the one check whose entire purpose is to refuse. The path test lives inside the script, where "nothing governed" is a verdict that says so and costs nothing.Gates — all run at
c4885f3, the commit this PR carriesExit codes captured with redirect-then-capture, never through a pipe.
check-governed-queue-guard.mjs --self-testOK check-governed-queue-guard self-test: 132 cases pass— exit 0npx vitest run scripts/__tests__Test Files 86 passed (86)/Tests 2404 passed (2404)— exit 0pnpm type-check:scripts--listFilesconfirms the new test file is in the program — 1 hit — so this covers it)pnpm lint:root28 problems (0 errors, 28 warnings)— exit 0; all pre-existing, and the CI gate ratchets errorscheck-lint-coverage.mjslint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).check-control-bytes.mjscheck-control-bytes: OK (scanned 5596 tracked text file(s); skipped 85 binary).check-entry-guard.mjs52 scripts/ file(s) — no entry guard outside the baseline; 0 file(s) still hand-type onecheck-upstream-port-parity.mjs2 ported file(s) match objectstack-ai/objectstack@bf10debd5 modulo their declared divergences.check-pre-install-import-graph.mjs20 pre-install step(s) in 17 job(s) run 19 scripts/ gate(s); 21 module(s) walked, every non-relative leaf a node builtin.check-changeset-presence.mjsNo source of a released package changed in this range, so no changeset is owed.check-doc-links.mjsLinks are valid across 17 scan roots.check-skills-paths.mjscheck-skills-paths: OK (93/94 stated path(s) resolve across 18 guide file(s); 1 baselined).check-shell-escape-residue.mjscheck-shell-escape-residue: OK (4/4 root(s) resolved …)check-doc-fence-languages.mjsevery TypeScript block in 223 document(s) is fenced ts/tsx/typescript …check-doc-component-types.mjsEvery documented component type is registered.Changeset: none, and that is measured rather than recalled —
check-changeset-presence.mjsreports7 file(s) changed, 0 of them published source of a package the release covers. Matches the precedent for scripts/workflow-only changes (#6672, the parity-gate PR: scripts + workflows + package.json + tests, no changeset). ⛔ Noskip-changesetlabel — it is a phantom in this repo andci-cd-pipeline-doc.test.tspins that.One red was found and fixed here rather than in CI:
tsconfig.scripts.jsontype-checks the pin tests withallowJs, so the optional-chain spelling over a union of frozen literals is a genuine TS2339. It is aninnarrowing now, with a comment saying why the obvious spelling does not compile.Generated by Claude Code