Setting up a GitHub merge queue Jump to heading

Enabling a merge queue looks like a single toggle and is not. The toggle depends on a ruleset that forbids direct merges, a workflow that listens for a trigger most pipelines do not have, and a check name that must match between three different places. Miss any one and the symptom is identical: pull requests enter the queue and sit there until they time out. This recipe walks the whole chain in order, as part of merge queues and required checks.

When to use this approach Jump to heading

  • Your default branch breaks a few times a month from changes that were individually green.
  • More than three or four pull requests merge per day, so the ordering actually matters.
  • Your pipeline finishes in minutes rather than an hour — a queue amplifies pipeline slowness.
  • You are willing to forbid manual merges on the default branch, which the queue requires to mean anything.
  • If your team merges twice a week, a queue adds ceremony without removing a problem you have; strengthen pre-push validation rules instead.

Step 1 — Add the merge_group trigger to the pipeline Jump to heading

The queue builds a candidate branch and waits for check runs on it. A workflow that only triggers on pull_request never runs there, so the queue waits for a result that cannot arrive.

# .github/workflows/ci.yml
name: ci
on:
  pull_request:
  merge_group:            # the candidate branch the queue builds
  push:
    branches: [main]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

What changed: the same job now reports on ordinary pull requests and on the queue’s candidate branch, so one definition serves both.

# Verification: commit the workflow, then confirm the trigger is parsed
gh workflow view ci.yml --yaml | grep -A3 '^on:'
The three names that must agreeA required check is named in three places: the job in the workflow file, the check run the API reports, and the rule in branch protection. If any two disagree the queue waits forever for a result it will never recognise.where the name of a required check is decidedWorkflow job idverify, in ci.ymlReported check runci / verifyRuleset requirementthe exact string the gate waits forcopy the middle value verbatim — never retype it

Step 2 — Create the ruleset that forbids direct merges Jump to heading

A queue that people can step around is a suggestion. The ruleset is what makes it a rule, and it is also where the required check names live.

# Inspect what protects the default branch today
gh api repos/:owner/:repo/rulesets --jq '.[] | {id, name, target}'

# Read the exact check-run name from the most recent run on main
gh api repos/:owner/:repo/commits/main/check-runs --jq '.check_runs[].name'

Create a ruleset targeting the default branch with pull requests required, direct pushes blocked, and the merge queue enabled. Paste the check-run name from the command above into the required-checks list rather than typing what you think the job is called.

# Verification: a direct push must now be refused by the server
git push origin HEAD:main 2>&1 | grep -i 'protected\|rejected'

What changed: merging is now possible only through the queue, which is the precondition for anything the queue reports to be meaningful.

Step 3 — Choose the merge method and batch size Jump to heading

The queue can land entries as merge commits, squashes, or fast-forwards. Fast-forward keeps history linear and, more importantly, means the commit on the default branch is bit-for-bit the commit that was tested.

Merge methods available to the queueA fast-forward lands exactly the tested commit and keeps history linear. A merge commit creates a new, untested object at the tip. A squash rewrites the tested tree into a fresh commit, so what lands is not what was verified.Merge commitFast-forwardwhat landsa new, untested objectthe exact tested commithistory shapea merge per entrylinearbisect costnoisycleanrevertone commit reverts the setone commit per entryfast-forward is the default worth arguing for: the tested commit and the landed commit are the same object

Start with a batch size of one. It is the slowest and the least surprising, and you can raise it once you know your entry failure rate — the arithmetic for that is in batching pull requests to cut CI cost.

Step 4 — Queue the first pull request and watch the refs Jump to heading

The first run is where name mismatches surface. Watch the queue’s own refs rather than the web interface, because the refs show whether a candidate branch was built at all.

# A candidate branch appears while an entry is in flight
git ls-remote origin 'refs/heads/gh-readonly-queue/*'

# Follow the run that the candidate branch triggered
gh run list --event merge_group --limit 5

If ls-remote shows a candidate branch but gh run list shows nothing, the trigger from Step 1 is missing or misspelled. If neither shows anything, the pull request never entered the queue — usually because the ruleset requires a check that has not reported, so the entry is still waiting for approval conditions.

# Verification: after the run is green, main should point at the tested commit
git fetch origin && git log --oneline -1 origin/main
What happens in the first two minutes after enqueueingEnqueueing creates a candidate branch, which triggers a merge-group pipeline run. When that run reports green under the exact name the ruleset requires, the default branch fast-forwards onto the tested commit and the candidate branch is deleted.Enqueuedentry joins the linet+0sCandidate branchgh-readonly-queue/…t+5sPipeline startsmerge_group eventt+8sGreenname matches the rulet+6mFast-forwardmain advancest+6mif the timeline stalls at the candidate branch, the trigger is missing; if it stalls at green, the name does not match

Step 5 — Define the break-glass path before you need it Jump to heading

There will be an incident where the queue’s latency is unacceptable. Decide now who may bypass it and what they must do afterwards, because the alternative is someone discovering the bypass under pressure and it becoming routine.

# Record every bypass so the exception stays an exception
gh api repos/:owner/:repo/rulesets/RULESET_ID/history --jq '.[] | {actor: .actor.login, at: .created_at}'

SAFETY WARNING — granting bypass rights to a whole team removes the guarantee the queue exists to provide, and it does so silently: the branch stays protected, the queue stays configured, and nothing reports that the last twelve merges skipped it. Scope bypass to a named break-glass role, alert on its use, and review those alerts.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why does my entry time out with no pipeline run? Jump to heading

Almost always the missing merge_group trigger. The queue creates its candidate branch, no workflow listens for that event, no check run is ever reported, and the entry waits until the queue’s timeout expires. Confirm with gh run list --event merge_group: an empty list with a live candidate branch is conclusive.

Can required checks differ between pull requests and the queue? Jump to heading

They can, and doing so undermines both. If the queue requires less than the pull request did, changes reach the default branch having passed a weaker gate; if it requires more, authors see green on their branch and an unexplained rejection afterwards. Run the same job for both events and require the same name.

What happens to an entry when someone pushes to its branch? Jump to heading

The entry is removed from the queue, because the thing that was about to be tested no longer exists. The pull request returns to its normal state and has to be approved and enqueued again. This is worth explaining to the team, since an innocent “just fixing a typo” push during a long queue is a common source of confusion.