Skip to Content
SimulatorsGitHubMerge queues

Simulators

Merge queues

A merge queue lands pull requests one group at a time, each group tested on top of the ones ahead of it. Seed a queue on the base branch, require a check, queue a pull request and pass its group’s check to watch it land:

H='Authorization: Bearer ghp_anything'
R="$RYSTIC_URL/repos/tb-owner/tb-repo"
curl -s -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_merge_queue":{"tb-owner/tb-repo#tb-base-branch":{"id":"MQ_demo","repository":"tb-owner/tb-repo","branch":"tb-base-branch","url":"https://github.com/tb-owner/tb-repo/queue/tb-base-branch","resourcePath":"/tb-owner/tb-repo/queue/tb-base-branch","configuration":{"mergeMethod":"MERGE","mergingStrategy":"ALLGREEN","maximumEntriesToBuild":5,"maximumEntriesToMerge":5,"minimumEntriesToMerge":1,"minimumEntriesToMergeWaitTime":5,"checkResponseTimeout":60}}}}' -o /dev/null -w '%{http_code}\n'
# -> 204
curl -s -X PUT "$R/branches/tb-base-branch/protection" -H "$H" -d '{"required_status_checks":{"strict":false,"contexts":["unit"]},"enforce_admins":false,"required_pull_request_reviews":null,"restrictions":null}' -o /dev/null -w '%{http_code}\n'
# -> 200
curl -s -X POST "$R/statuses/517a4bbdfb525691b11db3e28f466eb3d7598d19" -H "$H" -d '{"state":"success","context":"unit"}' -o /dev/null -w '%{http_code}\n'
# -> 201
curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d '{"query":"mutation { enqueuePullRequest(input: {pullRequestId: \"tb-pr-41-node-id\"}) { mergeQueueEntry { state position } } }"}' | jq -c '.data.enqueuePullRequest.mergeQueueEntry'
# -> {"position":1,"state":"QUEUED"}
Q='{"query":"{ repository(owner: \"tb-owner\", name: \"tb-repo\") { mergeQueue(branch: \"tb-base-branch\") { entries(first: 5) { nodes { state pullRequest { number } headCommit { oid } } } } } }"}'
curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d "$Q" | jq -c '[.data.repository.mergeQueue.entries.nodes[] | {state, number: .pullRequest.number}]'
# -> [{"state":"AWAITING_CHECKS","number":41}]
curl -s "$R/branches" -H "$H" | jq -r '.[].name | select(startswith("gh-readonly-queue/")) | sub("-[0-9a-f]{40}$"; "-<sha>")'
# -> gh-readonly-queue/tb-base-branch/pr-41-<sha>
group=$(curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d "$Q" | jq -r '.data.repository.mergeQueue.entries.nodes[0].headCommit.oid')
curl -s -X POST "$R/statuses/$group" -H "$H" -d '{"state":"success","context":"unit"}' -o /dev/null -w '%{http_code}\n'
# -> 201
curl -s "$R/pulls/41" -H "$H" | jq -c '{state, merged, merged_by: .merged_by.login}'
# -> {"state":"closed","merged":true,"merged_by":"tb-owner"}
curl -s "$R/issues/41/events" -H "$H" | jq -c '[.[].event]'
# -> ["added_to_merge_queue","removed_from_merge_queue","merged","closed"]

“Unconfirmed” marks behavior not yet checked against GitHub. How a blocked pull request waits to merge outside a queue is on Auto-merge.

Which branches hold a queue

A branch holds a queue in either case:

  • An active ruleset’s merge_queue rule targets it, and an organization owns the repository.
  • The scenario seeds a state_merge_queue row on it, or you seed one with /_rystic/seed.

A ruleset’s queue takes the rule’s parameters as its configuration: merge_method as mergeMethod, grouping_strategy as mergingStrategy and the entry bounds. It reads min_entries_to_merge_wait_minutes as minimumEntriesToMergeWaitTime and check_response_timeout_minutes as checkResponseTimeout, in seconds. The queue’s url is the repository’s followed by /queue/<branch>, and its id stays the same for the repository and branch.

Differs from GitHub: a ruleset’s merge_queue rule holds the branch’s queue, but lets a direct merge or push to the branch through.

