Skip to content

Instantly share code, notes, and snippets.

@andrewxhill
Last active July 17, 2026 15:49
Show Gist options
  • Select an option

  • Save andrewxhill/eb36e01bf54f672b5648c6e8c435e118 to your computer and use it in GitHub Desktop.

Select an option

Save andrewxhill/eb36e01bf54f672b5648c6e8c435e118 to your computer and use it in GitHub Desktop.
GitHub-Native Merge Automation — a complete replacement for merge-automation bots (Mergify/Kodiak/Bulldozer/Trunk/Aviator/Graphite/MergeFreeze/backport bots), with native config + CI recipes for each feature

GitHub-Native Merge Automation — a complete replacement for merge-automation bots

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.

Tools this replaces

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.


0. Architecture at a glance

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.


1. Prerequisites & the one real Enterprise dependency

Capability Plan required
Native auto-merge (the arm) Any plan
Rulesets / required status checks Any plan
Native merge queueprivate/internal repo GitHub Enterprise Cloud only
Native merge queuepublic 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=true

2. Core: the gate check, the queue, and arm-only auto-merge

2a. Ruleset on main

Enable 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-branch is rejected under a queue; rely on repo-level branch delete.

2b. CI must answer on pull_request AND merge_group (missing merge_group = the #1 stall cause)

# .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: {}

2c. Auto-enrollment v3 — the ENQUEUE model (supersedes the arm model under a queue)

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: log decision #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: false

GitHub 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.

2d. Merge Queue Watchdog — recovering wedged queue entries

Two wedge states occur in production with no Actions evidence at all:

  1. GitHub creates the position-one speculative ref but never dispatches merge_group CI.
  2. 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.

2e. PR Unjam — conflict repair that hands work back to the queue

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:

  1. 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.
  2. Prove base currency against the LIVE ref, not a PR field: the PR's baseRefOid is a snapshot from association time, NOT the current tip of main. Query ref(qualifiedName:"refs/heads/main"){target{oid}} and compare THAT (playhouse learned this in production after porting).
  3. Token-authored pushes emit no events — after a GITHUB_TOKEN push, nothing triggers CI on the new head. Unjam must explicitly workflow_dispatch the required CI workflow, and CI must accept workflow_dispatch (§2b). Own concurrency lane (see §2c). Reference: pr-unjam.yml in both repos.

2f. FALLBACK — the arm model (only for repos WITHOUT a merge queue)

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"
          done

No 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.


3–9. Reproducing the paid-tier features (optional add-ons)

3. Batching + bisection — already native, just configure it

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.

4. Two-step CI (cheap on PRs, full suite only in the queue) — native pattern

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.

5. Conditional gating by label / path / author — CI recipe (mind the path-filter trap)

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
          fi

This 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.

6. Flaky-test auto-retry — CI recipe

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:e2e

Best practice: retry only the known-transient class (network, browser boot), not the whole suite, so you don't teach CI to ignore real failures.

7. Merge / deploy freeze windows — CI recipe (replaces MergeFreeze/NoShip)

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
              :
            done

Make 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).

8. Backports to release branches — CI recipe

# .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 PR

9. Priority / jump-the-queue — partially native

The 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 } } }"

(bonus) Merge-commit message

Native repo setting controls the squash commit message (PR title + body, or default). Rich templating beyond that isn't native.


10. Footguns that break naive setups

  • A. GITHUB_TOKEN doesn't trigger downstream on: push workflows (recursion guard). Dodged here two ways: (1) we only arm; the queue merges as GitHub, so push fires; (2) deploy via a GitHub App webhook (e.g. Vercel), never GITHUB_TOKEN-gated. If you deploy via a push-triggered Action and merge from a workflow, merge with a GitHub App token / PAT, never GITHUB_TOKEN.
  • B. Required checks must include merge_group or the queue stalls.
  • C. Queue rejects --delete-branch and 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 workflow scope. The workflow's own GITHUB_TOKEN arms fine; a human from the terminal needs gh 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 on ci only, or approve from a separate App/bot identity.
  • F. Under a queue, pass NO merge-method flag--squash just 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. gh in a checkout-less job needs --repo, and || true will hide it when you forget. Without a checkout, gh pr merge tries to infer the repo from a local clone and dies with fatal: not a git repository. Pair that with || true and 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 --auto against 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 speculative merge_group builds from ever finishing (observed live: queue entries evaporating with no merge_group run completing). The sweep must filter to autoMergeRequest == null && mergeQueueEntry == null via GraphQL (§2c) before arming. Also filter isCrossRepository — 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 baseRefOid is 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). Query ref(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.

11. Honest verdict — what native + CI covers, and what it doesn't

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.


Sources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment