Merge Queues & Required Checks Jump to heading
A pull request is tested against the tree it was branched from, then merged into a tree that has moved on since. Most of the time the difference is harmless. Occasionally it is a rename in one branch and a new caller in another, both green in isolation and broken together — the semantic conflict that no merge algorithm can see. A merge queue closes that window by testing the exact tree the merge will produce, and required checks decide which results are allowed to hold a merge back. This part of Git Automation & CI/CD Hook Engineering covers both, because a queue without well-chosen required checks is an expensive way to merge quickly.
Prerequisites Jump to heading
What a Merge Queue Actually Does Jump to heading
The queue is a serialisation point with speculation built in. When a pull request is approved it is not merged; it is enqueued. The queue builds a candidate tree — your branch merged onto the current default branch, on top of every entry ahead of you that has not landed yet — and runs the pipeline against that tree. Only if the run is green does the default branch fast-forward onto the tested commit.
The important property is not the ordering. It is that the commit which lands is byte-for-byte the commit that was tested. A traditional merge tests one tree and ships another, and the gap between them is where “it passed on the branch” comes from.
Step 1 — Decide Which Checks Are Allowed to Block Jump to heading
Before configuring anything, split your current pipeline into two lists: results that should prevent a merge, and results that are information. The split is a policy decision, not a technical one, and getting it wrong is the most common reason teams abandon a queue after a fortnight.
# List every check that has reported on recent pull requests,
# so the decision is made against reality rather than memory.
gh pr checks --json name,state --jq '.[].name' 2>/dev/null | sort -u
# How often each check has failed over the last 50 merged PRs:
gh run list --limit 50 --json conclusion,name \
--jq 'group_by(.name)[] | {check: .[0].name, failures: (map(select(.conclusion=="failure")) | length)}' A check earns the right to block when a failure means the change is genuinely not safe to merge, when the failure is reproducible, and when someone acts on it within minutes rather than filing it. Everything else — coverage deltas, bundle-size reports, advisory linting of files nobody touched — belongs in a comment, not a gate.
Verify the classification by asking what happens on a red result at 17:55 on a Friday. If the honest answer is “we merge anyway”, it is not a required check.
Step 2 — Enable the Queue and Point Protection at It Jump to heading
Configuring a queue is mostly a matter of making the branch unmergeable by hand and naming the checks the queue must see pass. On GitHub this is a ruleset; the same shape applies to GitLab merge trains and to Mergify.
# .github/workflows/queue-ci.yml — the pipeline the queue runs.
name: queue-ci
on:
pull_request: # ordinary PR feedback
merge_group: # the queue's candidate tree — required
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test The merge_group trigger is the part teams forget. Without it the queue builds a candidate tree, waits for a check that is never going to start, and times out every entry.
# Verification: the queue's own ref should appear once an entry is in flight
git ls-remote origin 'refs/heads/gh-readonly-queue/*' What changed: the pipeline now runs in two contexts. Pull-request runs give the author feedback; merge-group runs decide whether the default branch moves.
Step 3 — Size the Batch Against Your Failure Rate Jump to heading
A queue can test entries one at a time or in batches. Batching amortises the fixed cost of a pipeline run — checkout, dependency install, container pull — across several pull requests, so the cost per merged change falls sharply. The cost is that a red batch must be bisected, and the more entries in a batch, the more re-runs that takes.
The arithmetic that matters is your failure rate. If one queue entry in twenty fails, batches of five are almost always green and bisection is rare. If one in three fails, large batches spend more time bisecting than they ever saved, and a batch size of one or two is genuinely cheaper.
# Estimate the failure rate from recent queue runs before choosing a size
gh run list --workflow queue-ci.yml --limit 100 \
--json conclusion --jq '[.[] | select(.conclusion=="failure")] | length' Step 4 — Define What Happens to a Failing Entry Jump to heading
When a candidate tree fails, the queue must decide which entry is to blame. The standard behaviour is to eject the suspect pull request, rebuild the batch without it, and continue. The failure then belongs to the author, not to whoever happened to be behind them in line.
Make sure the ejection message reaches a human. A queue that silently drops entries produces the worst failure mode available: a pull request that is approved, green on its own branch, and quietly never merged.
Integration With the Rest of the Pipeline Jump to heading
A merge queue is the last gate, so the cheaper gates in front of it should already have removed the obvious failures. Formatting and staged-file linting belong in lint-staged formatting automation; build-breaking mistakes belong in pre-push validation rules, where they cost one developer ten seconds instead of costing a batch a full pipeline run. The queue’s job is the class of failure only integration can find.
Trigger design matters more once a queue exists, because every entry runs the pipeline at least once. The path filters described in CI/CD pipeline trigger mapping apply to merge_group events too, and a required check that is skipped by a path filter will hang the queue unless you make it report a neutral success. Finally, a queue changes what the default branch’s history looks like: entries land as fast-forwards, which pairs naturally with the squash vs merge vs rebase decision matrix.
Configuration Reference Jump to heading
| Setting | Typical default | Effect | When to change |
|---|---|---|---|
merge_group trigger | absent | Runs the pipeline against the queue’s candidate tree | Always add it, or required checks never start |
| Batch size | 1 | Entries tested per pipeline run | Raise when the entry failure rate is below ~10% |
| Merge method | merge commit | How the tested tree lands on the default branch | Fast-forward keeps history linear and tested |
| Required checks | none | Results that must pass before a merge | Keep to the small set that genuinely blocks |
| Queue timeout | 60 min | How long an entry may wait before ejection | Shorten it so stuck entries surface fast |
| Bypass list | empty | Who may merge without queueing | Restrict to a break-glass role, and log every use |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Entries sit in the queue and time out | Pipeline has no merge_group trigger | Add the trigger and re-enqueue |
| Required check reported as pending forever | Check name in protection does not match the job’s reported name | Copy the name exactly from a recent check run |
| Every batch is red | A genuinely broken default branch, not the entries | Fix the branch first; the queue is reporting correctly |
| Queue merges but CI never ran | A path filter skipped the required job | Emit a neutral success from a fallback job |
| Throughput collapses under load | Batch size of one plus a slow pipeline | Batch, then cut the pipeline’s fixed setup cost |
| Authors bypass the queue routinely | Latency is unacceptable for hotfixes | Define a documented emergency path instead |
Frequently Asked Questions Jump to heading
Does a merge queue slow the team down? Jump to heading
It adds latency between approval and merge, and removes latency from the far more expensive event of a broken default branch. The trade is usually favourable, because a red default branch blocks everyone while a queued pull request blocks only its author, who has already moved on to the next thing. If the added wait is genuinely painful, the pipeline is too slow — the queue has simply made that visible.
Do we still need branch protection with a queue in place? Jump to heading
Yes, and protection is what makes the queue real. Without a rule forbidding direct merges, the queue is a convention that anyone can step around, and the one time someone does is the time the default branch breaks. Protection should require the queue and name the same checks the queue runs.
How many checks should be required? Jump to heading
Few enough that every one of them is acted on immediately, which in practice is between one and four. Long required lists correlate with teams re-running jobs until they go green, which converts a gate into a slot machine. Keep everything else reporting as information.
What happens to a pull request that is ejected? Jump to heading
It returns to the pull-request state with a failing run attached and the author notified. Nothing is lost: the branch is untouched, the review stands, and re-enqueueing after a fix is a single action. Make sure the notification is routed somewhere people read, or ejected work quietly stalls.
Related Jump to heading
- Setting Up a GitHub Merge Queue — the end-to-end configuration, from ruleset to the first queued merge.
- Choosing Required Status Checks That Actually Gate — how to pick the short list that blocks, and what to do with the rest.
- Handling Flaky Tests in a Merge Queue — quarantine, retry policy, and why blanket re-runs destroy the gate.
- Batching Pull Requests to Cut CI Cost — sizing batches against your real failure rate.
- CI/CD Pipeline Trigger Mapping — the sibling topic that decides which events start a pipeline at all.