Skip to content

ci(step7): the merge gate becomes a check, not a script in a drawer - #358

Merged
PhysShell merged 7 commits into
claude/p022-merge-gatefrom
claude/p022-merge-gate-ci
Sep 17, 2026
Merged

PhysShell merged 7 commits into
claude/p022-merge-gatefrom
claude/p022-merge-gate-ci

Conversation

@PhysShell

@PhysShell PhysShell commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Stacked on #357. That PR built a gate that refuses the wrong merge; nothing ran it. This is the first half of the wiring — a workflow that runs it against the commit a merge would produce, under two names a ruleset can require.

candidate merge tree  ->  mergegate_ci.py  ->  named CI check  ->  [ruleset: owner act]

The last arrow is not in this PR and cannot be: until a ruleset requires these contexts, the job reports and nothing is prevented.

Wiring exposed two questions the gate could not answer alone

Applicability. A required check runs on every pull request in the repository. mergegate.py refuses a tree that has no T0 in it — correctly, as a question about a contract that must be present — so as a required check today it would refuse every pull request, this one included. Verified, not assumed:

$ python scripts/step7/mergegate.py --repo . --commit HEAD
FAIL [t0_present] docs/notes/p022-263-t0-protocol-freeze.md does not exist at HEAD
merge gate: REFUSED

The wrapper settles applicability first: no frozen T0 at the merge commit means no merge here can make the contract reachable, so the check passes and names the reason. The moment a tree carries T0, every predicate applies in full. This is not a way around the gate — it is the difference between a gate and a wall.

Co-change. The workflow that runs on a pull request is the one on that pull request. A branch could otherwise carry the freeze and a weakened gate together, and be judged by the gate it brought with it. So:

The gate's own files — mergegate.py, this wrapper, the workflow — may not change in the same merge that introduces or changes the frozen contract.

Repairing the gate on its own stays ordinary work. That distinction is a control (ci-gate-repair-allowed), not a promise.

The two contexts

jobs:
  controls:  name: P-022 merge gate controls    # the gate's own 15 + this PR's 12
  gate:      name: P-022 merge gate             # needs: controls

gate needs controls: a gate whose own controls are failing must not be the thing that says a merge is safe, and an unreported job reads to a required check as unsatisfied. Those two strings are exactly what a ruleset must name — a required context nothing reports waits forever and looks like protection while being its opposite. A control fails if the workflow stops declaring them.

Applicability is decided once, in the tool that owns T0_PATH; a control fails if the workflow ever grows a second copy of that path in YAML.

Exit codes are the step-7 three: 0 proceed or not applicable, 1 refused, 2 the question could not be asked.

Controls

ci-not-applicable       a tree without the frozen T0 passes, and says why
ci-applies-in-full      a tree with it is judged by every predicate
ci-refusal-survives     a refusal from the gate is still a refusal here
ci-co-change-refused    the gate may not change in the merge that brings the contract
ci-gate-repair-allowed  repairing the gate on its own is ordinary work
ci-no-base-is-stated    without a base the co-change rule says it did not run
ci-misuse-is-two        a commit that does not exist is misuse, not a verdict
ci-absent-base-is-two   nor may an unavailable base be reported as a rewritten gate
ci-gate-files-exist     every file the co-change rule watches is really there
ci-workflow-names       the workflow names the contexts a ruleset must require
ci-workflow-derives-t0  the workflow hard-codes no path the tools already own
control-inventory       this list and the executed set are the same set

merge gate wiring controls: 12 passed, 0 failed; #357's 15 passed, 0 failed unchanged. Both suites were also run under Linux, which is where the job runs — a check that is green only on the author's Windows box is not wiring.

Against real commits:

commit exit result
this branch's head 0 not applicable — no T0 in the tree
full chain 270e3c1 0 allowed
#354 alone 8c87118 1 refused on three predicates

One defect found in this PR's own code

If --base named a commit the clone does not carry, every gate file looked changed on that side and the merge was refused — for a fact about the checkout's depth, not about the merge. The workflow sets fetch-depth: 0, so it would not have fired today; it would have fired the first hand-run, or the first shallow clone, and the refusal would have claimed the gate was rewritten. A false refusal teaches people the gate is noise, which is how a required check dies. The base is now validated like the merge commit: unreachable is exit 2, with the reason named.

