cmux-next fast gates: web lint, crash ratchet, defaults stamp and prepush gate fail on the PR merged with base

Merged
TE
15d ago
+878-0

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.yml react-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:

  • resolve names the head and BASE without running any checked-out code: git rev-parse on 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: read only) runs the PR's code: four push-time gates in fail mode on BASE..merge, listed below.
  • post (statuses: write only) 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:

  1. scripts/web_lint_prepush.py --base BASE --head MERGE: the same diff-scoped lint that web lint (fast) runs on push. Bun, Node and node_modules are installed only when the diff touches a web package the script would lint. Otherwise the gate is skipped.
  2. The crash ratchet. It calls scripts/ci/crash_ratchet_compare.py the way cmux-next-guards.yml does: on the merge and on BASE's exported tree.
  3. scripts/ci/defaults_guard.py check-stamp --base BASE, the same call as in cmux-next-guards.yml.
  4. scripts/cmux-next/prepush-gate.py --base BASE --head MERGE, which fails only on violations the PR adds. Its pinned tools are cached by lint-tools.lock.

The step logic is in scripts/ci/cmux_next_fast_gates.py:

  • prepare --head SHA --base SHA finds the merge on pull_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 writes merge and web.
  • run runs 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_request into feat-cmux-next with no paths filter, which uses the default merge ref.
    • workflow_dispatch with input pr, validated as digits only. A dispatch fetches refs/pull/<pr>/head and the current feat-cmux-next tip and runs git merge --no-edit itself (--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_target and no workflow_run trigger.
  • concurrency: fast-gates-${{ github.event.pull_request.number || inputs.pr }} with cancel-in-progress: true.
  • Result: a commit status on HEAD_SHA:
    • context cmux-next/fast-gates
    • state success or failure
    • description base <40-hex BASE>
    • target_url set to the run URL
  • Permissions: contents: read for the workflow; resolve adds pull-requests: read; fast gates has contents: read only; post has statuses: write only.
  • 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-gates status on the head is success, and its base B is an ancestor of the current tip T.
    • Either B == T, or GET compare/B...T has ahead_by <= 250 and 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.yml with pr=N on ref feat-cmux-next and judges again after that run.

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)
    • resolve runs no repository script and checks out only on pull_request; a dispatch refuses a fork head, a closed PR or another base there; fast gates needs it and takes its head and base
    • post has one step, no checkout, and takes head and base from resolve and only the result from fast gates, which has no outputs
    • no paths filter, and no pull_request_target or workflow_run trigger
    • the dispatch input is validated as digits
    • no ${{ }} inside run, in any job
    • concurrency
    • minimal permissions per job (only post can write)
    • no step or job is continue-on-error, and nothing uses CMUX_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
  • 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_request merge 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:

  • actionlint on cmux-next-fast-gates.yml and cmux-next-guards.yml is 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.py and test_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_local environment 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 false on 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 -f got a 403 with no reason shown. The token had Statuses: write. dc7b214833e9 posts with gh api instead, so a refusal prints GitHub's message.

  • Run 39578793945 on head dc7b214833e9 passed and posted cmux-next/fast-gates = success, description base 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: resolve and fast gates passed (base 66a1a077542a), and post got gh: API rate limit exceeded for installation (HTTP 403) from the Actions token, the same installation 403 that is red on base (high-signal review link and 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