A self-contained recipe for running fully automated PR merging on GitHub-native
features only — no third-party merge SaaS or bot. It combines native auto-merge
(arms a PR) + the native merge queue (serializes and tests against the future
main), plus a set of small CI recipes that reproduce nearly everything the paid tools
add on top.
This one setup covers the job of, variously:
| Tool | What it's known for | Covered here by |
|---|---|---|
| Mergify | Auto-merge rules + merge queue + conditions | Native auto-merge + queue + gate-job conditions |
| Kodiak | Auto-merge bot (merge when green + labeled) | automerge.yml + label gate |
| Bulldozer (Palantir) | Auto-merge on label/status | automerge.yml + label gate |
| Trunk Merge / Aviator / Graphite | Merge queues: batching, bisection, priority | Native merge queue (batching + bisection) + jump-to-top |
| auto-rebase / "update branch" bots | Keep PRs current with base | Merge queue tests against latest base automatically |
| MergeFreeze / NoShip | Deploy/merge freeze windows | Scheduled "freeze" required-status recipe |
| backport bots | Cherry-pick merged PRs to release branches | korthout/backport-action recipe |
| Dependabot/Renovate auto-merge add-ons | Auto-merge dependency bumps | automerge.yml scoped to the bot author |
You pick one queue owner per branch, so this is a full swap for any of the above — not a coexistence.
PR opened (non-draft)
│
├─ auto-enroll.yml ──► GraphQL enqueuePullRequest(expectedHeadOid) — eligibility-gated
│ direct ENQUEUE (v3 doctrine, §2c); never merges
│
▼
required "ci" gate passes on the PR
│
▼
GitHub adds the PR to the native merge queue (batches up to N PRs)
│
▼
queue builds a speculative commit (main + PRs ahead + this batch)
│
├─ CI re-runs via the `merge_group` event on that commit; batch fails ⇒ auto-bisect,
│ eject the offender, the rest proceed
│
▼
green ⇒ GitHub fast-forwards main (merge done BY GitHub ⇒ `on: push` still fires)
│
▼
deploy (e.g. Vercel GitHub App on push) runs
Two native primitives + thin CI. Everything below is either native config or a small workflow you own.
| Capability | Plan required |
|---|---|
| Native auto-merge (the arm) | Any plan |
| Rulesets / required status checks | Any plan |
| Native merge queue — private/internal repo | GitHub Enterprise Cloud only |
| Native merge queue — public repo | Any plan (org-owned) |
merge_group CI event |
Tied to the queue → same requirement |
The load-bearing fact: the merge queue is the only Enterprise-gated piece, and only
for private repos. INTERNAL-visibility repos are Enterprise Cloud by definition — which
is why the queue is available to them. On a Team-plan private repo the queue simply
doesn't exist; you fall back to auto-merge only (§2), losing "tested SHA == merged
SHA" against a moving main and native batching, but keeping everything else here.
Enable at the repo level (Settings → General → Pull Requests):
gh api -X PATCH repos/OWNER/REPO -F allow_auto_merge=true -F delete_branch_on_merge=trueEnable two rules: Require status checks → add check ci; Require merge queue →
pick a merge method, group size min 1 / max 5 (raise later, see §4).
The queue — not gh pr merge — selects the method, so choose it here deliberately:
-
SQUASH — one commit per PR on main (linear history).
-
MERGE — a two-parent merge commit; PR commits are preserved rather than squashed. Field-proven: recallnet/aggregator-okf#28 landed through the native queue as a real two-parent merge commit. Use this when per-commit history matters. A battle-tested config running across five repos, two independent teams (2026-07-17): method MERGE, ALLGREEN required checks, 1–5 PR batches, zero wait time, 60-minute timeout, required
ci— with the §2c enqueue model, §2d watchdog, and §2e PR Unjam as the reliability layer (all three earned by production incidents; see footguns J–M). -
Required check name = the reporting job name (
ci), not the workflow name. -
Under a queue,
gh pr merge <n>with an explicit method is rejected — the queue owns it. -
gh pr merge --delete-branchis rejected under a queue; rely on repo-level branch delete.
# .github/workflows/ci.yml
name: CI
# Deliberately NO `push: branches [main]` trigger. Under a merge queue every main commit
# arrives via the queue, whose merge_group run just tested the IDENTICAL tree — and the
# ruleset blocks direct pushes. A post-merge re-run repeats your most expensive job
# (macOS runners bill ~10x) for zero information. (We ran with it for a day before
# noticing the triple execution.)
on:
pull_request:
merge_group: # the queue dispatches this on its speculative commit
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend: { name: Frontend, runs-on: ubuntu-latest, steps: [ /* ... */ ] }
swift-app: { name: Swift App, runs-on: ubuntu-latest, steps: [ /* ... */ ] }
ci: # single required "gate" that fans in from the real jobs
name: ci
needs: [frontend, swift-app]
runs-on: ubuntu-latest
if: ${{ always() }}
steps:
- name: Gate
run: |
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
echo "A required CI job did not succeed."; exit 1
fi
echo "All required CI jobs passed."The fan-in ci gate means the ruleset requires exactly one context; you add/remove
underlying jobs without touching branch protection, and merge_group reports the same
context the PR run does.
Two CI delivery fallbacks learned in production (add both):
on:
pull_request:
merge_group:
push:
# GitHub documents gh-readonly-queue/<base> as the queue's temporary branch prefix.
# OBSERVED: GitHub occasionally creates the speculative ref WITHOUT emitting
# merge_group. This push filter is the delivery fallback — CI still reaches the SHA.
branches: ["gh-readonly-queue/main/**"]
# Token-authored pushes (e.g. an automation rebasing a PR with GITHUB_TOKEN) emit NO
# push/pull_request events — the pusher must dispatch required CI explicitly.
workflow_dispatch: {}Production experience across five repos (two independent teams) retired the
gh pr merge --auto arm model for merge-queue repos. Two reasons, both observed live:
armed PRs can sit indefinitely armed-but-never-enqueued (the arm creates a NON-queue
auto-merge that can conflict with the "Require merge queue" ruleset), and arming is not
head-pinned (a push between arm and merge changes what ships). The v3 recipe enqueues
directly with the native queue's own verb:
Eligibility gate before every enqueue (GraphQL, per PR):
- non-draft, targets the default branch,
mergeStateStatus == CLEAN,mergeQueueEntry == null, same-repo head. Anything else: logdecision #N: skip — <reason>and exit 0. Every run also dumps the queue state before/after (observability is what made the later bugs diagnosable).
The enqueue mutation, head-pinned:
gh api graphql -f pr="$pr_id" -f head="$head_oid" \
-f query='mutation Enqueue($pr:ID!,$head:GitObjectID!){
enqueuePullRequest(input:{pullRequestId:$pr,expectedHeadOid:$head}){
mergeQueueEntry{state position}}}'expectedHeadOid makes a stale-head enqueue impossible — if anything pushed between the
eligibility query and the mutation, the enqueue fails loudly instead of shipping the
wrong commit ("gate enrollment on fresh heads").
Trigger set (each earned by a real gap):
on:
pull_request:
types: [opened, reopened, ready_for_review, synchronize]
push:
branches: [main] # PRs become CLEAN only AFTER the PR ahead merges — re-check now,
# not at the next 30-min sweep
workflow_run:
workflows: [CI] # re-check after a PR's checks settle (CLEAN arrives late)
types: [completed]
schedule:
- cron: "*/30 * * * *" # backstop only — the event paths do the real work
workflow_dispatch: {}Dedicated, non-canceling concurrency lane:
concurrency:
group: auto-enroll-${{ github.repository }}
cancel-in-progress: falseGitHub keeps ONE pending run per concurrency group. If enrollment shares a lane with any
other automation that also triggers on push: main (e.g. a conflict-repair sweep), the
two runs replace each other and one silently never executes ("separate recurring
automation lanes"). Every recurring automation gets its own <name>-${{ github.repository }} lane.
Policy exclusions still apply (e.g. Dependabot majors stay held — gate bot PRs through their own metadata-checked job exactly as in the arm model).
Reference implementations: recallnet/memracer and recallnet/playhouse
.github/workflows/auto-enroll.yml.
Two wedge states occur in production with no Actions evidence at all:
- GitHub creates the position-one speculative ref but never dispatches merge_group CI.
- Unrelated GitHub App check-suites (Vercel et al.) auto-attach to the speculative SHA and flip the entry QUEUED → AWAITING_CHECKS while the required workflow was never started.
A */5 cron watchdog queries the queue, and — only when the front entry AND its
speculative ref are older than a grace window (default 10 min) with zero evidence of the
required workflow — resets that entry (dequeue → re-enqueue), with a per-PR cooldown
(default 30 min) so it can't flap. It never merges; the native queue remains the only
merger. Root-cause companion: a dispatch-only workflow using a repo-admin fine-grained
PAT can disable automatic App check-suite creation for the known offender App IDs
(configure-check-suite-preferences) — the watchdog then rarely fires.
Reference: merge-queue-watchdog.{yml,mjs} in both repos above.
For OPEN, non-draft, default-base PRs that GitHub reports DIRTY/CONFLICTING: rebase the
head onto origin/main; clean rebase → git push --force-with-lease (CI re-runs, the PR
re-enters enrollment on its own); conflict → git rebase --abort, label
needs-manual-rebase, comment naming the conflict, leave the PR untouched. Never merges,
never bare-force, never touches queued or cross-repo heads.
Three hard-won correctness rules:
- Re-prove safety after the local rebase, before the push: a second API query must show the same head/base, still-not-queued — and the push lease names the exact head SHA both queries observed.
- Prove base currency against the LIVE ref, not a PR field: the PR's
baseRefOidis a snapshot from association time, NOT the current tip of main. Queryref(qualifiedName:"refs/heads/main"){target{oid}}and compare THAT (playhouse learned this in production after porting). - Token-authored pushes emit no events — after a GITHUB_TOKEN push, nothing triggers
CI on the new head. Unjam must explicitly
workflow_dispatchthe required CI workflow, and CI must acceptworkflow_dispatch(§2b). Own concurrency lane (see §2c). Reference:pr-unjam.ymlin both repos.
Where no merge queue exists (e.g. Team-plan private repos, §1), native auto-merge arming is still the right recipe — the ruleset conflict that killed it for queue repos doesn't apply. Everything below remains valid IN THAT CONTEXT; under a queue, use §2c instead.
# .github/workflows/automerge.yml
#
# INVARIANT — this workflow only *arms* auto-merge; it must NEVER perform the merge itself
# (no method-flagged `gh pr merge`, no direct merge API call). The native queue does the
# merge, as GitHub, so `on: push` deploys still fire. A workflow that merges with
# GITHUB_TOKEN would silently suppress every downstream `on: push` workflow. Keep it arm-only.
name: auto-merge
on:
pull_request:
types: [opened, reopened, ready_for_review, synchronize] # synchronize = self-heal
schedule:
- cron: "*/30 * * * *" # continuous-evaluation backstop (re-arm all open PRs)
workflow_dispatch: {}
permissions:
contents: write
pull-requests: write
jobs:
enroll:
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
# No method flag: the queue owns the strategy (SQUASH or MERGE, per the ruleset).
# `gh pr merge --auto --squash` under a queue only warns "merge strategy is set by
# the merge queue" and ignores the flag.
# --repo is REQUIRED: this job has no checkout, so without it gh tries to infer the
# repo from a local clone and dies with "not a git repository". Do NOT add `|| true`
# — it turns that failure into a green no-op run (we shipped exactly that bug: the
# enroll job passed green for a day while never arming anything; see footgun I).
- run: gh pr merge --auto "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY"
env: { GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" }
# CRITICAL: the sweep must be IDEMPOTENT. Unconditionally re-running `gh pr merge --auto`
# on every open PR every tick churns merge-queue entries — repeatedly re-arming a QUEUED
# PR resets queue progress and can starve merge_group builds entirely (observed live).
# Query GraphQL and arm ONLY same-repo, non-draft PRs where BOTH autoMergeRequest and
# mergeQueueEntry are null — i.e. PRs that genuinely need arming.
sweep:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Arm eligible open non-draft PRs
env: { GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" }
run: |
owner="${GITHUB_REPOSITORY%/*}"; name="${GITHUB_REPOSITORY#*/}"
query='query($owner: String!, $name: String!, $endCursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(first: 100, states: OPEN, after: $endCursor) {
nodes {
number isDraft isCrossRepository
author { login }
autoMergeRequest { enabledAt }
mergeQueueEntry { state }
}
pageInfo { hasNextPage endCursor }
}
}
}'
gh api graphql --paginate -f query="$query" -F owner="$owner" -F name="$name" \
--jq '.data.repository.pullRequests.nodes[]
| select(
.isDraft == false
and .isCrossRepository == false
and .author.login != "app/dependabot" # policy exclusion: bot majors stay held
and .author.login != "dependabot[bot]"
and .autoMergeRequest == null
and .mergeQueueEntry == null
)
| .number' | while read -r n; do
[ -z "$n" ] && continue
gh pr merge --auto "$n" --repo "$GITHUB_REPOSITORY"
echo "armed #$n"
doneNo submitter-managed secret needed. The built-in Actions GITHUB_TOKEN is sufficient
for this entire arm-only pattern when the workflow declares contents: write +
pull-requests: write — verified across multiple repos. If you inherited a custom PAT
(e.g. a MERGE_QUEUE_TOKEN), migrate to the built-in token and delete the secret; keep
the old token only until the migration PR itself has landed through the queue, so
in-flight sweeps can't fail mid-transition.
The three hardenings: synchronize re-attempts a transiently-failed arm on the next
push; the cron sweep arms any PR that slipped through un-armed (stand-in for a bot's
continuous evaluation — catches PRs that predate the workflow or whose event silently
no-op'd — but ONLY un-armed, un-queued ones; see the idempotency warning above); the
arm-only invariant keeps on: push deploys firing.
Operating model: draft = hold, "Ready for review" = release. And that is the ONLY
hold this design supports: the sweep arms every non-draft PR, so any policy that "holds"
a PR by merely not arming it (a bot bump you want reviewed, a change awaiting sign-off)
gets silently un-held on the next sweep tick. If you add such a policy, exclude those PRs
from the sweep's gh pr list filter explicitly — we learned this by watching the sweep
arm a PR we thought was held.
The queue batches up to your max group size PRs into one speculative commit; if the batch fails it auto-bisects (splits, retests halves, ejects the offender, proceeds with the rest). No workflow needed — raise max group size (up to 100) in the ruleset's merge-queue settings. Start at 5; higher = fewer CI runs, larger blast radius per failed batch. This alone is the core of what Trunk/Aviator/Graphite queues sell.
Split heavy jobs to the merge_group event so every push doesn't pay for the full suite:
jobs:
lint-unit: # cheap: runs on every PR push
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps: [ /* lint + unit */ ]
e2e-full: # expensive: ONLY on the queue's speculative commit
if: github.event_name == 'merge_group'
runs-on: ubuntu-latest
steps: [ /* full e2e / integration */ ]Gotcha: the required check must still resolve on merge_group. Keep the fan-in ci
gate if: always() and have it treat a not-run job as pass on the event where it's
intentionally skipped, or make separate gate contexts per event.
Do NOT make a paths:-filtered workflow a required check — when it doesn't run it
reports no status (blocks forever) or a skip that counts as pass (merges ungated), and
merge-queue commits don't re-evaluate PR path filters anyway. The robust pattern is one
always-running gate job that inspects the PR itself and exits 0/1:
policy-gate:
name: policy-gate # make THIS a required check
runs-on: ubuntu-latest
if: ${{ always() }}
steps:
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
migrations: ['db/migrations/**']
- name: Enforce policy
env:
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
# e.g. require an explicit opt-in label to auto-merge
case ",$LABELS," in *,auto-merge-ok,*) ;; *) echo "missing auto-merge-ok label"; exit 1;; esac
# e.g. migrations need a second reviewer/label
if [ "${{ steps.changes.outputs.migrations }}" = "true" ]; then
case ",$LABELS," in *,db-approved,*) ;; *) echo "migration without db-approved"; exit 1;; esac
fiThis gives Mergify/Kodiak/Bulldozer-style "merge only when labeled / path X needs extra
approval / author is trusted" — all inside one required gate you control. To route
auto-merge by author (e.g. only bots), add github.event.pull_request.user.login == 'dependabot[bot]'
to the enroll job's if.
Wrap the flake-prone step so a transient failure doesn't eject the PR from the queue:
- uses: nick-fields/retry@v3
with:
timeout_minutes: 20
max_attempts: 3
retry_on: error
command: npm run test:e2eBest practice: retry only the known-transient class (network, browser boot), not the whole suite, so you don't teach CI to ignore real failures.
A scheduled job posts a failing required status during the freeze window; a bypass label lets emergencies through:
# .github/workflows/freeze.yml
on:
schedule: [{ cron: "*/15 * * * *" }]
pull_request: { types: [opened, synchronize, reopened, labeled, unlabeled] }
permissions: { statuses: write, pull-requests: read }
jobs:
freeze:
runs-on: ubuntu-latest
steps:
- env: { GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" }
run: |
HOUR=$(date -u +%H); DOW=$(date -u +%u) # pass timestamps in if reproducibility matters
FROZEN=false
[ "$DOW" -ge 5 ] && FROZEN=true # e.g. freeze Fri–Sun UTC
# set a required "merge-window" status on every open PR head (skip if labeled emergency)
gh pr list --repo "$GITHUB_REPOSITORY" --state open --json number,headRefOid,labels --jq '.[]' \
| while read -r pr; do
# ...post success/failure to context "merge-window" based on $FROZEN and the emergency label
:
doneMake merge-window a required check. Frozen ⇒ it's red ⇒ nothing merges; add label
emergency ⇒ the job posts green for that PR.
Queue-repo caveat (learned in production): the required freeze status must ALSO be
posted on the merge queue's speculative commits, or every queue entry waits forever on
merge-window and the queue wedges. Run the freeze workflow on
push: branches: ["gh-readonly-queue/main/**"] too, and post success on the exact queue
SHA (freeze evaluation stays on PR heads; the queue ref just needs the check satisfied).
# .github/workflows/backport.yml
on: { pull_request_target: { types: [closed] } }
permissions: { contents: write, pull-requests: write }
jobs:
backport:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: korthout/backport-action@v3 # label a PR `backport release/1.2` → auto cherry-pick PRThe native queue has a "move to top of queue" control (single-level jump); it forces a rebuild of everything ahead, so use it sparingly. That covers the common "hotfix now" case. Multi-tier priority lanes (urgent > high > normal that interrupt in-flight tests) are not native — that specifically needs Trunk/Mergify. Jump-to-top via API:
PRID=$(gh pr view <n> --json id --jq .id)
gh api graphql -f query="mutation { enqueuePullRequest(input:{pullRequestId:\"$PRID\", jump:true}){ mergeQueueEntry { position } } }"Native repo setting controls the squash commit message (PR title + body, or default). Rich templating beyond that isn't native.
- A.
GITHUB_TOKENdoesn't trigger downstreamon: pushworkflows (recursion guard). Dodged here two ways: (1) we only arm; the queue merges as GitHub, sopushfires; (2) deploy via a GitHub App webhook (e.g. Vercel), neverGITHUB_TOKEN-gated. If you deploy via apush-triggered Action and merge from a workflow, merge with a GitHub App token / PAT, neverGITHUB_TOKEN. - B. Required checks must include
merge_groupor the queue stalls. - C. Queue rejects
--delete-branchand explicit merge methods — use--auto, no method. - D. Enqueuing a workflow-editing PR via the CLI can silently no-op if your local
token lacks the
workflowscope. The workflow's ownGITHUB_TOKENarms fine; a human from the terminal needsgh auth refresh -h github.com -s workflow, or enqueue via GraphQL:PRID=$(gh pr view <n> --json id --jq .id) gh api graphql -f query="mutation { enqueuePullRequest(input:{pullRequestId:\"$PRID\"}){ mergeQueueEntry { position } } }"
- E. Auto-merge + required reviews: native auto-merge waits forever for an approval and
a workflow can't self-approve with
GITHUB_TOKEN. Gate oncionly, or approve from a separate App/bot identity. - F. Under a queue, pass NO merge-method flag —
--squashjust warns and is ignored. - G. A queued branch is locked (
GH006: Branches that are queued for merging cannot be updated). To amend, dequeue first:PRID=$(gh pr view <n> --json id --jq .id) gh api graphql -f query="mutation { dequeuePullRequest(input:{id:\"$PRID\"}){ mergeQueueEntry { position } } }"
- H. Path-filtered required checks hang pending or skip-pass — gate via one
always-running job (§5), never a
paths:-filtered workflow as the required context. - I.
ghin a checkout-less job needs--repo, and|| truewill hide it when you forget. Without a checkout,gh pr mergetries to infer the repo from a local clone and dies withfatal: not a git repository. Pair that with|| trueand you get the worst failure mode in this whole setup: a green workflow that never arms anything (we shipped exactly this — the enroll job passed for a day while every actual arm was the sweep or a human). Always pass--repo "$GITHUB_REPOSITORY", and let the arm step fail loudly rather than swallowing its exit code. - J. A non-idempotent sweep starves the queue. Re-running
gh pr merge --autoagainst a PR that is already armed or already IN the merge queue is not a harmless no-op — it can churn/reset the queue entry, and a 30-min cron doing that to every open PR keeps speculativemerge_groupbuilds from ever finishing (observed live: queue entries evaporating with no merge_group run completing). The sweep must filter toautoMergeRequest == null && mergeQueueEntry == nullvia GraphQL (§2c) before arming. Also filterisCrossRepository— fork PRs can't be armed by the repo's token anyway. - K. PR API fields are snapshots; decide against live refs. A PR's
baseRefOidis the base as of association time, NOT the current tip of the base branch — an automation that "proves" base currency from it will pass the proof and act on a stale premise (observed live in a rebase automation). Queryref(qualifiedName:"refs/heads/main"){target{oid}}at decision time. Same family: event payloads describe the world at emission time — re-query current state before mutating. - L. GitHub emits events unreliably around queue merges — build state-derived
fallbacks. Observed live: the merged-PR close event simply not delivered; merge_group
not dispatched for a created speculative ref; the commit→PR association object briefly
incomplete right after a queue merge. Doctrine: every event-driven automation needs a
fallback that derives the same fact from durable state (e.g. resolve a main push's
merged PR via REST
commits/{sha}/pulls, retry briefly-incomplete associations within a bounded window, then FAIL CLOSED — never guess identity from a truncated answer). - M. The Actions token can't do everything GraphQL-wise; REST sometimes can. A
GraphQL association query that fails under the Actions installation token can succeed
via the REST equivalent (
commits/{sha}/pulls) — before reaching for a PAT, try the REST shape of the same read.
| Feature | Native? | Reproduced here? | Notes |
|---|---|---|---|
| Auto-enroll ready PRs | via workflow | ✅ §2c enqueue model (§2f arm model sans queue) | — |
| Wedged-queue self-recovery | ➖ | ✅ §2d watchdog | grace+cooldown, evidence-gated |
| Conflict auto-repair | ➖ | ✅ §2e PR Unjam | force-with-lease + live-ref proof |
| Merge queue (tested SHA == merged SHA) | ✅ (Enterprise, private) | ✅ §2 | — |
| Branch auto-up-to-date, no manual rebase | ✅ (queue) | ✅ | — |
| Continuous evaluation of all PRs | ➖ | ✅ §2c cron sweep | — |
| Batching + bisection | ✅ native | ✅ §3 | configure group size (≤100) |
| Two-step CI (cheap PR / full queue) | ✅ pattern | ✅ §4 | split on merge_group |
| Conditional gating (label/path/author) | ➖ | ✅ §5 | one always-run gate job |
| Flaky-test retry | ➖ | ✅ §6 | nick-fields/retry |
| Merge/deploy freeze windows | ➖ | ✅ §7 | scheduled failing status |
| Backports | ➖ | ✅ §8 | korthout/backport-action |
| Single-level priority (jump to top) | ✅ native | ✅ §9 | forces a rebuild |
| Merge-commit message | ➖ partial | ➖ | repo setting only |
| Multi-tier priority lanes | ❌ | ❌ | needs Trunk/Mergify |
| Parallel / scoped queues (many queues → one branch) | ❌ | ❌ | one queue per branch; only worked around with long-lived integration branches |
| Rich queue analytics dashboards | ❌ | ➖ buildable via API | no native dashboard |
Bottom line: everything except multi-tier priority lanes, parallel scoped queues, and turnkey analytics is either native or a small self-owned workflow. Those three are genuine high-scale-monorepo features; below very high PR throughput they don't pay for a paid tool.
- GitHub Docs — managing a merge queue (batching, group size, jump-to-top, bisection, removal): https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue
- GitHub Docs — automatic token authentication (GITHUB_TOKEN recursion guard): https://docs.github.com/en/actions/security-guides/automatic-token-authentication
- GitHub merge queue GA: https://github.blog/news-insights/product-news/github-merge-queue-is-generally-available/
- Merge queue availability on Team private repos (community #131130): https://github.com/orgs/community/discussions/131130
- Two-step CI / merge_group pattern: https://mergify.com/blog/enable-github-merge-queue-actions-setup
- Batching + bisection explainer: https://graphite.dev/blog/merge-queue-batching · https://tenki.cloud/blog/github-merge-queue-setup
- Path filters + required checks trap (community #44490, #45899): https://github.com/orgs/community/discussions/44490
- dorny/paths-filter: https://github.com/dorny/paths-filter
- nick-fields/retry: https://github.com/nick-fields/retry
- Merge-freeze via failing required status (Stop Merging action): https://github.com/marketplace/actions/stop-merging
- korthout/backport-action: https://github.com/korthout/backport-action
- Priority / emergency PRs (Trunk, for contrast): https://docs.trunk.io/merge-queue/optimizations/priority-merging