A 24/7 cloud coordinator that lands your pull requests automatically — and safely.
Workers (people or coding agents) open PRs. The coordinator owns landing them: it arms every eligible PR so GitHub merges it the moment its checks pass, keeps risky-to-combine changes to one at a time, re-validates stale PRs before they merge, re-arms the instant GitHub silently disables auto-merge, and can auto-revert a bad merge. It runs on GitHub Actions, so it works with your machine off — and it can never bypass your required checks or branch protection.
It's ~1 file of zero-dependency Node driving the gh CLI, plus one workflow and a small config.
you/agent open a PR ──▶ your checks run ──▶ coordinator arms it ──▶ GitHub merges on green
An example queue board (status): the serial lane lets one risky PR land at a time,
a hold label parks a PR, drafts wait, and everything eligible is armed so GitHub
merges it the moment its checks pass.
When several PRs are ready at once, the manual merge dance gets slow and error-prone:
- you have to arm every PR (turn on auto-merge) by hand;
- GitHub silently disables auto-merge whenever a check hiccups, and never re-enables it — so a finished PR just stalls;
- a PR green'd against an old base can merge without re-checking against what landed since (two independently-fine changes can conflict or, for tightly-coupled code, jointly break something);
- if a bad change lands, the revert is manual and slow.
This automates all four, so opening the PR is the whole job.
| Arms everything on green | Enables auto-merge on every eligible PR; GitHub merges each the moment its checks pass. |
| Two lanes | Parallel PRs land independently. Serial PRs (paths you flag) go one at a time. |
| Re-validates stale PRs | A PR behind the base gets its branch updated first, so your checks re-run against current base before it merges. |
| Re-arms instantly | The moment GitHub disables auto-merge, it turns it back on. |
| Auto-revert (opt-in) | If a chosen post-merge workflow fails, it opens a revert PR, which the next sweep arms under the same lane rules as any other PR. |
| Never bypasses the gate | It only enables auto-merge (which waits for green) and opens PRs (which run your checks). |
Native merge queues re-run your full required checks on every queued PR (sometimes twice), which taxes cheap PRs that could have merged independently. Batch-integration bots bundle many PRs into one test run — pointless when your checks already pass per-PR in minutes, and risky for tightly-coupled changes that must not be blind-batched. This tool instead automates the arming discipline you'd otherwise do by hand, and restores the "must be up to date" guarantee for only the PRs that need it (your serial/revalidate lanes) — leaving cheap PRs on the fast path.
Inspired by the framing in jremick/agent-merge-batch-protocol (workers don't merge; one coordinator owns the write path) — this is a working, lane-aware implementation of that idea.
- Copy three files into your repo:
coordinator.mjs,.github/workflows/merge-coordinator.yml, andmerge-coordinator.config.json(start frommerge-coordinator.config.example.json). - Protect your base branch with a required status check (this is what "green" means). The coordinator can never merge past it.
- Create a token — a fine-grained PAT (see below) — and add it as a repo secret:
gh secret set COORD_TOKEN --repo OWNER/REPO - Create the opt-out label so you can park a PR:
gh label create hold --repo OWNER/REPO --color BFD4F2 --description "coordinator: do not auto-merge" - That's it. Open a PR — it merges itself on green. Until
COORD_TOKENis set, the coordinator runs in dry mode (reports what it would do, changes nothing).
The coordinator's writes must re-trigger your workflows. GitHub deliberately suppresses workflow
events caused by the default GITHUB_TOKEN, so if it used that token, a branch-update or a revert PR
it created would never re-run your checks — a stale PR could merge un-revalidated, and a revert PR
could never satisfy a required check.
So give it a fine-grained personal access token (or a GitHub App token):
- Repository access: only the repo(s) you want it to run on.
- Permissions: Contents → Read & write · Pull requests → Read & write · Workflows → Read & write · Actions → Read-only · Metadata → Read-only (auto).
Store it as the COORD_TOKEN secret. It still can't bypass your branch protection — it can only
enable auto-merge (which waits for green) and open PRs (which run your checks).
Reading each PR's green/red — important for fine-grained tokens. Fine-grained PATs cannot read check runs (GitHub exposes no "Checks" permission to grant), so the usual
statusCheckRollupfails for them. Instead, setcheckWorkflowin the config to your CI workflow file (e.g."ci.yml") — the coordinator then reads the gate from that workflow's run conclusion via the Actions: read permission, which fine-grained tokens do support. (If you use a classic PAT or a GitHub App token that can read checks, you can leavecheckWorkflowempty and it usesstatusCheckRollup.)
merge-coordinator.config.json (repo auto-detected in Actions via GITHUB_REPOSITORY):
{
"base": "main",
"mergeMethod": "rebase",
"requiredCheck": "",
"checkWorkflow": "ci.yml",
"holdLabel": "hold",
"lanes": [
{ "name": "serial-core", "match": ["src/core/**", "migrations/**"], "serialize": true, "revalidate": true },
{ "name": "backend", "match": ["server/**"], "serialize": false, "revalidate": true },
{ "name": "everything", "match": ["**/*"], "serialize": false, "revalidate": "collision" }
],
"revertOn": null,
"allowForks": false
}| field | meaning |
|---|---|
base |
Branch PRs target and merge into. Default main. |
mergeMethod |
rebase | squash | merge. |
requiredCheck |
Name of your single required check (e.g. a gate job). If set, arm-eligibility keys on it alone; leave "" to use the overall check rollup. (Only used when checkWorkflow is empty.) |
checkWorkflow |
CI workflow file (e.g. "ci.yml") whose run conclusion == your gate, or an array of them (["ci.yml", "security.yml"]) when several workflows are required — the gate is the worst of them, so a PR never reads green while a second required workflow is red or hasn't reported. Set this if you use a fine-grained token — it reads status via Actions: read instead of statusCheckRollup (which fine-grained tokens can't read). Empty → use statusCheckRollup. |
rerunInfraRed |
Default false. When true, a red run that contains no failed job (only cancellations, every other job benign) or whose failures are confined to actions/checkout / actions/setup-node / Set up job is re-run once instead of being treated as a verdict — for self-hosted runners where a timeout kill or a network blip is not a broken branch. Requires Actions → Read & write (the default minimal scope is read-only). It only ever runs from sweep, never from status or --dry. Every unreadable fact — an incomplete job list, an unknown attempt, a timed_out sibling — resolves to "real failure", so it cannot swallow a genuine break. |
aggregatorJob |
Optional name of your if: always() summary job (e.g. "gate"). It fails whenever anything upstream does, so it is excluded from rerunInfraRed's failure test. Leave empty if you don't have one — do not set it to a job that does real work. |
holdLabel |
Label that parks a PR (never auto-merged). Default hold. |
lanes |
Rules matched against a PR's changed files. A PR is serial (one-at-a-time) if it matches any lane with serialize:true, and revalidated if it matches any lane with revalidate set. revalidate takes three values: false (never), true (always re-validate when behind base), or "collision" (re-validate only when the PR's files overlap what base changed since the branch diverged — a behind PR that touches nothing base moved cannot backtrack anything, so it merges at full speed instead of paying an extra gate cycle). When a PR matches several lanes the strictest wins. No match → parallel, no revalidation. Globs support * and **. |
revertOn |
null, or { "workflow": "nightly.yml", "jobs": ["e2e"] } — if that post-merge workflow's run on the base fails (optionally only when one of jobs failed), open a revert PR; the next sweep arms it under your lane rules. It waits out any newer in-progress run of that workflow, respects the merge method (merge commits revert with -m 1; a rebase-landed multi-commit PR reverts as a range), and never reopens a revert a human closed. Use jobs to exclude infra-flaky jobs — the trigger assumes the failing run points at the commit to revert. |
neverArmBranches |
Branch globs whose landing belongs to another system — a release daemon, a human sign-off flow (e.g. ["fix-line/**"]). The coordinator never arms these, however green they look. Use it whenever something else deliberately opens a PR and leaves it unmerged pending an approval: arming such a PR merges it the instant checks pass, and the approval never happens. |
allowForks |
Default false: PRs from forks are never armed (a repo whose CI skips fork PRs would otherwise count that skip as green). Set true only if your checks genuinely run for fork PRs. |
Lanes, in plain terms: put paths that are risky to combine (schema/migrations, tightly-coupled
core logic) in a serialize:true lane so only one lands at a time and is re-checked against current
base first. Put paths that just need up-to-date validation (backends, anything order-sensitive) in a
revalidate:true lane. Give everything else revalidate:"collision" — it costs nothing on the
common path and still catches the case where a PR green against an old base would silently revert
newer work in a file they both touch. Anything unmatched flows in parallel with no revalidation.
Any device:
gh workflow run merge-coordinator.yml -f action=status— read-only queue board.... -f action=arm -f pr=42— arm a specific PR (alsohold/unhold/sweep/revert-check).- Locally:
node coordinator.mjs status(orsweep,arm 42,hold 42,revert-check; add--dry). - GitHub Mobile: view the queue, add/remove the
holdlabel, trigger the workflow.
Optional Discord pings: set a DISCORD_WEBHOOK_URL secret and it posts a line on every action.
"Arm everything on green" means a PR merges the moment it's green — including one you still want to
review or test manually. To hold a PR back: mark it a draft, or add the hold label. The
coordinator leaves draft / hold PRs completely alone. Drop the label (or mark ready) when you're
happy and it lands.
- Push to your base branch, or merge past a required check / branch protection.
- Arm two PRs in a
serializelane at once. - Arm a PR from a fork (unless you opt in with
allowForks). - Arm a PR on a branch you listed in
neverArmBranches— a green PR that another system is holding for someone's sign-off is not an oversight to tidy up. - Auto-resolve a merge conflict (a conflicting PR is reported "needs a rebase — hand it back").
- Auto-revert a failure outside your configured
revertOn(it just reports it).
- Not for merge-queue branches. If your base branch requires GitHub's native merge queue,
--automeans "enqueue," not "arm," and the coordinator's model doesn't apply — you already have a queue. - The revalidation guarantee is event-driven, not atomic. An armed fresh PR whose base moves can be merged by GitHub before the push-triggered sweep disarms it (a window of seconds). For a hard guarantee on serial paths, additionally enable branch protection's "require branches to be up to date" — the coordinator then keeps arming/updating for you and the race disappears.
holdis a brake, not a lock. The coordinator re-reads a PR's state immediately before it arms, so a label applied while a sweep is running is almost always caught — but a hold applied in the last few hundred milliseconds before the arm can still lose the race. If a PR must be impossible to merge rather than merely un-armed, convert it to a draft or use a required check that GitHub itself enforces.
- Reads the open PRs with
gh pr list, and each PR's changed files with the GitHub API — no cloning. - Maps files to lanes with the globs in your config.
- "Stale" is detected with
compare/base...headSHA(behind_by) — the SHA, not the branch name, so fork PRs resolve correctly — and it works even with branch-protection strictness off. A stale revalidate-lane PR is refreshed first and armed only on a later sweep, once its checks have run against the current base (arming a stale-but-green PR would merge it immediately, unrevalidated). - Idempotent: every run re-derives state and only acts on what's out of place — safe to run on a cron and on events at once (a global concurrency group serializes runs).
npm test (or node --test) runs the unit suite. The sweep's decisions come from a
pure planner (planSweep), so the coordination invariants — at most one armed serial
PR under every condition, refresh-before-arm for stale PRs, fail-closed on every
unreadable state — are asserted directly, alongside glob matching, lane
classification, check rollup, eligibility, and secret scrubbing. Zero dependencies,
Node 18+. The config is strictly validated at startup: a misspelled key refuses to
run rather than silently disabling a lane.
MIT — see LICENSE. Use it, fork it, share it.