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.

How a pull request travels through a merge queueAn approved pull request is enqueued rather than merged. The queue builds a candidate tree containing the branch plus everything ahead of it, runs the full pipeline against that tree, and only then fast-forwards the default branch onto the tested commit.Approved PRbranch tipreview completeQueue entrygh-readonly-queue/position in lineCandidate treePR + entries aheadfull pipeline runFast-forwardmain advancesalready testednothing reaches the default branch that has not been tested as the exact tree it will produce

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.

Deciding whether a check should gate a mergeA check qualifies as a required gate only when a failure means the change is unsafe and someone acts on it immediately. Checks that are merely informative belong in a comment, and checks nobody reads should be deleted rather than left running.Does a failure mean this change must not merge?yes, alwaysRequired checkblocks the mergeuseful contextInformational runreported, not enforcednobody reads itDelete the jobcost without signala check that blocks without being acted on teaches people to re-run until it is green

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.

CI minutes per merged pull request by batch sizeTesting one pull request per pipeline run pays the fixed setup cost every time. Grouping entries spreads that cost across several merges, so the machine time charged to each merged change falls steeply up to a batch of about five.CI minutes charged per merged pull requestone at a time42 minbatch of 317 minbatch of 511 minbatch of 106 minthe saving is real, but a red batch of ten costs four extra runs to bisect

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.

What the queue does when a batch goes redThe queue asks CI to test a batch. On a green result it fast-forwards the default branch. On a red result it ejects the entry it believes is responsible, notifies the author, and rebuilds the remaining batch so unrelated pull requests are not punished.authormerge queueCImainenqueue approved PRtest candidate batchred — one entry at faulteject and notifyre-test remaining batchgreenfast-forwardan ejected entry blocks nobody else — the rest of the batch continues

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

SettingTypical defaultEffectWhen to change
merge_group triggerabsentRuns the pipeline against the queue’s candidate treeAlways add it, or required checks never start
Batch size1Entries tested per pipeline runRaise when the entry failure rate is below ~10%
Merge methodmerge commitHow the tested tree lands on the default branchFast-forward keeps history linear and tested
Required checksnoneResults that must pass before a mergeKeep to the small set that genuinely blocks
Queue timeout60 minHow long an entry may wait before ejectionShorten it so stuck entries surface fast
Bypass listemptyWho may merge without queueingRestrict to a break-glass role, and log every use

Troubleshooting Jump to heading

SymptomLikely causeFix
Entries sit in the queue and time outPipeline has no merge_group triggerAdd the trigger and re-enqueue
Required check reported as pending foreverCheck name in protection does not match the job’s reported nameCopy the name exactly from a recent check run
Every batch is redA genuinely broken default branch, not the entriesFix the branch first; the queue is reporting correctly
Queue merges but CI never ranA path filter skipped the required jobEmit a neutral success from a fallback job
Throughput collapses under loadBatch size of one plus a slow pipelineBatch, then cut the pipeline’s fixed setup cost
Authors bypass the queue routinelyLatency is unacceptable for hotfixesDefine 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.