# CI job selection Every PR still runs the workflow, its `static` job and the `CI` gate: locale validation, lint, the CI-selection tests, the verification-documentation checks and the source guards always run. Selection never skips the entire workflow. The source guards are the tests that only read the source (the vocabulary and locale checks, the size and route ratchets, and the like): they take seconds, and they run in `static` as well as in the shards, so a change that breaks one fails before the run asks for its runners. `scripts/ci-scope.mjs` sorts a pull request's changed files (a local merge-base diff, rename detection off, so a moved file selects its old path too) into areas, and runs only the jobs some area can affect: | Area | Paths | What runs | | --- | --- | --- | | docs | `*.md` at the root, everything under `docs/` and `apps/docs/`, `.github/FUNDING.yml` | lint, the docs tests (the three vitest files that read the docs) on Linux | | locales | `src/locales/*.json` except `en.json`, `src/locales/*.test.ts` | the build, the locale tests: every `src/` test and `scripts/generate-locale.test.mjs` in two Linux shards, with the Electron node tests | | web | the rest of `src/`, `public/`, the root `*-preview.html` pages | the build, the eight Linux shards, the renderer smoke | | script tests | `scripts/**/*.test.*` | the Linux shards (and Windows', see Windows), the renderer smoke, browser identity | | server | `server/`, `shared/`, `enterprise/`, `companion/`, `evals/`, `skills/`, `deploy/`, the composio broker, the Dockerfile, and the renderer files the server or a smoke loads | the build, Linux shards (and Windows', see Windows), behavior evals, packaged server, open-source edition, the macOS smokes, renderer smoke, browser identity, browser driver, Linux package | | Windows computer control | the `electron/` modules that start computer control and lend a PC's screen: `cua`, `cua-failure`, `cua-connection`, `capabilities`, `screen-status`, `shared-cua-connection`, `shared-computer-access`, `lent-screen-tools`, `lent-screen-failures`, `paced-typing` | the same as server, and Windows CUA | | desktop | the rest of `electron/`, `build/`, `electron-builder.yml`, `index.html` | the same as server | | control plane | `cloudflare/control-plane/` | the control-plane job | | everything | anything else: `.github/` workflows, `package.json`, the lockfile and workspace, tsconfig, vite and vitest config, `third_party/`, `scripts/` code, unknown paths | every job | `static` also validates `server/model-manifest/model-manifest.json` with `scripts/check-model-manifest.mjs`: installs download that file from main within hours of a change, so it is checked on every event and fails on any invalid edit. The build is the `build` job: typecheck, Electron syntax check and UI build, about three minutes, beside the shards rather than ahead of them. Several areas run the union of their jobs. Why the mapping is what it is: - The server imports a few renderer files (`SRC_OUTSIDE_RENDERER`), and the renderer imports Electron modules, so a server or desktop change can affect the renderer smoke, and those renderer files count as server. `scripts/ci-scope.test.ts` holds the list equal to the import graph. - The macOS smokes run the packaged server inside Electron (approval modes, app frames, phone relay, login handoff), so a server change runs them. - A renderer change runs no Windows or macOS shard: the renderer's tests read the platform from the desktop bridge, never the host. The full lane still runs both. - English (`en.json`) is the text every test reads, so it is web. Other catalogs are read only by `src/` tests, the locale generator's test and an Electron node test; `scripts/ci-scope.test.ts` fails if another test starts reading them, or the docs, without being listed. - Windows CUA starts computer control the way the installed app does and takes a Cloud's first look at the PC through the lend path, so a change to those Electron modules runs it, as do `scripts/` and dependency changes. The Linux ARM64 cloudflared installer tests only its own script and pinned download. ## Lanes: pull requests, main and the full lane Only pull requests use the areas. Every other event runs one of two fixed sets (`laneOf` in `scripts/ci-scope.mjs`): | Event | Lane | What runs | | --- | --- | --- | | `pull_request` | its areas | the table above, with Linux shards, and Windows jobs only for a change that can break Windows (Windows below) | | `push` to main, `merge_group` | main | static (lint and the CI checks), the build, the eight Linux and eight Windows shards with the Electron node tests, the macOS smokes job, behavior evals, the Linux and Windows packaged server smokes, the open-source edition, the control plane, the renderer smoke and Linux and Windows browser identity | | `schedule` (nightly, 21:17 UTC), `workflow_dispatch` (manual runs, the release branch's CI that `prepare-release.yml` starts, and the release lane) | full | every job on every platform: also the Windows and macOS shards, the Windows packaged server and Windows CUA smokes, Windows and macOS browser identity, the browser driver on all three, the Linux package and the Linux ARM64 installer | The main lane runs Linux, Windows and one macOS job. The macOS shards, the Windows CUA smoke and every smoke that needs a second macOS runner wait for the night or a release. A change that breaks only Windows shows up in its own PR when it can (Windows below), and otherwise in main's run after it merges; a change that breaks only macOS shows up in the nightly run or in the release lane, which only an emergency `ship_without_ci` release skips. - A push to main is scoped by its own commits (`before..after`): docs only (root Markdown, Markdown under `docs/`, `.github/FUNDING.yml`), or a `package.json` change that only bumps `"version"` (the release bump), skips the runtime jobs. It does so only when the commit before the push has a green CI run of its own. Main's waiting run is replaced by each push, so without that check a replaced run's code would never be tested. Every other push runs the main lane. - A push whose commit already passed the `CI` gate in a merge group runs only the control-plane job (the broker deploy needs it). The merge queue lands its group's own commit, so the merge group's run tested exactly what the push brings; `ci-scope.mjs` reads that run's gate job, which can pass before the run's last job has finished. - Merge groups always run the main lane, whatever they change. Empty or unreadable push diffs, and a commit whose CI cannot be read, fall back to the main lane; a pull request whose diff cannot be read falls back to every job on every platform. The single `CI` gate keeps the existing required dependencies, and adds `build`. `static` outputs the selected job ids; the gate wants each required job to have passed when it was selected and to have been skipped when it was not. Failures, cancellations, a missing or malformed selection and unexpected runs or skips fail the gate. Advisory jobs remain advisory. A new push to a PR cancels that PR's older run (see Main and releases for every other group). The separate shared-terminal smoke workflow is manual-only: its tests already run in the Windows Vitest/Electron jobs. ## Vitest shards The slowest shard sets how long a PR and a release wait. Linux and Windows run eight shards each, macOS four (the full lane only); `scripts/ci-scope.mjs` builds the matrix. **Files at once.** A Linux shard runs four files at a time (`VITEST_WORKERS`, `OMB_VITEST_WORKERS` in `vite.config.ts`), one for each of the runner's vCPUs. Most of a shard's time is waiting on real servers and fake engines: one file at a time it took 16 minutes with the machine 70% idle, and four at a time 5. A Windows shard runs two files at a time: one at a time it took 9 to 14 minutes and was the last job of every run that had one. macOS shards, and every local run, still run one file at a time. So that files side by side never share a port, each worker picks from its own (`server/testing/worker-port.ts`); a test that picks a port some other way can meet another file's. Files share the runner's CPU, so test and hook timeouts double there, as on Windows (`server/testing/host-timeout.ts`). **Which files run where.** Linux runs every file. Most e2e files drive OMB's own logic through a real server and fake engines, which is the same on every OS, and they were 70% of a Windows shard's time, so only Linux runs them: the Windows and macOS shards set `OMB_VITEST_E2E=host`, and `vite.config.ts` then leaves out every e2e file not listed in `scripts/testing/host-e2e.ts`. The list keeps an e2e file on every host when it has a test that never runs on Linux (Auto on the person's own desktop, the built-in Mac voices) or depends on the OS (PowerShell and Windows process trees, junctions, a running process's folder, Windows paths, Chrome behind the egress gate). Every unit and API test file runs on every host. A new e2e file with a test Linux skips fails `scripts/testing/host-e2e.test.ts` until it is listed. The packaged-server, Windows CUA host, browser identity and browser driver jobs are separate and unchanged. **Balance.** Vitest's own `--shard` deals out equal numbers of files, and in October 2026 that put three of the four slowest e2e files in one shard. `scripts/testing/duration-sequencer.ts` deals the files out by recorded time instead, slowest first, each to the shard with the least time so far. The times are `scripts/testing/vitest-shard-weights.json`: for each platform (`linux`, `win32`, `darwin`), each file's median seconds on that platform's runners, for every file over five seconds and every e2e file. A file not in it counts one second, or the median e2e time if it is an e2e file. Every file still runs exactly once, and each shard job logs a `duration-sequencer:` line with its share. Refresh the times from a few recent nightly or manual runs (they have all three platforms) when shards drift apart: ```sh node scripts/testing/update-shard-weights.mjs --run --run --run ``` To reproduce a failing Vitest job locally: 1. Use the checkout revision shown in that job's log and install its locked dependencies with `pnpm install --frozen-lockfile`. A PR's CI checkout may be a merge commit rather than the branch tip. 2. Use the same OS. Shard weights are platform-specific, so Windows shard 3 and Linux shard 3 do not contain the same files. 3. Copy the expanded command from **Run tests**, including any file filters and the complete `--shard=n/count` fraction. Copy its `OMB_VITEST_E2E`, `OMB_VITEST_SUBSET` and `OMB_VITEST_WORKERS` environment values too. On Boat, the static job contains each shard's output in a `vitest (ubuntu-latest, ...)` lane's log group; the lane uses the same matrix values. For example, a full Windows shard 3/8 uses PowerShell syntax: ```powershell $env:OMB_VITEST_E2E = "host" $env:OMB_VITEST_SUBSET = "" $env:OMB_VITEST_WORKERS = "2" pnpm exec vitest run --shard=3/8 ``` The full Linux shard 3/8 uses a POSIX shell: ```sh OMB_VITEST_E2E=all OMB_VITEST_SUBSET= OMB_VITEST_WORKERS=4 pnpm exec vitest run --shard=3/8 ``` Docs and locale jobs select smaller file lists and have their own shard counts. Keep their file filters: removing them changes the set the sequencer divides, even with the same `--shard`. `OMB_VITEST_SUBSET` suppresses the CI warning about missing weighted files; the command's file filters select the tests. Copy the current job's list rather than assuming it is the full suite. Deleting or renaming a test file in the weights fails `scripts/testing/duration-sequencer.test.ts` until its entry is removed or refreshed. A refresh moves some files to other shards; a failure that appears only after one is a real ordering dependency between test files. ## macOS runners The account runs at most five macOS jobs at a time, and a PR used to queue seven (four Vitest shards, two smokes, the iOS job with its hour-long simulator suite). With 25 open PRs the macOS jobs waited a median of six and a half hours. Now: - A PR runs the Vitest shards on Ubuntu (and Windows where its change can break Windows), a push to main or a merge group on Ubuntu and Windows; the full lane adds four macOS shards, which run the host e2e files only. Only a couple of test blocks are macOS-only. - Every PR's, push's and merge group's macOS checks are one job: the packaged-server smoke and the Electron smokes. - `ci-stop-closed.yml` cancels a PR's CI run when the PR is merged or closed. PR runs share a group named by the PR number (`ci-pr-`), never by `github.ref`: a merged PR's closed event reports the base branch as `github.ref`, which made every merge cancel main's CI (fixed Oct 3 2026). ## Windows Windows runners are slow to get and a Windows shard takes as long as a Linux one, so a pull request runs the Windows jobs (the eight shards, the packaged server smoke, browser identity, the browser driver and, for a change that can affect anything or to the Electron modules it starts, the CUA smoke) only when its change can break Windows first (`windowsReason` in `scripts/ci-scope.mjs`): - the PR carries the `windows` label; - a path names Windows (`win`, `win32`, `windows`, `nsis`, `powershell` as a word of the path, `.ps1`, `.cmd`, `.bat`, `.exe`), or is the desktop app and its installer (`electron/`, `build/`, `electron-builder.yml`), the dependencies (`package.json`, the lockfile and workspace, `.npmrc`, `third_party/`), `.gitattributes`, `vite.config.ts`, a script that builds, bundles, prepares, packages, stages or smokes the app, or what decides how the Windows shards run (`ci.yml`, `ci-scope.mjs`, the host e2e list, the shard weights, the retry list and runner, `server/testing/host-timeout.ts`); - it changes one of the e2e files the Windows shards also run (`scripts/testing/host-e2e.ts`); - an added or removed line of its code (not docs or translations) names Windows: `win32`, `Windows`, `windows-latest`, PowerShell, `cmd.exe`, `.exe`, `path.win32`, `USERPROFILE`, `APPDATA`, `LOCALAPPDATA`, `PATHEXT`, `ComSpec`, `windowsHide` or `windowsVerbatimArguments`. The static job's log says which one (`CI scope: Windows runs for …`). Every other PR leaves Windows to main's push run after it merges, the nightly run and the release lane. Over October 2026's last 300 merges this ran Windows on 37% of PRs instead of 81%. To run Windows on any PR, add the `windows` label, then re-run the CI workflow (or push): the static job reads the PR's labels when it runs. Adding the label alone starts nothing, because a run for every label change would cancel the PR's running checks. ## Browser driver (S4) `browser driver S4 ()` runs the omb-cdp conformance suites (`server/browser/cdp/s4-*.e2e.test.ts`) against real Chrome: snapshots across out-of-process frames and closed shadow roots, every action, dialogs, downloads, popups and tabs, both evaluation worlds, waiting, the calibrated identity against the headed browser and across page, worker, service worker, frame and request headers, Chrome exiting after its controller is killed, and the latency targets. Each suite records every CDP command its browsers were sent and fails on `Runtime.enable`, `Runtime.addBinding`, `Console.*`, `Debugger.*`, `Profiler.*` or an evaluation in the page's own world that the test did not ask for. The unit tests in the vitest shards hold the same rule without a browser: the method guard refuses those methods and no driver source names them. The same job runs the new browser's suites on that Chrome: `server/browser/checklist.e2e.test.ts` (the design's checklist items the browser service owns: tools and refs, sign-ins kept across a restart of the browser, erase and temporary profiles, uploads, downloads, dialogs, popups, HTTP sign-in, a person's hold, restart and idle close) and `server/browser/viewers/viewers.e2e.test.ts` (the page filling the viewer at the screen's device scale, resizes, presets, acknowledged frames, a scroll ending on a sharp frame, and the phone's v1 live view against its golden from agent-browser), `server/browser/passkey-wall.e2e.test.ts` (a sign-in shaped like Google's that stops at a passkey or a security key: the bot's notice, the request ending at once as unavailable, Try another way, the options and a code, and no passkey registered) and `server/browser/viewers/stream-v2.e2e.test.ts` (the panel's WebSocket stream through its routes: a 1600x1000 panel getting a 1600x1000 page and 3200x2000 frames on a 2x screen, the page's file picker answered by an upload, a dropdown list drawn by the viewer, a download served to it, the page's cursor and copied text). - The job uses the runner's installed Google Chrome (`OMB_CDP_E2E=1`) on the same platforms as the vitest shards: Linux, and Windows where a PR runs it, on PRs, all three in the full lane. Pushes to main and merge groups leave it out. Linux runs under Xvfb so the headed-identity case has a display. - On Linux it then builds the server image's `runtime` stage and runs the suites inside it as a non-root user against the image's Chrome for Testing, with `OMB_BROWSER_SANDBOX=0` because the image has no setuid sandbox helper yet. - It is advisory, outside the `CI` gate, until it has run green for a while. - The latency targets hold wherever Chrome itself can meet them. The suite also times Chrome alone in the same run (the same binary started bare and showing a page; a frame and the same mouse events with none of the driver's checks), and where Chrome alone is slower, as on GitHub's macOS VM (0.7-1.5 s to start, 60-75 ms to click, against 0.3 s and 33 ms on a Mac), the driver may add only its budget to that. - On macOS the job locks the runner's Google Chrome app before the suites and fails if its version changed: the image's Chrome is older than Google's release, and the GoogleUpdater it starts swaps the new version into the app about a minute and a half after Chrome first runs, in the middle of the suites. - Locally: `OMB_CDP_CHROME= pnpm exec vitest run server/browser/cdp/s4- server/browser/checklist.e2e server/browser/passkey-wall.e2e server/browser/viewers/viewers.e2e server/browser/viewers/stream-v2.e2e` (or `OMB_CDP_E2E=1` for the installed Google Chrome). Without either the suites skip. `OMB_CDP_TEST_TMP` moves the throwaway profiles. - `pnpm bench:browser-stream` measures the panel's stream on the installed Google Chrome with this computer's display: frame and page sizes, input to photon, frame rate, bytes and CPU. It is not part of CI. ## Main and releases Main's pushes keep one CI run going and one waiting. Each merge replaces the waiting run, and the running one always finishes, so a burst of merges costs two runs instead of one per merge (fifteen queued behind each other in October 2026, with the release waiting behind them). Every other run has a group no push can join, so nothing a merge does cancels it: a merge group is named by its own ref, and the nightly run and each manual run (the release branch's CI and the release lane among them) by their event and branch. `release.yml` ships only a commit whose code passed CI (`scripts/release-ci.mjs`, overlapping the platform builds). A run counts only when its `CI` gate passed and every platform's every vitest shard ran and passed. A scoped run (docs- or version-only, vitest skipped) is never proof, and neither is a partial one: a push to main or a merge group runs Linux's and Windows' shards only. The script accepts, in order: 1. a finished full run on the release commit itself; 2. a finished full run on any commit with the same git tree: the all-OS manual run `prepare-release.yml` starts on the `release/v` branch, whenever main did not move before the merge, or a nightly run; 3. when the release commit's only change from its parent is `package.json`'s `"version"` (all the bump touches), the parent's proof by 1 or 2. A parent's nightly or manual run still going is waited for; a red parent, or one that skipped its tests or ran only some platforms, never counts. Otherwise the script starts CI on the commit in its own lane: a `release-ci/v` branch that no merge can touch, run as a manual run of every job on every platform, and deleted afterwards. It does not wait for the commit's push or merge-group run first, which could only come back partial, but a red one that has finished stops the release. A red verdict stops the release; re-run the failed CI jobs (`gh run rerun --failed`), and the waiting release picks up the new attempt. A red where every failed job never started a step (a macOS runner "failed to be acquired") is not a verdict. On the release lane's own run the script re-runs the failed jobs once itself (`POST /actions/runs//rerun-failed-jobs`, which the job's `actions: write` allows) and keeps waiting. Main's run is never re-run that way: a re-run joins main's concurrency group, where it would cancel the newest merge's waiting run and re-run main-only deploys from an older tree. The script starts CI in the lane instead. A manual release can skip the wait with `ship_without_ci`, for emergencies only. ## Flaky tests and Windows timeouts Known flaky tests are listed in `scripts/testing/ci-retry-list.json`: file, full test name (describe blocks and test joined with ` > `), owner, the evidence, and the date it was listed. In CI only (`CI=true`), the repo's vitest runner (`scripts/testing/ci-retry-runner.ts`) gives each listed test two retries; nothing else is retried, and local runs never retry. Retries are bounded per file: once a listed test fails all three attempts, that is a real failure, and the file's later listed tests get no retry. Otherwise a regression that breaks a whole fixture (six listed tests share `server/delta-context.e2e.test.ts`) would triple their wall time and push the Linux shard past its 25-minute cap, and a timed-out job loses the failing tests' annotations and this summary. The cost: a real flake listed after a broken test in the same file is not retried in that run. A listed test that needed a retry is written to the vitest job's summary and raised as a warning annotation (`scripts/testing/ci-retry-summary.mjs`), so a flake that passed is still seen. Fix the test, then delete its entry; `scripts/testing/ci-retry-list.test.ts` fails on an entry whose test no longer exists. Windows runners run the suite about 1.45x slower than Linux. Every test and hook timeout doubles there (`server/testing/host-timeout.ts`): the defaults in `vite.config.ts`, and a test's own timeout written as `hostTimeout(ms)`. Linux and macOS keep the strict numbers, such as the 120 s budget for the 3,000-file checkpoints test. The packaged-server smoke asks the OS for free ports (listen on port 0) instead of picking them at random: a random pick inside a range Windows reserves failed with `listen EACCES 127.0.0.1:497xx`. ## Linux on Boat GitHub runs a limited number of jobs at once for the whole account, and each PR's run used to be about thirty of them, so with twenty agents opening PRs a run waited hours for runners. While the repository variable **`BOAT_CI`** is `on`, a run's Linux work goes to one [Boat](https://docs.boat.dev/ci) VM instead (Settings → Secrets and variables → Actions → Variables; anything but `on` switches it off, and CI runs as it always has). - **Which runs.** A push to main, a merge group, and a PR whose branch is in this repository, when the repository owner authored the PR and both pushed and started the run (`github.actor`, `github.triggering_actor`). Boat runs a public repository's job only for the users on its allow-list (`boat ci public milind-soni on --users "milind-soni"`) and fails any other job at once (a teammate's PR got nine failed Boat jobs that way), so every other run stays on GitHub: forks, teammates, bots, re-runs by anyone else, the nightly run and manual runs (the release lane needs every platform's shards by name). To give a teammate Boat too, add them to Boat's list and to the condition in `ci.yml`'s `static` job. To run Windows on a PR as well, label it `windows` (Windows above). - **What changes.** `static` runs on `boat-xlarge` (16 vCPU, 32 GB) and is named "preflight + Linux tests (Boat)". After its usual checks it runs the Linux vitest shards, behavior evals and the Linux packaged server smoke side by side as lanes of that VM (`scripts/ci-linux-lanes.mjs`, the same commands as their GitHub jobs), and prints each lane's log in a group when it ends. GitHub keeps only 10 error annotations per step, so when many tests fail, the lane groups in the log are the full record. `ci-scope.mjs` leaves those jobs out of the selection, so the `CI` gate reads them as skipped by design and `static`'s result covers them. The Windows jobs, the macOS smokes, the advisory Linux jobs (open-source edition, control plane, renderer smoke, browser identity and driver, Linux package, ARM installer) and the `CI` gate stay on GitHub's runners. - **The VM made like a GitHub runner.** Boat's runner gives its processes `cap_dac_read_search`, and its image has a desktop (`DISPLAY`), the coding agents (Claude, Codex, Hermes, …) and a working `bwrap`. The lanes run under `setpriv` without the capability, with `DISPLAY` unset, `C.UTF-8` and UTC, and the step before them deletes the agents and `bwrap`. Otherwise permission tests read files set to mode 000, the server starts Boat's Hermes in every test home, and Team Cloud turns run in boxes they never get on GitHub. - **Cost.** One Boat start per run, and the account's daily start limit is shared with customers' Cloud computers: keep it to one job. The job takes about 25 minutes: 4 for setup and the checks, 21 for the lanes, whose slowest shard sets the time. - **Where to look.** `boat ci` (setup, problems, the last jobs), `boat ci job ` and `boat ci logs --follow`, or the CI on Boat VMs tab of the Boat dashboard. The job id is the GitHub job's id. - **If Boat is down.** Set `BOAT_CI` to `off`: new runs go back to GitHub's runners. A queued Boat job does not move; cancel the run and re-run it. ## Required check The `main-ci-gate` ruleset requires the single `CI` check (it replaced the three legacy `typecheck + test ()` names in September 2026). Renaming the `gate` job needs the ruleset updated first, or every PR waits forever. ## Merge queue Two PRs that were each green have broken main together. A merge queue tests each PR on top of what lands before it: GitHub builds a merge group (the PR squashed onto main and the groups ahead of it), CI runs the main lane on it as a `merge_group` event, and the group's commit lands only when its `CI` check passes. The workflow is ready: the `merge_group` trigger, a concurrency group per merge group, the main lane, and the same `CI` check name. GitHub offers the queue only to repositories an organization owns ("Pull request merge queues are available in any public repository owned by an organization", GitHub's docs, October 2026). `milind-soni/OpenMausBot` is owned by a user account, so the rule below appears only after the repository moves to an organization (such as `SupaMaus`). The move changes the URL the desktop updater reads (see Updater migration invariant in `releasing.md`), so it is the owner's decision. Then: **Settings → Rules → Rulesets → `main-ci-gate` → Edit** (it targets the default branch): 1. Keep **Require status checks to pass** with the one check `CI` (source: GitHub Actions). The same rule applies to the PR before it can join the queue and to the merge group in it. **Require branches to be up to date before merging** stays off: the queue brings each PR up to date itself. 2. Add **Require merge queue** with: - **Merge method:** Squash. - **Build concurrency:** 3. Each group's run is 17 jobs, one of them on macOS; an organization on the Team plan runs 60 jobs and 5 macOS jobs at once, shared with every PR's run. - **Minimum group size:** 1, **maximum group size:** 5, **wait time to meet the minimum group size:** 0 minutes, so a PR alone starts at once. - **Require all queue entries to pass required checks** (Only merge non-failing pull requests): on. Every PR's own group must pass, not only the last of the PRs merged together, so every commit the queue lands on main was tested. - **Status check timeout:** 60 minutes (a main-lane run takes about 25). 3. Save. PRs then show **Merge when ready** instead of the merge button; `gh pr merge --squash` adds a PR to the queue, or turns on auto-merge when its checks have not passed yet. The same rule through the API, keeping the ruleset's existing check: ```sh gh api -X PUT repos//OpenMausBot/rulesets/ --input - <<'JSON' {"rules": [ {"type": "required_status_checks", "parameters": {"required_status_checks": [{"context": "CI", "integration_id": 15368}], "strict_required_status_checks_policy": false, "do_not_enforce_on_create": false}}, {"type": "merge_queue", "parameters": {"merge_method": "SQUASH", "max_entries_to_build": 3, "min_entries_to_merge": 1, "max_entries_to_merge": 5, "min_entries_to_merge_wait_minutes": 0, "grouping_strategy": "ALLGREEN", "check_response_timeout_minutes": 60}} ]} JSON ``` `ci-stop-closed.yml` only cancels a closed PR's own group (`ci-pr-`), so it never touches a merge group's run. The push that lands a group's commit runs only the control-plane job and the broker deploy, because the merge group already tested that exact commit. To turn the queue off, remove the **Require merge queue** rule; nothing in the workflow needs to change. While the repository stays user-owned, the one setting that stops two green PRs from landing red together is **Require branches to be up to date before merging** on the same ruleset: a PR merges only after CI passed on it with main's newest commit in it. Every merge then makes the other open PRs update their branch and run CI again before they can merge, so it trades merge throughput for that guarantee.