A ruleset that pairs a merge_queue rule with a wildcard include is refused 422 with Invalid rule 'merge_queue': Wildcard ref names are not supported when merge queue is enabled. GitHub refuses a * pattern this way. The simulator refuses a ? or [ pattern and ~ALL the same way (unconfirmed).

Joining the queue

isMergeQueueEnabled reads whether a base holds a queue. A pull request joins it in one of two ways:

  • gh pr merge --auto into a queue base enables auto-merge, and the request joins the queue once the pull request’s own protection is met. A pull request already met joins as auto-merge is enabled.
  • enqueuePullRequest adds a pull request whose protection is met.

enqueuePullRequest refuses a pull request it cannot add:

Pull requestRefused with
its protection not yet metPull request before the first unmet requirement, as GitHub words a missing required status: Pull request Required status check "<context>" is expected.
a draftPull request is in draft and Pull request not in mergeable state
already queuedPull request is already in the queue
no queue on its base, in an organization’s repositoryNo merge queue found for branch '<base>'
no queue on its base, in a user’s repositoryMerge queue is not enabled for this branch

The response shows the entry as it joined: QUEUED, with no headCommit or baseCommit, and its pull request open.

Groups and their checks

From the next request on, the queue reads the entry AWAITING_CHECKS, with its merge group built.

Differs from GitHub: the simulator builds the merge group once the request that queued the entry ends. GitHub takes seconds.

An entry whose group conflicts then leaves the queue (dequeued, reason MERGE_CONFLICT).

Differs from GitHub: a group built by rebase whose replay of a commit conflicts lands that commit’s changes as an overwrite of the base. GitHub drops the pull request from the queue because it can’t be rebased.

The group is the pull request merged onto the tip ahead of it: the base tip, or the previous entry’s group. It merges with the queue’s configuration.mergeMethod, authored by the enqueuer and committed by GitHub. A git fetch reads it as the branch gh-readonly-queue/<base>/pr-<number>-<parent sha>.

No push, ref write or contents write lands on that branch, from an admin either. Each refusal gives this reason:

A pull request for this branch has been added to a merge queue. Branches that are queued for merging cannot be updated. To modify this branch, dequeue the associated pull request.

WriteRefusal
A pushGH006, the reason as a - line. When a ruleset also refuses the push, GH013, with this reason after the ruleset’s own.
A .../git/refs update or deletion422, with the reason as its message
A contents write409, with the reason after Could not create file: , Could not update file: or Could not delete file:

Each of them wraps the reason over three lines, and an API message ends with a newline.

A client can still create and delete other branches under gh-readonly-queue/.

Merging a pull request based on a group’s branch still moves the group, and so does update-branch on a pull request headed by one. A later success can then land the stale group without that merged commit. What GitHub returns for these two writes is unconfirmed.

Building a group sends the merge_group checks_requested webhook. The repository’s merge_group workflows start on the group and report on its head. No push workflow runs on a queue branch.

The required contexts of the base’s protection and rulesets, read on the group head, decide:

Required contextsResult
all passingThe entry reads MERGEABLE.
one failingThe entry leaves the queue (pull_request dequeued, reason CI_FAILURE). The groups behind it are rebuilt: merge_group destroyed (reason invalidated), then checks_requested again.

Landing and leaving

Entries land in position order while each is mergeable. When one lands:

  • The base fast-forwards to the group head, which is the pull request’s merge_commit_sha, and merged_by is the enqueuer.
  • The issue’s events gain removed_from_merge_queue by github-merge-queue[bot], and merged and closed by the enqueuer. GitHub logs the removal before or after the merge.
  • The pull_request closed webhook and merge_group destroyed (reason merged) go out.
  • The queue branch disappears.

dequeuePullRequest removes an entry (dequeued, reason MANUAL) and rebuilds the groups behind it.

Differs from GitHub: a pull request not in the queue is refused Pull request is not in the merge queue. GitHub returns Failed to remove PR #<number> "<title>".

An entry whose pull request is pushed to, closed, converted to draft, retargeted or merged by hand leaves the queue too, with reason ALREADY_MERGED once merged and UNKNOWN_REMOVAL_REASON otherwise. It leaves at its pull request’s synchronize or closed notification.

Differs from GitHub: PATCH .../pulls/{pull_number}, closePullRequest and convertPullRequestToDraft send no notification, so an entry they change leaves only at the queue’s next move.

A seeded entry records its pull request’s head as the seed lands, and one a scenario loads at boot before the next request runs. Either leaves on any push to its pull request.

A push or a merge that moves the base from outside the queue rebuilds the base’s groups on the new tip. A removal clears the pull request’s auto-merge request unless it merged. Repository.mergeQueue(branch:) lists the entries in order, and is null where no queue stands.

What the queue is compared with

The queue is checked against GitHub on a public repository an organization owns: enqueuePullRequest, dequeuePullRequest, a waiting entry’s read, its group branch and a landing.

Last updated on