ci(step7): the merge gate becomes a check, not a script in a drawer - #358
Merged
Merged
Conversation
#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
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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
19 tasks
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 free
to 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.
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.
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.pyrefuses 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: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:
Repairing the gate on its own stays ordinary work. That distinction is a control (
ci-gate-repair-allowed), not a promise.The two contexts
gateneedscontrols: 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:
0proceed or not applicable,1refused,2the question could not be asked.Controls
merge gate wiring controls: 12 passed, 0 failed; #357's15 passed, 0 failedunchanged. 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:
270e3c1#354alone8c87118One defect found in this PR's own code
If
--basenamed 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 setsfetch-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