Skip to content

feat(automation): refuse a merge on the fork with a required check (#812) - #829

Merged
Birfy merged 1 commit into
mainfrom
fix/812-fork-merge-gate
Oct 23, 2026
Merged

Birfy merged 1 commit into
mainfrom
fix/812-fork-merge-gate

Conversation

@Birfy

@Birfy Birfy commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Written-by: Dev
Fixes #812

What changed

main on Birfy/FlyBot gets a gate GitHub enforces, instead of a script somebody has to remember to run. The ruleset that requires the check is in the pull request but not applied by it — see the sequencing at the end.

  • .github/workflows/enqueue-guard.yml — on pull_request_target, pull_request_review, issue_comment (created/edited/deleted) and a manual run, it judges the live head with code checked out from main (ref: main; no pull request code is fetched or run — the comment body is only parsed) and writes the result as a check run named enqueue-guard. pull_request_target, not pull_request: a pull_request job from a fork is read-only and could not write the check for the head it judges, and a required context that is never produced leaves that pull request on "Expected" forever (Merge gate is not a required check on the merge queue, so a red gate does not block a merge #639).
  • .github/scripts/gate-check.mjs — turns the decision into that check run. It calls decidePull in enqueue-guard.mjs (which calls merge-gate.judge and enqueue-guard.guard), the same function behind the command the guard prints, so the check and the command cannot disagree — a second copy of the rules is the issue's kill condition. The read takes the endpoint’s check_runs array out of the { total_count, check_runs } envelope, and external_id is the head: a label or a comment PATCHes that one run, a new commit is a new head and a new run, and a stale green never answers for a new tip. A refusal is a red check, not a red job; --dry-run judges and prints and writes nothing.
  • .github/scripts/enqueue-guard.mjs — decidePull extracted out of main() so both readers share one function; the printed ALLOW/REFUSE output and the command are unchanged (its 26 existing tests pass untouched).
  • .github/agent/merge-gate-ruleset.json and .github/scripts/gate-ruleset.mjs — the ruleset body (branch refs/heads/main, enforcement: active, bypass_actors: [], required tests-ok + enqueue-guard, strict_required_status_checks_policy: false so one merge on main does not force every open pull request to rebase, no approvals required because T2/T3 need none) plus --check (read-only) / --apply / --rollback. --apply re-reads what GitHub saved and fails on a bypass actor, current_user_can_bypass other than never, a missing context, or strict checks.
  • AGENTS.md → Merge gates (the fork paragraph says the ruleset is in place once --apply has run, because this pull request does not apply it), docs/autonomous-dev.md (new subsection), the testing-layers row and one changelog fragment.
  • Review fixes: the check-run read was --jq '.[]' on an endpoint that answers { total_count, check_runs }, so the update branch was unreachable and every event added a run; it is .check_runs[] now and the test fake answers the envelope the way the API does. --rollback now has a fixture that keeps the ruleset listed after the DELETE and requires the rollback to fail on it.

Out of scope, as #812 says: the upstream ruleset (Release owns it), a merge queue on the fork, backfilling labels on already-merged pull requests.

Verification at the tip

Tip SHA: 9e60f7484beb08dfa0cb1bcbd071bbe117efb7a9

Ran:

  • node --test .github/scripts/gate-check.test.mjs .github/scripts/gate-ruleset.test.mjs → 27/27 pass (16 + 11).
  • node --test .github/scripts/enqueue-guard.test.mjs .github/scripts/merge-gate.test.mjs .github/scripts/review-gate.test.mjs → 97/97 pass; the guard's suite is unchanged and green on the refactor.
  • node --test ".github/scripts/*.test.mjs" → 315 tests: 309 pass, 4 fail, 2 skipped. All 4 are in ds-bot-patches.test.mjs, which shells out to git diff --no-index / git apply; this box has no git, and the same 4 fail on the base commit. The 2 skips say git and bash are needed. CI has both and runs them.
  • node .github/scripts/testing-layers.mjs --check → 328 test files in 14 workflows, all classified.
  • node .github/scripts/changelog.mjs check → 10 fragments well formed.
  • PATH=<shim> GH_TOKEN=… node .github/scripts/gate-ruleset.mjs --check --repo Birfy/FlyBot → NOT GATED — no ruleset named "main merge gate" on refs/heads/main: main is unprotected (exit 1): the read path works against the live API and reproduces the issue's premise.
  • node .github/scripts/gate-check.mjs --dry-run against PR ci: Compatibility nightly + local Mac CI (follows #31) #35 on the live API, writing nothing → enqueue-guard: success, enqueue #35: no \hold` and the gate passes, newest conclusion PASS by Birfyatf027324, labels agent-pr, pass. That run also caught a defect the unit tests could not see: the two ghcall shapes (variadic reads,[args]+ JSON body for writes) were wired the wrong way round, and the real CLI rejected it while the fake accepted both. Fixed inrealGh`; the live dry run is now part of verifying this file.

Not run (and why):

  • actionlint 1.7.7 — this box has no curl and github.com is unreachable, so the binary cannot be fetched. The workflow is parsed locally with a YAML parser to confirm the four triggers, permissions.checks: write, the concurrency group and the job if; CI's package job runs actionlint for real.
  • npm run verify in full — nothing under core/, plugins/ or patches/ changes, and .github/scripts is in neither its lint nor its suite list; the script suites CI runs are the two commands above.
  • --apply / --rollback against the live repository — they change repository protection, which this Bot does not do (AGENTS.md → Hard rules).
  • The workflow itself — pull_request_target and issue_comment take their workflow file from the default branch, so its first run is after this merges. That is the sequencing below, not an omission.

Evidence that the tests pin the change

Every mutation was reverted immediately after:

  • every conclusion forced to success → 6 of the 16 gate-check tests red;
  • planCheck ignoring external_id → 1 red (a check for another head is never reused);
  • the workflow's checks: write changed to checks: read → 1 red;
  • the guard's missing label: agent-pr rule dropped → 4 red across both suites (2 in enqueue-guard.test.mjs, 2 in gate-check.test.mjs). One rule, two readers — the property The fork's main has no ruleset and no branch protection, so a PASS is only advisory and an unreviewed pull request can be squash-merged #812 asks for;
  • the check-run read back to --jq '.[]' → 3 red (the red→green flip on one run, the new envelope case, and the comment-after-refusal case), because the endpoint answers an envelope and that filter yields a number and the whole array;
  • --rollback's list re-read deleted → 1 red (its fixture keeps the ruleset listed after the DELETE and requires the rollback to say so).

Merge gates and the switch

Tier T3 (a workflow plus two new scripts under .github/), so it needs a PASS at this tip; CI runs tests-ok on the head as usual.

Applying the ruleset is deliberately not part of this merge, and the order is not optional:

  1. Merge this workflow.
  2. Prove the check: a deliberately incomplete pull request (no agent-pr) must show enqueue-guard: failure with the guard's exact REFUSE sentence, and a complete one must show success; on one head it must also flip red → green without a new commit when the label or the PASS arrives.
  3. Only then does the owner apply .github/agent/merge-gate-ruleset.json (node .github/scripts/gate-ruleset.mjs --apply --repo Birfy/FlyBot), and --check must then pass on the read-back.

Applied first, every open pull request reads "Expected — waiting for status" and none can merge.

/commits/<head>/check-runs answers { total_count, check_runs }, so
`gh api --jq '.[]'` printed a number and then the whole array. planCheck saw
neither with a name, and every event POSTed another enqueue-guard run for the
same head instead of PATCHing the one already there: the update branch was
unreachable, and the step-1 proof in #812 would have shown a second run rather
than a flip. The read is `.check_runs[]` now, the test fake answers the envelope
and slices it the way --jq does, so the wrong filter is visible in the suite, and
a test drives two judgments of one head into one POST and one PATCH.

Two more from the same review. --rollback proves the ruleset is gone by reading
the list again, and its test asserted the fake's own bookkeeping instead: a
fixture that keeps the ruleset listed after the DELETE now fails the rollback.
AGENTS.md says the ruleset is on the fork's main once --apply has run, not
before, because this pull request deliberately does not apply it.

Fixes #812
@Birfy

Birfy commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at 9e60f74
Written-by: Reviewer
Re-posted at #829: the same head, the pull request migrated from Birfy#39; the verdict was written on that pull request and nothing about the tip changed.

The INC at dbb140de is fixed at the place it hurt: applyCheck reads --jq '.check_runs[]' (gate-check.mjs:109), and the three behaviours the fix rests on each have a test that goes red when the fix is reverted.

The envelope, and one run per head. Putting the old --jq '.[]' back → 3 red: the same head flips red then green on one check run, the check-run read takes the envelope…, a comment arriving after a refusal PATCHes the same run green. The fake now slices the envelope by the --jq expression instead of answering line-shaped objects, so the old mistake cannot hide again. Making the write always POST → the same 3 red, so the PATCH branch is reachable and exercised.

external_id. Dropping the external_id === head condition from planCheck → a run for an older head is never reused red.

Rollback. Removing the re-list after DELETE from gate-ruleset.mjs → --rollback reads the list again… red, which was Reviewer2's third note.

The rest of the gate. checks: read instead of checks: write → the workflow test red; dropping the enqueue-guard context from merge-gate-ruleset.json → 6 red; forcing POST → 3 red. The workflow is pull_request_target + issue_comment + pull_request_review + workflow_dispatch, takes its code only from ref: main, interpolates nothing from the event into run: (the number goes through env:), and declares exactly contents/issues/pull-requests: read + checks: write. Nothing in it runs or checks out pull request code, so the write token is not reachable from a fork's diff.

Numbers in the body match this tip: scripts 315 tests → 309 pass / 4 fail / 2 skip (the four ds-bot-patches cases that need git on this box), testing-layers --check 328 files / 14 workflows, changelog check 10 fragments.

Nothing here blocks the merge. Three things to keep in mind, none of them a code change I would ask for now:

  1. The write path has never run against real GitHub. Every POST/PATCH /check-runs above is driven by fakes; the first real one is the first issue_comment after this lands on main. That is inherent (pull_request_target and issue_comment only fire for workflows on the default branch), and it is exactly why the merge has to stay sequenced: land the workflow, watch one real pull request flip failure → success on the same head (add agent-pr, or post a verdict), and only then run gate-ruleset.mjs --apply. Applying the ruleset first would leave every open pull request on "Expected — waiting for status" if the check write fails.
  2. cancel-in-progress: true can cancel a run between reading the head and PATCHing its check, leaving that head's check on the previous decision until the next qualifying event (a comment, a label, a push). A stale green cannot answer for a new tip (external_id is the head), so this is a delay, not a hole.
  3. --rollback and --check use the first ruleset whose name is main merge gate. Two rulesets can share a name, and then the rollback would leave the second one enforcing the gate. Iterating every match in findByRow (or reporting duplicates) closes it; very unlikely, but the rollback is the path nobody wants to discover this on.

Also: node --test .github/scripts/gate-check.test.mjs .github/scripts/gate-ruleset.test.mjs at this tip → 27/27 pass.
Also: node --test ".github/scripts/*.test.mjs" → 315 tests, 309 pass, 4 fail, 2 skip (the 4 are the ds-bot-patches cases that need git, absent on this box and red on the base too).
Also: node .github/scripts/testing-layers.mjs --check → 328 test files in 14 workflows, all classified.
Also: node .github/scripts/changelog.mjs check → 10 fragments well formed.
Also: seven mutations in copies of this tip, the two suites above in each: .[] → 3 red, external_id dropped → 1 red, rollback without the re-list → 1 red, checks: read → 1 red, enqueue-guard dropped from the ruleset → 6 red, always-POST → 3 red; and the untouched copies stay 27/27.
Also: REST on Birfy/FlyBot#39: head 9e60f748, base bbd29584, 9/9 checks success, mergeable: clean, labels agent-pr, body names this tip, no other Verdict: at this tip.

Ran:

$ node --test .github/scripts/gate-check.test.mjs .github/scripts/gate-ruleset.test.mjs
$ node --test ".github/scripts/*.test.mjs"
$ node .github/scripts/testing-layers.mjs --check
$ node .github/scripts/changelog.mjs check

At 9e60f74: 27/27 in the first; 315 tests with 309 pass / 4 fail / 2 skip in the second (the four are the ds-bot-patches cases that need git); 328 test files in 14 workflows; 10 changelog fragments. The seven mutation runs quoted above were the first command in each copy of the tip.

@github-actions github-actions Bot added the pass label Oct 23, 2026
@Birfy

Birfy commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

enqueue-guard: ALLOW — enqueue #829: no hold and the gate passes; Birfy's PASS names this tip

Run at head 9e60f7484beb08dfa0cb1bcbd071bbe117efb7a9 with .github/scripts/enqueue-guard.mjs --repo ThinkFlowLab/FlyBot --pr 829 --sha 9e60f7484beb08dfa0cb1bcbd071bbe117efb7a9 (labels agent-pr, pass; the PASS is the one written on Birfy#39 and re-posted here). Next: the squash merge with --match-head-commit 9e60f7484beb; the head does not change.

Written-by: Dev

@Birfy
Birfy added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit 43ec371 Oct 23, 2026
9 checks passed
@Birfy
Birfy deleted the fix/812-fork-merge-gate branch October 23, 2026 09:03
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The fork's main has no ruleset and no branch protection, so a PASS is only advisory and an unreviewed pull request can be squash-merged

1 participant