What this still does not claim

Enforcement isolation. A repository administrator can turn protection off, and this script cannot see that. What it removes is the quiet path: a weakened gate arriving as part of the change it was meant to judge.

The remaining owner act

A ruleset on the target branch requiring both contexts. After that — and only then — #354 can leave draft.

🤖 Generated with Claude Code

https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh

#357 built a gate that refuses the wrong merge. Nothing ran it. A weapon in a
safe is not a guard at the door, so this is the wiring: a workflow that runs it
against the commit a merge would produce, under two names a ruleset can require.

Wiring it exposed two things the gate could not answer on its own.

**Applicability.** A required check runs on every pull request in the
repository. `mergegate.py` refuses a tree with no T0 in it — correctly, as a
question about a contract that must be there — so as a required check today it
would refuse every pull request, including this one. The wrapper settles it
first: no frozen T0 at the merge commit means no merge can make the contract
reachable, the check passes and says so. The moment a tree carries T0 every
predicate applies in full.

**Co-change.** The workflow that runs on a pull request is the one on that pull
request. A branch could otherwise carry the freeze and a weakened gate together
and be judged by the gate it brought with it. So the gate's own files —
`mergegate.py`, this wrapper, the workflow — may not change in the same merge
that introduces or changes the frozen contract. Repairing the gate on its own
stays ordinary work; that distinction is a control, not a promise.

The applicability decision lives in the tool that owns `T0_PATH`, never a second
copy in YAML, and a control fails if the workflow ever grows one. Another fails
if the contexts the workflow declares stop matching the ones a ruleset is told
to require — a required check nothing reports waits forever and reads as
protection.

Exit codes are the step-7 three: 0 proceed or not applicable, 1 refused, 2 the
question could not be asked.

11 wiring controls, 0 failed; the 15 gate controls unchanged. Both suites also
run green on Linux, which is where the job will run. Against real commits: this
branch's head is not applicable, the full chain is allowed, #354 alone is
refused on three predicates.

This is half the wiring. Until a ruleset requires these two contexts, the job
reports and nothing is prevented. #354 stays a draft until it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: c4a432b6-cfb8-46fb-869b-822ab89caecc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

ruff allows a sys.path insertion before an import, so the E402 suppression
was covering nothing and RUF100 said so. Caught by running the CI-pinned
ruff (0.15.8) rather than the one that happened to be installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
If `--base` named a commit the clone does not carry, `blob_sha` returned None
for every gate file on that side, the co-change rule read all of them as
rewritten, and the merge was REFUSED — for a fact about the checkout's depth
rather than about the merge. The workflow sets fetch-depth 0, so this would not
have fired today; it would have fired the first time someone ran the wrapper by
hand, or the first time a shallow clone was used to save a minute, and the
refusal would have been about the gate being rewritten. A false refusal teaches
people that the gate is noise, which is how a required check dies.

The base is now validated like the merge commit, and an unreachable one is exit
2 with a message that names the reason. A control builds exactly that case and
requires 2, refusing to accept 1.

12 wiring controls, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
tests/run_tests.py imports every test_*.py and calls `run()` on it. The new
suite called its entry point `main()`, so the runner reported it as a module
with no run() and ended the whole test job — after my own controls had printed
15 green lines, which is exactly the shape of a failure that looks like someone
else's.

Found by reading CI rather than by running the file directly: `python
tests/test_step7_mergegate_ci.py` passes either way, and that is precisely why
the local run said nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
The previous commit gave this module a run() for tests/run_tests.py to call —
onto a name a local helper already had. The helper won, the entry point was
shadowed, and eight controls started invoking themselves recursively instead of
the wrapper.

Caught immediately by ruff (F811) and by the suite dropping to 4 passed, 8
failed, which is what a repository's own checks are for. The helper is `invoke`
now and says in its docstring why it is not `run`.

12 wiring controls, 0 failed; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
@PhysShell
PhysShell merged commit 1ba9f60 into claude/p022-merge-gate Sep 17, 2026
67 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants