cmux-next fast gates: web lint, crash ratchet, defaults stamp and prepush gate fail on the PR merged with base
Why
Four base reds landed on feat-cmux-next on 2026-10-21. Each came through a check that does not fail a pull request:
- Two came through webviews lint/format and switch-fetches. On a pull request those checks only warn (
ci-web.ymlreact-apps-check,CMUX_NEXT_STYLE_GUARDS=warn). They fail only on push (cmux-next-web.yml"web lint (fast)",scripts/web_lint_prepush.py). - One came from a PR that was self-merged past its own red
defaults-snapshot. - One came from direct pushes that tripped the crash ratchet.
feat-cmux-next has no required checks. Its ruleset blocks only deletion and force-push.
What
.github/workflows/cmux-next-fast-gates.yml ("cmux-next fast gates", Linux) has three jobs, so pull request code never runs next to a write token:
resolvenames the head and BASE without running any checked-out code:git rev-parseon the merge ref, or on dispatch the GitHub API. A dispatch refuses a head from a fork, a closed pull request or a base other than feat-cmux-next before anything is fetched.fast gates(needs: resolve,contents: readonly) runs the PR's code: four push-time gates in fail mode on BASE..merge, listed below.post(statuses: writeonly) checks nothing out. It writes the status from resolve's head and base and the gates job's result, never from anything the gates job wrote.
The four gates:
scripts/web_lint_prepush.py --base BASE --head MERGE: the same diff-scoped lint thatweb lint (fast)runs on push. Bun, Node andnode_modulesare installed only when the diff touches a web package the script would lint. Otherwise the gate is skipped.- The crash ratchet. It calls
scripts/ci/crash_ratchet_compare.pythe waycmux-next-guards.ymldoes: on the merge and on BASE's exported tree. scripts/ci/defaults_guard.py check-stamp --base BASE, the same call as incmux-next-guards.yml.scripts/cmux-next/prepush-gate.py --base BASE --head MERGE, which fails only on violations the PR adds. Its pinned tools are cached bylint-tools.lock.
The step logic is in scripts/ci/cmux_next_fast_gates.py:
prepare --head SHA --base SHAfinds the merge onpull_request, or makes it on dispatch, fails unless that merge is exactly BASE + HEAD as resolved (a head that moved after resolve is refused), and writesmergeandweb.runruns every gate, even after one fails, writes a summary table and exits 1 if any gate failed.
Both scripts already accepted --base/--head, so neither needed new flags. The new test is also wired into the tooling-tests job of cmux-next-guards.yml.
The push-attribution part of the original plan was dropped: it would have posted comments on teammates' PRs. Attribution moves to base-red-watch.
Contract
Other PRs depend on this contract: next-autoland in leo-workspace and the hq merge hook. Each will enforce it in its own PR.
- Triggers:
pull_requestinto feat-cmux-next with no paths filter, which uses the default merge ref.workflow_dispatchwith inputpr, validated as digits only. A dispatch fetchesrefs/pull/<pr>/headand the current feat-cmux-next tip and runsgit merge --no-edititself (--no-ff). A conflict fails with a message naming the PR and the conflicting files.
- In both cases BASE is the first parent of the tested merge, and HEAD_SHA is the PR head.
- No
pull_request_targetand noworkflow_runtrigger. concurrency: fast-gates-${{ github.event.pull_request.number || inputs.pr }}withcancel-in-progress: true.- Result: a commit status on HEAD_SHA:
- context
cmux-next/fast-gates - state
successorfailure - description
base <40-hex BASE> target_urlset to the run URL
- context
- Permissions:
contents: readfor the workflow;resolveaddspull-requests: read;fast gateshascontents: readonly;posthasstatuses: writeonly. - Fork PRs: no status is posted, and the gates still pass or fail. A dispatch for a fork PR fails in
resolve. - PR-controlled values reach shell only through
env. - FRESH rule, judged by mergers:
- The newest
cmux-next/fast-gatesstatus on the head issuccess, and its base B is an ancestor of the current tip T. - Either B == T, or
GET compare/B...Thasahead_by <= 250and none of its files is among the PR's files (GET pulls/N/files). - Otherwise the status is stale. The merger dispatches
cmux-next-fast-gates.ymlwithpr=Non ref feat-cmux-next and judges again after that run.
- The newest
Tests
tests/test_cmux_next_fast_gates.py has two parts:
- Workflow shape:
- name, the three jobs and their runner (the runner expression is the same as
cmux-next-guards.yml's) resolveruns no repository script and checks out only onpull_request; a dispatch refuses a fork head, a closed PR or another base there;fast gatesneeds it and takes its head and baseposthas one step, no checkout, and takes head and base fromresolveand only the result fromfast gates, which has no outputs- no paths filter, and no
pull_request_targetorworkflow_runtrigger - the dispatch input is validated as digits
- no
${{ }}insiderun, in any job - concurrency
- minimal permissions per job (only
postcan write) - no step or job is
continue-on-error, and nothing usesCMUX_NEXT_STYLE_GUARDS - all four gate scripts are present
- the status context, description, target URL and fork skip
- Bun is installed only for web changes
- the test is wired into
tooling-tests
- name, the three jobs and their runner (the runner expression is the same as
- Script behavior on temporary git repositories (fake gate scripts record their argv):
- a dispatch merges the PR head into a shallow clone's tip, with BASE as the first parent
- a conflict fails and names the PR and BASE
- a head that moved after resolve, a base not on feat-cmux-next, and a short sha are refused
- a non-digit PR is refused
- the
pull_requestmerge ref is read, and a mismatched head or base is refused - a web change sets
web=true - every gate is called with BASE and the merge, and the ratchet gets BASE's tree
- any one red gate fails the run, and the rest still run
- with no web change only web lint is skipped
- a bad base runs nothing
Red (test commit 7ce13bd80000, before the workflow and script existed):
$ python3 tests/test_cmux_next_fast_gates.py
Ran 20 tests in 9.704s
FAILED (failures=12, errors=11)
Green (3fe7900eb2e2, and still green at dc7b214833e9):
$ python3 tests/test_cmux_next_fast_gates.py
Ran 20 tests in 11.159s
OK
Review fix (chief-fix-3 BLOCK at dc7b2148: dispatch ran fork-PR code in the job that posts). Red on the test commit c018308cb61 with the old workflow and script, green on 8ccd8eeea712:
$ python3 tests/test_cmux_next_fast_gates.py
Ran 25 tests in 14.292s
FAILED (failures=19, errors=4)
$ python3 tests/test_cmux_next_fast_gates.py
Ran 25 tests in 17.143s
OK
Changelog
none
Observed / Not run
Observed:
-
actionlintoncmux-next-fast-gates.ymlandcmux-next-guards.ymlis clean (again at 8ccd8eeea712). -
At 8ccd8eeea712 these still pass:
tests/test_ci_workflow_guards_are_wired.py,test_ci_workflow_permissions_are_job_scoped.py,test_ci_actionlint_covers_every_workflow.py,test_ci_workflow_path_filter_parity.pyandtest_ci_repo_variable_defaults.py. -
In
scripts/ci/guards-local.sh --group ci, the only reds are unrelated to this change:- shellcheck is not installed on huge-blue
- "canonical guard profile" refuses an uncommitted tree
- nightly-next-train's environment secrets, which are red on base
- a
verify_localenvironment test
-
python3 scripts/cmux-next/prepush-gate.py: no new violations. -
A real
cmux_next_fast_gates.py run --base <feat-cmux-next tip> --web falseon this branch on huge-blue passed: crash ratchet 63.2s, defaults stamp 0.3s, prepush gate 3.3s, web lint skipped. -
On this PR, the workflow's first run (39578318736, head 3fe7900eb2e2) passed all four gates, but the status POST through
curl -fgot a 403 with no reason shown. The token hadStatuses: write. dc7b214833e9 posts withgh apiinstead, so a refusal prints GitHub's message. -
Run 39578793945 on head dc7b214833e9 passed and posted
cmux-next/fast-gates= success, descriptionbase efaab9b377161282c7a53d797d073ce259b00dfb, with the target URL set to the run.- The job took 70s and the run 2m29s, including the queue.
- Crash ratchet 28.5s, defaults stamp 0.1s, prepush gate 0.1s, web lint skipped.
-
The first 403 is now explained. Run 39580378636 on head 8ccd8eeea712:
resolveandfast gatespassed (base 66a1a077542a), andpostgotgh: API rate limit exceeded for installation (HTTP 403)from the Actions token, the same installation 403 that is red on base (high-signal review linkand others). It is infra, not this change, and it is not rerun.
Not run:
- Web lint, because this PR touches no web path.
- The dispatch path on GitHub, including resolve's API reads and the fork refusal. It can only be dispatched on feat-cmux-next after this merges, so only the temp-repo and shape tests cover it.
- Swift, cargo and app builds (not relevant).
Agent session: huge-blue
🤖 Generated with Claude Code