gh-merge-green: fast gate by default for feat-cmux-next, --full keeps the full wait (no bead)

Merged
TE
18d ago
+1364-86

This is a merge-policy change. The coordinator approved it on 2026-10-19, and leoli-f6 decided the two policy calls listed under Policy. It is reversible: pass --full, or revert this PR.

gh-merge-green waits for every check before merging, which slows feat-cmux-next iteration (median 25.9 min from head push to merge over the last 60 merged PRs). For PRs into feat-cmux-next it now waits only for a fast gate by default and merges once that is green. The full suites still run after the merge, and the culprit bot and base sheriff revert or fix forward. --full keeps the old wait. Main and every other base always get the full wait.

What the fast gate waits for

scripts/ci/merge_fast_gate.py polls the exact head once per 30 s with one GraphQL gh pr view --json headRefOid,baseRefName,statusCheckRollup,files read, for up to --wait seconds (default 2400). It waits for:

  • cmux-next path routing, when the change is inside cmux-next.yml's pull_request paths, read from the base so a PR can't drop itself from the filter;
  • cmux-next fast tier;
  • each touched compile that routing started: cmux-next Swift canary and cmux app scheme compile (Debug). A check reported under the raw expression name (inputs.dev_daily == true && ... || 'cmux app scheme compile (Debug)', as on #21981) counts as the Debug compile;
  • react-apps-check, when scripts/ci/detect_ci_change_areas.py (the helper checkout's copy, which ci.yml's web job routes by) sends a changed file to web (judged on the helper's paginated REST file list, not gh pr view --json files, which stops at 100), including config/** and Resources/markdown-viewer/**, or when the web caller job is already running. The wait ends when the caller finishes without one; a red caller is a red;
  • god-file, l10n and concurrency lint (today cmux-next checks (god files, concurrency, crash safety, l10n)) and risk evidence, when reported. A red one refuses outright, with no --override and no base-red excuse, as in main's full gate (#21692).

It refuses:

  • a completed red in those lanes, in the web, app size and crash families, or in ci-status, cmux-next Release compile (Xcode 26) or cmux-next swift test, unless the base has the same red (base_red_excuse.py) or --override waives it;
  • a cancelled, timed-out, startup-failed or stale fast lane, or a skipped cmux-next fast tier on a change inside the paths filter, even with --override, because it has no verdict to waive;
  • a pull request from an untrusted fork without --trust-fork-head <sha>, before the gate reads or waits for anything (#21587);
  • a head or base that changed during the wait;
  • a lane it waits for that is no longer a job name in the head's cmux-next.yml, ci.yml or ci-web.yml, so a rename can't silently drop a check. An unreadable workflow file also refuses.

A pending check outside the waited set does not block. Of several runs of one check, the latest started wins.

What stays the same, and what both gates now share

Conflict markers, merge-freeze issues and the freeze-ok label (#21957), the GraphQL-to-REST metadata and merge fallback (#21529), fork-head trust and --trust-fork-head, and --main-fix are unchanged. The full gate body is main's code; optional-workflow 404 handling and advisory Vercel statuses still apply there. The GraphQL and Git readers now live outside full_gate, so the fast path's changed-file listing, conflict-marker read and freeze check keep their fallbacks.

New on both gates: right before gh pr merge, the helper re-reads head, base and state (GraphQL, REST on a rate limit) and refuses if the head or base moved. --match-head-commit pins only the head, so before this a PR retargeted from feat-cmux-next to main during the fast wait would have merged into main on the fast gate. This replaces the --override path's own head-only re-check.

The fast gate skips the local merged-tree checks except on protected gate paths (see Policy). outcome=merged is set only after a real merge, including the REST path. One line prints at exit, merged or refused, for example:
gh-merge-green: merged manaflow-ai/cmux#42 (fast gate: cmux-next Swift canary, cmux-next fast tier, cmux-next path routing, conflict markers, freezes; waited 3m10s, 214s in all)

docs/ci-runners.md describes both gates.

Evidence

Merge wait, measured on the last 60 PRs merged into feat-cmux-next before this PR was written (head push time is the earliest check suite):

medianp90
merged under the full wait25.9 min94.8 min
fast gate lanes done on the same heads15.2 min53.8 min

That is when the fast lanes finished on those heads, not a measured merge time under the new gate.

Review fixes. Each has its test committed first, and each test failed before its fix:

BugTestRed atFixed in
An untrusted fork merged on green fast checkstest_the_fast_gate_refuses_an_untrusted_fork3d63242b9438 (#22108)0f11f22d6eef
A skipped fast tier counted as a passtest_a_skipped_fast_tier_has_no_verdict3d63242b94380f11f22d6eef
Web routing read only the first 100 filestest_the_callers_full_file_list_routes_web; through the helper, test_a_web_file_past_the_first_100_still_waits_for_react_apps_check3d63242b94380f11f22d6eef
The paths filter came from the PR headtest_the_fast_gate_reads_the_paths_filter_from_the_base2d7ddfeee357ad3393c92c33
Protected gate paths skipped the merged-tree checkstest_the_fast_gate_runs_the_merged_tree_checks_for_a_pr_touching_gate_paths (3 subtests); test_the_fast_gate_skips_the_merged_tree_checks_for_an_ordinary_cmux_next_pr2d7ddfeee357ad3393c92c33

The 100-file test that goes through the helper was added after the fix. Dropping --files-from from gh-merge-green makes it fail.

The fork and freeze ordering tests passed on the code before the fix, so they guard behavior rather than prove a fix: test_an_untrusted_fork_refuses_before_the_fast_wait (pending lanes, --wait 0, --override: the fork is refused, not the wait) and test_a_reviewed_fork_on_the_fast_gate_still_stops_at_a_freeze.

Freezes (#21957): the merge_freeze.py check runs after either gate on every base. test_the_fast_gate_still_refuses_a_frozen_path (with and without --override) fails if the fast gate skips that check. The other fast-gate freeze tests are test_the_fast_gate_merges_an_unfrozen_path_and_the_owners_freeze_ok and test_a_main_merge_without_full_checks_freezes_on_the_full_gate.

The four required behaviors:

  • feat-cmux-next defaults to the fast gate: test_feat_next_merges_on_the_fast_gate_without_waiting_for_the_full_suites.
  • --full keeps the full wait: test_full_keeps_the_wait_for_every_check.
  • A frozen path is still refused on the fast gate: the freeze tests above.
  • main is unchanged: test_main_keeps_the_full_wait_without_full.

Run on Linux (huge-blue) at ad3393c92c33, plus the 100-file helper test at 3e1360f40a11:

  • test_gh_merge_green: 131 passed.
  • test_merge_fast_gate: 21 passed.
  • test_merge_freeze: 16 passed.
  • test_base_red_excuse: 26 passed.
  • test_gh_merge_green_revert: 5 passed.

The earlier evidence for the gate still holds: the merge-wait table above and the red-first commits 71661076d553 and 24b05719d6f6. Not run: shellcheck and actionlint. No live merge has been made with the fast gate.

tests/test_merge_fast_gate.py is registered in tests/test-execution.toml (linux-guard) and runs in a ci-guards.yml ci step.

Policy (decided by leoli-f6, 2026-10-19)

  1. Changes outside cmux-next.yml's paths and web routing merge with no checks to wait for. That covers backend/**, docs/ and other non-web paths. This stays as written. The freeze check, conflict markers and the lint and risk-evidence refusals still apply.
  2. The fast gate skips the merged-tree checks (merge cleanliness, the god-file check and vp check), except for a PR that touches a path listed in scripts/ci/merge-fast-gate-protected-paths.txt: .github/workflows/, scripts/ci/ (shared CI scripts, merge_freeze.py, the routing config detect_ci_change_areas.py and this list itself) and scripts/gh-merge-green. Such a PR gets every merged-tree check back. The list is read from the base. Until the base carries the file, the helper checkout's copy (origin/main) applies, and if neither can be read, every changed file counts as protected.

Remaining known gaps

  • On unlabeled PRs the gate is effectively path routing, the fast tier and lint. cmux-next.yml skips the Swift canary, the Release compile, swift test and the Debug compile on those PRs.
  • A queued fast tier that never starts waits the full 40 minutes before the gate refuses.
  • Web routing uses main's copy of detect_ci_change_areas.py. feat-cmux-next's copy has extra exact web inputs.
  • The job-name check still reads the head's workflows. That is deliberate: it asks whether the lanes the gate waits on still exist on this head.

Changelog

none

🤖 Generated with Claude Code