Debugging a stuck merge queue Jump to heading

A stuck queue presents as silence. The pull request is approved, the interface says it is queued, and nothing happens until a timeout ejects it an hour later. Because the queue sits between three systems — the ruleset, the pipeline and the forge’s own scheduler — the instinct to check the most visible one first usually wastes the most time. This is the diagnostic order that finds the break fastest, within merge queues and required checks.

When to use this approach Jump to heading

  • Entries sit in the queue and time out without a pipeline run.
  • A run happens, goes green, and the entry still does not merge.
  • The queue worked yesterday and nothing obvious changed.
  • Only some pull requests stick — typically documentation-only ones.
  • If entries are being ejected after a red run, the queue is working correctly and the failure is real; start from the test output instead.

Step 1 — Ask whether a candidate branch exists Jump to heading

The first fork in the road: did the queue build anything at all? A candidate branch appears on the remote for the lifetime of an entry.

# A live entry has a candidate ref; no ref means the queue never started
git ls-remote origin 'refs/heads/gh-readonly-queue/*'

If there is no candidate ref, the queue never accepted the entry — the problem is in the ruleset or the approval conditions, and Step 4 is where to go. If there is one, the queue is working and the pipeline is not answering, which is Step 2.

The first question splits the search in halfThe presence of a candidate branch on the remote separates two entirely different failures. Without one, the queue never accepted the entry and the fault is in the ruleset. With one, the queue is waiting for a check result that is not arriving.Does a gh-readonly-queue ref exist on the remote?no refQueue never accepted itruleset or approvalref presentWaiting on a checkpipeline or namingtwo minutes with ls-remote saves an hour of reading logs

Step 2 — Confirm a merge_group run was triggered Jump to heading

# Runs triggered by the queue specifically
gh run list --event merge_group --limit 10 --json displayTitle,status,conclusion,createdAt

An empty list with a live candidate branch means no workflow listens for the event. This is the single most common cause, and the fix is one line in the pipeline, described in setting up a GitHub merge queue.

# Which workflows declare the trigger?
grep -rl 'merge_group' .github/workflows/ || echo "no workflow listens for merge_group"

Step 3 — Compare reported names against required names Jump to heading

A green run that does not release the entry means the names do not match. The gate waits for a literal string; a job renamed, a matrix dimension added, or a workflow renamed all change what gets reported.

# What the ruleset waits for
gh api repos/:owner/:repo/rulesets --jq '.[].rules[]?
  | select(.type=="required_status_checks")
  | .parameters.required_status_checks[].context' | sort > /tmp/required.txt

# What actually reported on the latest candidate run
gh api repos/:owner/:repo/commits/HEAD/check-runs --jq '.check_runs[].name' | sort > /tmp/reported.txt

diff /tmp/required.txt /tmp/reported.txt

What the diff tells you: a line only in required.txt is a name the gate is waiting for and nobody produces — that is your stuck entry.

The three ways a name driftsAdding a build matrix, renaming a job, or renaming the workflow all change the reported check-run name while the ruleset keeps waiting for the old string. Each looks like a hung queue and none produces an error message.Ruleset waits forPipeline now reportsmatrix addedtesttest (node 20)job renamedunitunit-testsworkflow renamedci / testbuild / testunchangedlintlintthree edits that feel cosmetic, one symptom: entries that never land

Step 4 — Check whether a required job was skipped Jump to heading

A path filter that skips a required job produces a permanent wait, because a skipped job reports nothing at all.

# On the stuck pull request, which required names are missing a result?
gh pr checks --json name,state --jq '.[] | select(.state=="PENDING") | .name'

# Confirm the job's condition excluded it
gh run view --json jobs --jq '.jobs[] | {name, conclusion}' | grep -i skip

The fix is a fallback job reporting success under the same name, covered in choosing required status checks that actually gate.

The diagnostic order, cheapest check firstEach step in the order eliminates roughly half the remaining causes, and every one is a single command. Working through them in order finds the break in one pass, where starting from the pipeline logs usually does not.Candidate refls-remotequeue accepted?merge_group rungh run listtrigger present?Name diffrequired vs reportedSkipped jobpath filterno result at allWrite accessprotectionbranch healthfive commands, in this order — not five hypotheses in any order

Step 5 — Rule out permissions and a broken default branch Jump to heading

Two less common causes remain, and both are cheap to exclude.

# Can the queue's identity actually write to the branch?
gh api repos/:owner/:repo/branches/main/protection --jq '.restrictions // "no push restrictions"'

# Is the default branch itself red? Every candidate inherits its state.
gh run list --branch main --limit 5 --json conclusion,displayTitle

If the default branch is broken, every candidate tree contains the breakage and every batch fails. The queue is reporting accurately; it is just reporting about someone else’s change.

SAFETY WARNING — resist the urge to fix a stuck queue by turning off the ruleset and merging by hand. Doing so lands untested trees at exactly the moment your confidence in the pipeline is lowest, and the merges that follow are the ones nobody reviews afterwards. If you must bypass, do it through the named break-glass path and record it.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why does the interface show the entry as running when nothing is running? Jump to heading

The queue reports its own state, which is “waiting for checks”, and has no way to know whether any check will ever start. That is why the diagnostic begins with the refs and the run list rather than the queue view: those two show what exists, while the queue view shows what is expected.

Only documentation pull requests get stuck. Why? Jump to heading

Because they match no path filter, so the expensive required jobs never run and never report. Everything else exercises at least one filter and reports normally. The fallback-job pattern fixes it permanently.

After fixing the cause, do stuck entries recover on their own? Jump to heading

Usually not — they are waiting on a check run that was never created for that specific candidate, and the fix applies to the next one. Remove and re-enqueue them once a fresh entry has been observed to land.