Ch. 16 · Git & CI/CD

CI Merge Queues and Combined Change Validation

Two pull requests can each pass CI and still break main together. A merge queue tests each one on top of everything queued ahead of it.

~6 min readadvancedupdated Oct 7, 2026

Two pull requests are open on a busy repository. Both have green checks. Both are approved. Both get merged within ten minutes of each other, and main goes red. Nobody did anything wrong by the rules, and it still happened, because each PR’s checks ran against a version of main that no longer existed by the time it merged.

A merge queue fixes this by testing changes in the order they’ll land, combined with everything ahead of them. Interviewers bring it up when talking about monorepos, trunk-based development or “how do you keep main green with 50 engineers?” The strong answer explains the underlying problem first, then the queue, then its costs.

Green plus green can equal red

Here’s a reproduction in a test repository. main has a list of supported currencies. PR 101 adds an exchange-rate table and a check that every supported currency has a rate. PR 102 adds JPY to the currency list. Each PR was branched from the same main:

pr-101-rates alone on main: tests pass
pr-102-jpy alone on main: tests pass
Text

Neither touches the other’s files, so Git merges them both without a conflict. Then:

$ git merge pr-101-rates && git merge pr-102-jpy
$ node test.js
AssertionError [ERR_ASSERTION]: no exchange rate for JPY
Text

PR 101’s new check is correct. PR 102’s new currency is correct. Together they violate an invariant that only exists once both are in. No amount of testing either PR on its own would catch this; the tested commit and the merged commit are different.

The usual fix, and why it doesn’t scale

GitHub’s branch protection has “Require branches to be up to date before merging.” With it on, PR 102 can’t merge until it has been updated with the latest main (which now contains PR 101), and its checks re-run. That catches the failure above.

On a small team it’s fine. On a busy repository it turns into a race. Ten PRs are approved. The first merges. The other nine are now out of date, so each author clicks “Update branch” and waits 20 minutes for CI. Whoever finishes first merges, and the remaining eight are stale again. Developers spend their afternoons babysitting the merge button, and CI runs about n² builds to land n changes.

What a merge queue does instead

With a merge queue, approving and passing checks makes a PR eligible to queue, not to merge. The queue then does the “update and re-test” step for you, in order, and only merges what passed in the combination it’ll actually land in.

Here’s a toy version, as a shell script, that shows the mechanism. It tests each PR on top of main plus everything ahead of it in the queue:

#!/bin/bash
# Toy merge queue: test each PR on top of main plus every PR queued ahead of it.
set -u
base=$(git rev-parse main)
for pr in "$@"; do
  git switch -q --detach "$base"
  git merge -q --no-edit "$pr" >/dev/null 2>&1 || { echo "$pr: conflicts, removed from queue"; git merge --abort; continue; }
  if node test.js >/dev/null 2>err.txt; then
    base=$(git rev-parse HEAD)
    echo "$pr: passed on top of the queue, candidate $(git rev-parse --short HEAD)"
  else
    echo "$pr: FAILED with everything ahead of it -> removed from queue"
    sed -n '/AssertionError/p' err.txt | head -1 | sed 's/^/    /'
  fi
done
git switch -q main
git merge -q --ff-only "$base" && echo "main fast-forwarded to $(git rev-parse --short main)"
Terminal
$ ../queue.sh pr-101-rates pr-102-jpy
pr-101-rates: passed on top of the queue, candidate f14725c
pr-102-jpy: FAILED with everything ahead of it -> removed from queue
    AssertionError [ERR_ASSERTION]: no exchange rate for JPY
main fast-forwarded to f14725c
Text

main only ever moves to a commit that was tested exactly as it is. PR 102 goes back to its author with a precise failure, and main never went red.

Real queues do the same thing with more machinery. GitHub’s merge queue creates temporary branches under the prefix gh-readonly-queue/main/, each containing main, the PRs ahead in the queue, and this PR. It runs your required checks on those branches. When a PR’s group passes, the queue merges it. When it fails, the PR is removed and the PRs behind it are rebuilt without it. GitLab calls its version merge trains, and tools like Mergify, Bors and Aviator do similar jobs.

Making it fast enough

