Skip to content

ci(governance): machine-enforce the ADR merge prohibition — docs/adr/** requires the maintainer's own approval (#6741) - #6802

Draft
os-project-manager wants to merge 1 commit into
mainfrom
claude/issue-6785-adr-merge-gate
Draft

ci(governance): machine-enforce the ADR merge prohibition — docs/adr/** requires the maintainer's own approval (#6741)#6802
os-project-manager wants to merge 1 commit into
mainfrom
claude/issue-6785-adr-merge-gate

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #6785

Machine enforcement of the #6741 ruling (maintainer, verbatim, untranslated):

「adr 只能由维护者自己确认,人工合并,ai 不得擅自合并。」

Prose enforcement was measured insufficient the day the ruling landed: filed 13:52Z on 2026-08-08, and within the following hour two different AI-operated seats merged docs/adr/** PRs — #6671 at 14:23:32Z (os-zhuang) and #6732 at 14:38:56Z (os-project-manager, while the PR was in draft state). Neither was the maintainer; both carried zero reviews of any kind. Declared = enforced applies to governance exactly as it applies to metadata, so the prohibition now lives on the GitHub side.

What this PR lands (Half 1 — in-repo, self-serve)

File What it is
scripts/check-adr-merge-approval.mjs The gate + --self-test + replay modes (--pr N, --files-json/--reviews-json)
.github/workflows/adr-merge-approval.yml Standalone workflow: pull_request + pull_request_review + merge_group, deliberately no paths filter
.github/CODEOWNERS Routes docs/adr/ — and the enforcement chain itself — to @hotlong
package.json One check:adr-merge-approval script entry (local convenience; the workflow calls node directly, no install)

Decision rule: a diff that does not touch docs/adr/** passes with zero API lookups (structurally — the review fetch is a lazy thunk the clean path never invokes). A diff that touches it passes only with an APPROVED review from the maintainer's own account.

Design points, each with its receipt:

  • No path filter on any trigger (objectui#3523, restated in objectui#3769): a path-filtered required check never creates a run on non-matching PRs, and a required context that never reports hangs the merge queue until the ruleset's 60-minute timeout. The script reads the diff and decides.
  • pull_request_review trigger = the zero-friction green path: an approval does not fire pull_request, so without it the red check would sit red after the maintainer approves until re-run by hand. With it, the approval itself re-runs the gate to green. No extra step for the maintainer.
  • merge_group: the PR is resolved from the queue ref (the gh-readonly-queue/.../pr-N-sha spelling embeds the number), falling back to the head commit subject (merge and squash spellings), then to the commit's associated PRs via the API. Unresolvable is a loud failure, never a skip. The diff is narrowed to the PR's own slice of the group when the head commit provably belongs to the ref-named PR; otherwise the whole-group diff is used — a fail-closed over-approximation, argued in the script header.
  • The approver set is a hard-coded constant, on purpose: repo/org Actions variables are writable by the shared agent identities (os-*, claude, yinlianghui), and those identities granting themselves approval rights is exactly the failure this gate closes. Matching is by numeric account id (login squats do not count). hotlong = 50353452 is verified, not assumed: 2,153 commits on main are authored as 50353452+hotlong@users.noreply.github.com, GitHub's id+login noreply form (the MCP collaborator listing does not show org owners, so the git history is the confirmation source).
  • Every missing input fails loud (check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690 anti-pattern, filter job 一旦失败,Test Core / Build Core / Dogfood 会全部 skipped 而分支保护判为通过 —— 隐式 success() 今天已第三次咬人 #4928 filter contract): unresolvable diff base, failed git diff, unresolvable PR on a queue build, unfetchable or non-array review list — all exit 1 with the reason, never a silent pass.
  • The enforcement chain protects itself via CODEOWNERS, not via the CI check: the CI check stays scoped to docs/adr/** (this PR itself must — and does — pass the not-an-ADR-diff path), while CODEOWNERS routes the gate script, its workflow and CODEOWNERS itself to the maintainer, so once Half 2's code-owner-review rule is on, the gate cannot be lifted by editing the enforcers instead of the ADRs.

Review-state semantics: the maintainer's latest state-setting review decides (APPROVED / CHANGES_REQUESTED / DISMISSED; a later COMMENTED does not revoke, a later CHANGES_REQUESTED or a dismissal does).

Acceptance evidence

Historical replay — real API inputs, direction predicted RED before running. File lists and review lists for #6671 and #6732 were captured from the live GitHub API on 2026-08-08 (pulls/N/files, pulls/N/reviews; both review lists really are empty) and replayed through the shipped decision path (--files-json/--reviews-json):

❌  This change touches docs/adr/ and carries no APPROVED review from the maintainer's own account.
      • docs/adr/0048-cross-package-metadata-collision.md        (PR 6671 replay — EXIT=1)
      • docs/adr/0079-record-display-name.md                     (PR 6732 replay — EXIT=1)

Both violations are also pinned as inline fixtures in --self-test, RED forever, alongside their inversion (same file lists + a maintainer-approval fixture → GREEN), which pins that the red is about the missing approval, not about ADR diffs being unmergeable per se.

Red/green paths, direction predicted before each run — all confirmed:

Fixture Predicted Result
docs/adr/** diff, zero reviews RED RED (exit 1)
Same diff + hotlong APPROVED GREEN GREEN
Same diff + APPROVED by os-zhuang, os-project-manager, yinlianghui, claude[bot] only RED, verdict names the non-counting approvals RED, named
Same diff + APPROVED by login "hotlong" with a different account id RED RED
hotlong APPROVED then CHANGES_REQUESTED / then DISMISSED RED RED
hotlong CHANGES_REQUESTED then APPROVED / APPROVED then COMMENTED GREEN GREEN
Non-ADR diff, reviews thunk that throws if invoked GREEN, thunk never runs GREEN, zero lookups proven
End-to-end synthetic repo: gated diff, PR unresolvable RED, loud RED (exit 1, names every resolution source tried)
End-to-end synthetic repo: PR resolved from a gh-readonly-queue ref, review list unfetchable RED, loud RED (exit 1, HTTP status in the message)

--self-test: 27 assertions over the real decide() / review-state / PR-resolution functions, no imitations.

Self-referential smoke: this PR touches .github/, scripts/ and one package.json line — the gate run in gate mode on this branch reports GREEN via the not-an-ADR-diff path with zero lookups, and the workflow on this PR is the live demonstration that the check creates a run and reports on a non-ADR PR.

Local verification (under the shared verify lock): pnpm lint green; all 32 family gates of lint.yml's ESLint job green (including check:workflow-status-functions, check:node-version, check:adr-anchors, check:nul-bytes over the new files) plus the TypeScript job's root-level gates. Nothing under packages/ changes, so the workspace build/typecheck surface is untouched by this diff; CI's own run on this PR is the authoritative pass.

Half 2 — repo settings, for @hotlong to apply by hand

This PR deliberately changes no repository settings (the point of #6785 is that governance actions belong to you). Two switches, both under Settings:

A. Make the new check required (binds the CI half):

  1. Settings → Rules → Rulesets → open the ruleset targeting main (or "New branch ruleset", target branch main).
  2. Enable "Require status checks to pass" if not already on.
  3. "Add checks" → search for ADR maintainer approval (the job name; it appears in the picker once this PR's workflow has run at least once) → add it.
  4. Save changes. (Classic branch protection: Settings → Branches → edit the main rule → "Require status checks to pass before merging" → add ADR maintainer approval.)

B. Require code-owner review on the routed paths (binds the CODEOWNERS half):

  1. In the same ruleset (or classic rule), enable "Require a pull request before merging" if not already on.
  2. Tick "Require review from Code Owners".
  3. Save. Because CODEOWNERS routes only docs/adr/ and the three enforcement files, this adds a required review from you on exactly those paths and changes nothing anywhere else.

C. Optional hardening, recommended: in the same pull-request rule, tick "Dismiss stale pull request approvals when new commits are pushed". The CI gate accepts your standing APPROVED review; dismissal-on-push closes the window where content changes after your approval while the approval stands.

Your own flow stays exactly two clicks: approve, merge. The gate goes green on your approval automatically (the pull_request_review trigger re-runs it).

Scope guards honoured

  • No edits under docs/adr/**; the red-path demonstration used a throwaway synthetic repo in the session scratchpad.
  • This gate governs merging only — drafting ADR PRs stays open to every seat.
  • No repository settings were modified via API.
  • No changeset: nothing under packages/ changes, nothing is published or released by this PR — skip-changeset applied.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn


Generated by Claude Code

…the GitHub side

A PR whose diff touches docs/adr/** is no longer mergeable without an
APPROVED review from the maintainer's own account (hotlong, matched by
account id). Prose did not propagate: two AI-seat merges of docs/adr/**
PRs landed within an hour of the ruling (#6671, #6732) — both are pinned
RED forever as replay fixtures in the gate's --self-test.

- scripts/check-adr-merge-approval.mjs: the gate. No path filter on the
  trigger (objectui#3523); zero API lookups on a clean diff; merge_group
  PR resolution from the queue ref with subject/API fallbacks; every
  missing input fails loud (#4690, #4928). Approver set is a hard-coded
  constant on purpose — repo variables are writable by the shared agent
  identities that are the root cause.
- .github/workflows/adr-merge-approval.yml: standalone workflow
  (pull_request + pull_request_review + merge_group), reports on every PR.
- .github/CODEOWNERS: routes docs/adr/ and the enforcement chain itself
  to the maintainer, for the ruleset half only he can flip.

Fixes #6785

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
@vercel

vercel Bot commented Aug 8, 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 8, 2026 6:05pm

Request Review

@github-actions github-actions Bot added size/l ci/cd dependencies Pull requests that update a dependency file labels Aug 8, 2026
@os-project-manager os-project-manager added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed ci/cd dependencies Pull requests that update a dependency file labels Aug 8, 2026 — with Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants