Skip to content

Merge-ready bot

Keeps every open PR based on main in a landable state — merges cleanly, checks green — so a reviewer can crawl the open-PR list and approve without fixing anything first.

Owner: engineeringWhen to use: your PR got a bot commit, or a deconflict labelOpt out: no-merge-ready

Canonical sources. The triage decisions are .github/scripts/merge-ready-core.ts (pure, and unit-tested next to it); the git and API work is .github/scripts/merge-ready-fix.ts; the wiring is .github/workflows/merge-ready-bot.yml and its reusable half. This runbook is the HOWTO — when those disagree with it, they are right.

I do not want this on my PR

Apply the no-merge-ready label. The bot then skips the PR entirely: no merge, no resolution, no label.

Good reasons to reach for it, none of which the bot can infer:

  • you are mid-rebase and do not want commits appearing underneath you
  • the branch is pinned to an old base to reproduce something, and moving the base destroys the repro
  • it is a long-lived spike that is not headed for review, so the churn is noise

The bot also skips, without being asked: drafts, approved PRs (a bot push would dismiss the approval — this repo's main sets dismiss_stale_reviews_on_push, so that is literal, not theoretical), forks (an installation token cannot push to one), Dependabot PRs (they have their own lockfile-sync and auto-merge path), and anything labelled DO NOT MERGE or stacked.

That gate is re-checked against a fresh read immediately before the push, not just when the sweep started. A sweep runs five PRs at a time and a leg waiting on checks can hold for 25 minutes, so an approval landing mid-sweep would otherwise still be dismissed by a bot commit.

Drafts are skipped entirely. A draft is its author saying the branch is not ready for anyone to act on, and a bot commit landing under someone mid-iteration is exactly the interruption that applies to. Mark it ready and the next sweep picks it up, or dispatch the workflow on its number to settle it now.

Stacks are skipped, and the detection is structural — not the stacked label. Merging main into any layer of a gh-stack chain desyncs the layers above it and leaves a merge commit for gh stack rebase to replay over. Use gh stack sync / gh stack rebase — see the stacking rules in CLAUDE.md.

Two different things protect the two positions in a chain:

PositionBase isWhat excludes it
Mid-stackanother PR's branchthe sweep only lists PRs based on main, and the dispatch path rejects any other base
Bottommainanother open PR is based on its head

The bottom is the one that needed real work: its base is main, so it looks like an ordinary PR. Do not reach for the stacked label to fix that — nothing applies it. When this was written, zero of 43 open PRs carried it while three live stacks were open, two of them five layers deep, and all three bottoms were selected and behind main. The label remains as a manual override; the guard is the topology read.

What it does, and when

There is no merge-conflict webhook, and — this is the part that motivates the whole thing — when a PR lands on main and breaks three other open PRs, GitHub fires nothing on those three. So the trigger is the push to main, fanning out over every open PR based on it. To settle one PR immediately, dispatch the workflow with its number.

It is not a PR check, and must never become one. There is deliberately no pull_request trigger: a PR-triggered workflow posts a check run onto every PR, so this bot would sit in each one's check list — red whenever the sweep hit an unrelated problem — and read as something the author has to fix. Its absence is also what makes the self-trigger loop impossible: the bot pushes to PR branches, and no push to a PR branch matches branches: [main]. There is no runtime guard behind that; the trigger list is the guard.

The accepted cost: an outcome label only moves when the sweep runs. A PR the bot just pushed a fix to stays unlabelled until the next merge to main re-reads its checks, and a PR broken by its author after a clean sweep keeps its clean label state until then. Dispatch the workflow on that PR number if you need it settled now.

Two detection paths, because the two failures surface in different places:

What it catchesHow
Git conflictBoth sides edited the same linesGET /pulls/{n}.mergeable, polled to a boolean — it is null while GitHub computes the test merge
Out of datemain moved on, and the merged tree is worth readinggit merge-base --is-ancestor for behind-ness (not mergeable_state)
Clean merge, forked graphDivergent Alembic headscountHeads over the merged tree, plus the migration/single-head status

Note what row two does not say. Being behind is not on its own a reason to push — the merge is read locally and rolled back unless it earns the push. See the churn gate below.

It reads exactly one CI signal: migration/single-head. Red unit tests, a failing lint, a flaky e2e — none are this bot's business. The author's own CI already reports them and a bot repeating it is noise. Divergent heads are the exception because they are the one failure a merge creates. A pending status is never judged; the next sweep reads it again.

Fixing failing tests may be worth doing one day. It is out of scope now.

Out-of-dateness is asked of git, and must stay that way. GitHub only emits mergeable_state: "behind" when the ruleset requires branches to be up to date, and this repo's main sets strict_required_status_checks_policy: false — so open PRs report clean, blocked or unknown and behind never appears. Keying the update path on that field made the path unreachable: every cleanly-mergeable PR read as current, so the bot never merged main into a stale branch, and the divergent-heads check ran against the bare branch, which by construction has one head because the fork exists only in the merge result.

The second path exists because two migrations off the same parent are separate files with no overlapping lines. Git reports a clean merge, mergeable comes back true, and the fork exists only in the merge result — which is why the head count runs over the merged tree, before CI has said anything.

The churn gate: merge to read, push only when it buys something

Being behind main is not a reason to push main into a branch. The bot separates the two decisions, because only one of them costs anybody anything:

  • The local merge costs a checkout. It happens on every behind branch.
  • The push costs a dev an unrelated commit in their branch and a full CI re-run. It has to be earned.

Conflating them is what made the bot churn. The measured shape: one PR taking five bot merges inside a day, pulling in 85 commits of main that touched zero of its 14 files — and it merged cleanly before the first one. Five CI runs on someone else's PR, no conflicts prevented, no mergeability gained. A second: a PR whose merge base was 7 days and 338 commits old took a merge commit onto a branch that was green, approved and cleanly mergeable, resetting its 67-check suite.

The behind-but-clean path no longer asks whether a conflict is reachable.mergeable: true is GitHub's own three-way merge of the two commits coming back clean, and that merge is symmetric in its sides — same merge base, same two tips, same per-path conflict test — so no textual conflict exists in either direction. Any file-level proxy for the question could only re-answer one git had already settled. Confirm it on any pair yourself:

bash
git merge-tree --write-tree <pr-head> origin/main
bash
git merge-tree --write-tree origin/main <pr-head>

Both print the same tree and exit the same way.

So why merge at all? Because two detections can only read the merged tree, and neither is visible before it.

A PR the merge empties. netDiffAgainstBase() answers nothing until the base is an ancestor of HEAD — before the merge, a two-way diff reads main's own newer commits as lines the branch deletes. Without the merge, a PR whose work already landed on main by another route is never labelled superseded and never gets the one comment naming the commit that carries it.

Detection path 2, the forked Alembic graph. Two revisions off a shared parent are separate files with no overlapping lines, so git merges them cleanly and the fork exists only in the merge result.

The merge also cross-checks GitHub. A mergeable computed against a main that has since moved fails the local merge and escalates, rather than passing as clean — which matters here, since the bot's only trigger is the push that moved main.

The push then needs one of two things, and is rolled back without them (keepBaseMerge()):

The PR touches migrations/versions/ (matrix.migrations). That is what carries the merge revision fixing the fork, so discarding the merge there discards the fix — silently, and with a green run.

The merge carries a line no reviewer has seen — a conflict resolution, or that merge revision. Never silently thrown away.

A conflict is never gated by any of it. When mergeable is false the bot merges, triages and pushes exactly as before.

The migrations flag fails open: a PR whose file list cannot be read is treated as touching migrations, so an unreadable side costs one round of churn rather than deleting a detection path. It is validated at startup beside the other required env vars — an absent or misspelled TOUCHES_MIGRATIONS throws rather than defaulting to false.

What the gate gives up: PRs may now merge on checks that never ran against current main. This is accepted, not overlooked, and it is the part a reader will not infer from the rationale above.

The bot's pushed merge commit did one thing besides preventing conflicts: it re-ran the PR's required checks against a tree containing current main. Nothing else does — pytest and its siblings trigger on pull_request, and a push to main re-runs none of them. So semantic drift now surfaces on trunk instead of pre-land: main changes a function signature in one file, an open PR adds a caller in another, no push, no re-run — and the PR lands red.

Two things make that a trade rather than a regression:

  • The window was never closed. main does not require up-to-date branches (the ruleset sets strict_required_status_checks_policy: false), so a PR merged between two sweeps was always exposed to exactly this. The push narrowed the window opportunistically; it never guaranteed anything.
  • The price of closing it is every open PR's full CI on every trunk push — which is the cost this gate exists to remove, and on a busy day that is several runs per PR.

If it is ever worth revisiting, the shape is to bound staleness rather than drop the gate — push the merge once the branch is far enough behind, so every PR is re-tested against a recent main at a bounded rate instead of on every push:

bash
git rev-list --count $(git merge-base HEAD origin/main)..origin/main

A merge base older than a day would do the same job.

The triage gate

Before resolving anything, the bot classifies the conflict. It resolves only what has exactly one correct answer:

  • build artifacts (design-system/generated/**, the fabricated skill references) — regenerated from the merged source
  • import blocks — both sides' imports, when every hunk is imports and the diff3 base proves both sides only added
  • divergent Alembic headsflask db merge heads, and only when the two revisions touch disjoint tables

A second set defers to a human and never reaches the agent, because deferring is the deterministic answer rather than an absence of one:

  • lockfiles — regeneration is not a pure function of the two sides
  • two edits to one migration file — not the divergent-heads case
  • a conflict with no readable hunks — binary, or modified one side and deleted the other. Git leaves one copy in the tree with no markers at all, so there is nothing for anything to read
  • an empty conflict set — nothing to reason about

Only the residual — a source conflict no rule covers, which is most real conflicts — goes to the resolving agent, because there whether one right answer exists is itself the judgement.

In a mixed set the rules still win their share: the agent is briefed only on what it must judge, and build artifacts are regenerated by rule rather than hand-merged — the agent has no Bash and cannot run a generator, so a hand-merged artifact would diverge from its source and turn verify-generated-artifacts, a required check, red.

The generators run AFTER the agent, and the order is load-bearing. A generator reads source, and on this path some of that source is conflicted until the agent finishes. Run first, bun run tokens parses a DESIGN.md full of conflict markers, finds zero tokens, and exits 0 — the transform treats "no entries" as a warning, not an error — so it writes an empty :root { }. That empty artifact is staged, nothing regenerates it once the agent cleans the source, and the branch is pushed having lost every design token.

The resolving agent

Its first job is not to resolve; it is to decide whether resolving is safe. The line it applies is the one this bot was specified around:

Dev A and dev B each implemented the same behaviour with different approaches, and the right answer may be a hybrid ("take A's coverage but B's structure"). The bot cannot know that.

Resolve and it commits with a subject saying what it decided. Decline and the PR gets the label — handed to the dev and the agent that implemented the PR, who have the context the resolver does not. What collides is written to the run log.

Four things make a decline cheap and a resolution expensive to reach:

GuardWhat it does
Prompt biasdeclining is the stated default; "when in doubt, decline"
Fail-closed parsingany malformed verdict — unparseable, no summary, no files — becomes a decline
All-or-nothinga verdict naming fewer than every conflicted file is rejected as partial
Scope verificationthe tree is diffed after; touching any file git did not report as conflicted aborts the push
Marker checka surviving <<<<<<< aborts the push

The last two are checked against the tree, never the agent's own report — what it claims to have edited is exactly the thing under test.

One verdict per PR, never per file. The assessment is made over the whole conflict set first: if a single hunk in a single file needs its authors, the bot declines everything and touches nothing.

There is no "I did five, you take the other three". That is the worst outcome available — the dev still has to stop, context-switch and resolve the hard ones, and now they must also review five resolutions they did not make, on a branch that moved under them. Handing back the whole thing costs one context switch; handing back most of it costs that switch plus a review.

The prompt says so, and the driver enforces it: a resolved verdict that names fewer than every conflicted file is rejected and the whole set escalates.

Injection is a code-execution path here, not a misleading-comment one: this agent reads a PR and then writes to that PR's branch. So it loads no setting sources — no CLAUDE.md, no .claude/rules/* from the working tree, because the working tree is the pull request. The conflict doctrine it does follow is read from the base ref with git show. Nothing telling it how to behave comes from the branch it is acting on.

The review swarm solves this by restoring those files from base before it runs. That is not available here — this runs mid-merge, so any file restored into the tree would land in the resolution commit.

No ANTHROPIC_API_KEY means no agent: every judgement conflict escalates instead, exactly as the bot behaved before the agent existed. A missing credential degrades; it never silently stops maintaining a PR.

Lockfiles are NOT auto-resolved, though they look like the same case as a build artifact. Regenerating one is not a pure function of the two sides: bun install against a manifest with version ranges can resolve a version neither branch had, if it was published in between. That is an unrequested dependency bump, landed by a bot, in a commit whose message says it resolved a conflict — and at sweep scale it lands on every PR that happens to conflict there. A build artifact is different: its generators read repo source and reach no network, so the same merge produces the same output next month.

The escalation log names the exact command — bun install, or cd crm-broker && uv lock. Mechanical for a human; not guessable for the bot.

A wrong auto-resolution is worse than a label. It lands green, gets approved on the strength of a passing check, and nobody re-reads it. That asymmetry is why the gate escalates on any doubt — including a conflict set that is mostly mechanical. One collision anywhere and the whole PR escalates.

The Alembic table-disjointness rule is the one place the bot is stricter than "produce a deterministic merge revision" implies. A merge revision fixes a running order between two migrations that previously had none; if both alter the same table, that order changes the result and only their authors know which way is right. The table parse fails closed — raw SQL, batch_alter_table, a table named by a variable, or any op the parser does not recognise makes the revision unreadable and sends the PR to a human.

It never comments, with one exception

The bot writes to a PR's conversation almost never — not to ask, not to report, not to say a conflict cleared. The label is the entire signal.

That is deliberate. The moment that matters is a reviewer scanning the open-PR list deciding what to pick up, and a label answers that without a click; a comment saying the same thing is a notification, an unread badge and a line of scroll on top of it.

The exception is a PR that merging main EMPTIES — the branch ends up identical to main, so it proposes nothing. The bot pushes nothing there, labels it superseded, and comments once naming the commit on main that already carries each file. The label cannot carry that name, and recovering it by hand costs a bisect. It recommends closing and never closes: the branch may hold a description or a discussion, and that call is the author's. Push content to the branch and the next sweep drops the label and deletes the comment.

The reasoning is not lost, only un-broadcast. Every decline and every agent resolution is written to the run's job summary, so a sweep reads as one page on the Actions run rather than N job logs:

🚦 #11467 — handed to a human both sides rewrote the same retry policy; taking either drops the other's backoff

That page is the tuning signal. If the agent is asking for a human over something silly, it shows up there — and resolutions are listed beside declines on purpose, because judging whether the decline rate is right needs both numerators. Nothing about it reaches a PR.

What the author loses, stated plainly: the label tells them a human is needed, not what collides. That is the accepted trade for a conversation the bot never touches, and it is easy to reverse if awareness turns out to matter more than quiet.

Deferring the conflict does not mean deferring everything else. If the branch merges cleanly but the Alembic graph forked in a way the bot will not merge, it still pushes the base merge and labels only the fork. Rolling that back would hand you two jobs — update your branch, then merge the heads — when the bot could do the first. One thing on your plate, not three.

The exception is a source conflict it declines: resolving it is what produces the merged tree, so there is nothing to keep. The branch is left exactly as you had it.

What lands on your branch

  • Nothing at all, unless the push buys something — your PR has to touch migrations/versions/, or the merge has to carry a resolution. Your branch being behind is not enough: the bot merges main locally to read the result, then rolls it back. See the churn gate.
  • A merge, never a rebase. A rebase force-pushes over your work.
  • The resolution is its own commit, with a subject naming what it decided (deconflict: regenerated bun.lock), so it reviews in isolation.
  • If resolution is not clean, git merge --abort and a comment. The bot never pushes a half-resolved tree, and never force-pushes.

Why it does not satisfy the AI review

A bot resolving a conflict and thereby turning its own review gate green is circular. Two things prevent it, and the second is easy to break by accident:

  1. After a push carrying decided content — a resolution, or a new Alembic merge revision — the bot drops bot-approved.

    Never after a clean base merge. That push changes no line of the PR's diff, so the swarm is meant to carry its verdict onto the new head; dropping the label there destroys a verdict the dev earned and forces a review round over an unchanged diff.

  2. The commit subject is chosen against the review swarm's skip gate. isBaseMergeShaped() in devtools/review-swarm/skip.ts matches ^Merge branch 'main', and a matching subject lets the swarm carry its prior verdict — bot-approved included — onto the new head.

So a clean update deliberately uses Merge branch 'main' into <branch>: the diff did not change and re-reviewing it would burn a round for nothing. Git's own default (Merge remote-tracking branch 'origin/main' …) does not match, which is why the bot passes -m explicitly. A resolution deliberately uses a subject that cannot match, so it is reviewed rather than skipped.

Labels it sets

LabelMeaning
manual-deconflictConflicts in a way the bot will not resolve
supersededMerging main leaves an empty diff — the work is already on main
no-merge-readyYou set this. The bot skips the PR entirely

The bot applies one label at a time — the first two are mutually exclusive — and removes it the moment the branch merges cleanly again, or proposes something again. A list still showing work that no longer exists is worse than no list.

There is deliberately no merge-ready label. The facets of readiness GitHub cannot search for are the ones the bot's own labels carry: conflict state (is:mergeable and is:conflicting are not qualifiers, and match nothing and everything respectively) and emptiness (changedFiles: 0 is not a qualifier at all). Every other facet is already native, so the queryable list this bot exists to produce is one search that subtracts them:

is:pr is:open draft:false status:success -label:manual-deconflict -label:superseded

Add review:approved for "already approved", or review:required for "waiting on a human". A merge-ready label would restate what the PR header and the merge box already say, while adding a piece of state to keep in sync and a question about whether a draft may wear it.

Operating it

  • Manual run: the Merge Ready Bot 🚦 workflow takes an optional PR number. Empty sweeps every open PR based on main. This is also how you settle a single PR without waiting for the next merge. A number that names a closed PR, or one based on anything other than main, is refused with a log line rather than acted on — gh pr view applies neither filter, so the dispatch path checks both itself.

Does it handle every PR, or just the first few?

Every one. max-parallel is a throttle, not a cap — every selected PR gets its own matrix leg with its own runner and checkout; that many run at a time and the rest queue. Two real ceilings sit above it, and both say so in the log rather than truncating quietly:

CeilingWhat happens
gh pr list --limit (200)PRs past it are never seen — warns when the list comes back at the ceiling
GitHub's 256-leg matrix capover it the whole RUN fails, so the scan takes the first 256 and warns, leaving the rest for the next sweep

A leg is seconds to a minute: nothing waits on CI. An earlier version blocked up to 25 minutes per PR for checks to settle, which made max-parallel effectively "that many PRs per 25 minutes" and was the sweep's real scaling limit. Divergent heads are found structurally in the merged tree instead, before CI has run at all, and the one status the bot does read is sampled once — pending yields no verdict that sweep rather than a wait.

  • Dry run: the same dispatch takes a dry_run flag. It does everything — polls, merges, triages — and then logs what it would have pushed, labelled and labelled instead of doing it. Use it after any change to the triage rules.
  • Secrets: MERGE_READY_BOT_APP_ID + MERGE_READY_BOT_APP_PRIVATE_KEY, for the onramp-merge-ready-bot App.

The App needs Contents: write, Pull requests: write, Issues: write and Metadata: read. All four are granted.

A workflow's permissions: block scopes GITHUB_TOKEN only — it cannot widen an installation token. So a 403 under the App is always an App-grant problem, never something to fix in the YAML. Labels are the trap: they are an Issues-API resource, not a Pull-requests one, and the bot's first run died on exactly that before Issues was granted. The label bootstrap deliberately stays on GITHUB_TOKEN regardless — a repo-wide label is not per-PR bot work, and keeping it off the App's credential means a future narrowing of those grants cannot take the whole scan job down.

The App token is not optional, and the job fails rather than falling back. A commit pushed with GITHUB_TOKEN does not start workflow runs, so the PR's already-red checks would never re-run against the fix — leaving a PR that reads as settled sitting over a stale failure. Doing nothing and saying so is strictly better.

Internal documentation — gated behind Cloudflare Access.