Testing strictly one at a time would make the queue a bottleneck: with a 20-minute CI run, you’d land three PRs an hour. Queues use two ideas to go faster.

Speculative parallel builds. Instead of waiting for PR 1 to finish before starting main + 1 + 2, the queue starts main + 1, main + 1 + 2 and main + 1 + 2 + 3 at the same time, assuming the ones ahead will pass. If they do, three PRs land in 20 minutes. If PR 1 fails, the builds that included it are thrown away and restarted without it. GitHub exposes this as the maximum number of concurrent builds.

Batching. Test several PRs as one group and merge them together if it passes. That reduces CI runs, but a failure means splitting the batch to find the culprit (a small bisection), which delays everyone in it. GitHub’s minimum and maximum group size settings, plus a wait time, control this.

The trade-off interviewers like you to state: more parallelism costs more CI minutes, especially when failures are common, because every failure invalidates the speculative builds behind it. A flaky test is particularly damaging here. A test that fails 2% of the time doesn’t just block one PR; it kicks out a PR that was fine and forces a rebuild of everything queued behind it. Teams adopting a queue usually discover their flaky tests in the first week.

Setting it up on GitHub, and the trap

Merge queues are enabled per branch through a branch protection rule or ruleset. The trap that catches almost everyone is that your workflows must also run on the queue’s branches. The event is merge_group:

on:
  pull_request:
  merge_group:
yaml

Without the merge_group trigger, the queue creates its branch, waits for required checks that never start, and eventually times out with the PR removed. The required check names must match too, so the job that reports ci-ok on a pull request must report ci-ok on the merge group.

It’s also worth thinking about what runs where. Some teams keep a fast suite on pull_request for quick feedback during review and run the slower integration tests only on merge_group, since that’s the run that decides what reaches main. Others run the same checks in both places for simplicity.

When you don’t need one

A queue adds latency to every merge and some operational complexity. For a team of five merging a few PRs a day, “require branches to be up to date” is enough. Signs you need a queue: people complain about racing to merge, main breaks from combinations of PRs a few times a month, or you have a monorepo where many teams land changes to shared code all day.

Interview practice

Your monorepo has 80 engineers and roughly 60 merges a day. CI takes 25 minutes. main is broken about twice a week by combinations of individually green PRs, and when it’s broken, everyone’s PRs fail until someone fixes it. The team turned on “require branches to be up to date,” and now developers say they spend hours rebasing and waiting. Design a better setup and name its risks.

Answer

The breakages come from testing each PR against a stale main. The up-to-date requirement fixes that but serialises everyone through manual updates, roughly squaring the number of CI runs. A merge queue automates the same guarantee: each PR is tested on main plus everything ahead of it, and main only advances to tested commits.

With 60 merges a day and 25-minute CI, strict one-at-a-time testing would manage about 2.4 merges an hour, far too slow. So I’d enable speculative builds, perhaps 5 to 8 concurrent, and modest batching, perhaps groups of up to 3. Then I’d remove the up-to-date requirement, since the queue supersedes it, add merge_group to every required workflow, and use a single aggregate required check so job names can change safely.

The risks: CI cost rises, because speculative builds are thrown away on each failure. Flaky tests become much more expensive, so before rollout I’d measure flake rates and quarantine the worst tests out of the required set. Queue latency adds time to every merge, so I’d watch the time from “added to queue” to “merged” and tune concurrency against cost. Finally, emergency fixes need a documented way to jump the queue. GitHub supports moving a PR to the top, at the cost of rebuilding everything in progress, so it should be reserved for production hotfixes.

Further reading

More in Git & CI/CD

read ✓Git & CI/CD · mid

CI Required Checks and Meaningful Validation

A green required check can hide a failed test. How pipes, skipped jobs and path filters fool branch protection, and how one gate job fixes it.

~6 min readread →
read ✓Git & CI/CD · mid

CI Caches Versus Reproducible Build Inputs

A CI cache should make builds faster, never different. Key it on every input, cache ~/.npm rather than node_modules, and use restore-keys warily.

~6 min readread →
read ✓Git & CI/CD · mid

CI Secrets and Untrusted Contributions

Fork pull requests run code you didn't write. How GitHub Actions withholds secrets, and how pull_request_target and script injection leak them.

~6 min readread →
esc