Bors-style merge queue for GitHub, implemented as a GitHub Action. Label a PR to
enqueue it, and the action batches PRs together, runs CI on the batch, and
fast-forwards main. When a batch fails, binary bisection isolates the
culprit in ceil(log2(N)) + 1 CI runs.
No external server. No GitHub native merge queue. Runs as a single Node.js-based GitHub Action — no compiled binary required.
.github/workflows/merge-queue.yml — processes the queue:
# .github/workflows/merge-queue.yml
name: Merge Queue
on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
batch_prs:
type: string
required: false
bisect:
type: boolean
default: false
concurrency:
group: merge-queue
cancel-in-progress: false
jobs:
queue:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
# Optional: register any custom merge drivers here, e.g.
# git config merge.lockfile.driver ".merge-drivers/lockfile.sh %O %A %B %L %P"
- uses: jeduden/merge-queue-action@5adb5a76e27e96f1da5efd36f097a2c5233e9ad3 # v0.6.0
with:
token: ${{ secrets.MERGE_QUEUE_TOKEN }}
ci_workflow: .github/workflows/ci.yml
batch_size: "5"
bisect: ${{ github.event.inputs.bisect }}
batch_prs: ${{ github.event.inputs.batch_prs }}The action configures user.email, user.name, and rewrites origin
to embed the merge-queue token before any merge runs, so actions/checkout
is the only setup step you need. You do not need to pass the merge-queue
token (the action's token input) to actions/checkout, because the
action rewrites the remote URL itself.
.github/workflows/ci.yml — your existing CI; just add workflow_dispatch:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
workflow_dispatch: # Required — merge-queue-action triggers CI on batch branches
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false # CI only reads; no push needed
- run: npm testThe default GITHUB_TOKEN cannot trigger workflow_dispatch on other
workflows. Create a fine-grained PAT (or GitHub App token) with
contents:write, pull-requests:write, actions:write,
issues:write and store it as a repository secret named
MERGE_QUEUE_TOKEN.
The action fast-forwards main via the Git Refs API, so the merge-queue
token's actor must be allowed to push. See
Required repository / ruleset configuration
for detailed setup.
The action's state labels derive from the queue_label input (default
queue): <base>, <base>:active, and <base>:failed. Create those
three in your repository from the GitHub UI under Issues → Labels or
with the GitHub CLI:
# Using the default queue_label ("queue"):
gh label create queue --repo owner/repo
gh label create queue:active --repo owner/repo
gh label create queue:failed --repo owner/repoThe retry counter labels (<base>:attempt-N — see
Bounded retries) are created
automatically as needed; don't pre-create them, and don't prune one from
a PR that is still queued — removing it resets that PR's retry budget.
Add your queue label (default queue) to a PR. The merge-queue workflow
triggers, batches it with any other queued PRs, runs CI, and merges on
success.
If the PR already has merge conflicts with the base branch, GitHub may not
start workflows triggered by pull_request: labeled. In that case, use
workflow_dispatch with the PR number in batch_prs — see
Conflicted PRs and label triggers.
- PRs labelled
queueare collected oldest-first. - Up to
batch_sizePRs are merged (server-side) into a temporarymerge-queue/batch-*branch. - CI is triggered on the batch branch (the merged result of all PRs
combined with
main) viaworkflow_dispatch. - CI passes —
mainis fast-forwarded, batch branch deleted. - CI fails, batch = 1 — the PR is labelled
queue:failedwith a comment. - CI fails, batch > 1 — the action dispatches itself to bisect the batch, recursively splitting until the failing PR is isolated.
| Label | Meaning |
|---|---|
queue |
PR is waiting to be processed |
queue:active |
PR is currently in a batch |
queue:failed |
PR failed CI, had a merge conflict, hit the requeue cap, or was stopped by a configuration error |
queue:attempt-N |
Internal: how many times this PR has been requeued |
Transient problems — an API blip, a main that advanced mid-run, a CI
run that couldn't be located — cause the queue to requeue the PR: it
re-adds the queue label, and the next run retries. To guarantee a
permanent failure can never requeue forever (a token missing a
permission, branch protection rejecting the bot, a check that always
fails), every requeue is bounded by a per-PR attempt cap:
- Each requeue stamps the PR with a
queue:attempt-Nlabel. These labels are created on the fly (GitHub assigns each a random color) — they don't need pre-creating, and deleting one from a PR that is still queued resets that PR's counter. - Once a PR has been requeued
max_requeuestimes (default 10; must be a positive integer — invalid values fall back to the default with a run-log warning) without succeeding, the queue stops retrying it, moves it toqueue:failed, and posts a "retry limit reached" comment instead of re-adding thequeuelabel. - The counter resets whenever the PR makes real progress: it merges, any batch member's head changes while batch CI runs (the push invalidates the shared batch run, and none of those PRs failed — so the whole batch's counters reset), or it is marked failed and later re-added by you. A genuinely transient failure that later succeeds is never penalised.
This backstop bounds every failure path, including ones the action does
not yet classify as permanent. Tune it with the max_requeues input.
The queue also self-heals from interrupted runs: because the mandated
concurrency group serializes runs, any PR still wearing queue:active
when a fresh run starts must be left over from a crashed or cancelled
run, and the new run requeues it automatically (cap-bounded) instead of
leaving it stranded.
The action runs in a job that has already checked out the repository
(see Quick start for the required steps). Branch
creation, fast-forward and deletion go through the GitHub Git Data
API; the per-PR merge step runs git merge in the runner's working
tree so committed .gitattributes and merge.<name>.driver config
take effect. See Custom merge drivers for the
driver setup.
-
Batch branch creation — A new branch
merge-queue/batch-<ID>is created from the current tip ofmainusing the Create a reference API (POST /repos/{owner}/{repo}/git/refs), then fetched and checked out locally. -
Local merges — Each PR's head SHA is fetched and merged into the batch branch using a two-step process:
git merge --no-commitfollowed bygit commit. This allows both merge drivers (which run duringgit merge) and pre-merge-commit hooks (which are invoked manually beforegit commit) to resolve conflicts automatically.Conflict resolution: When
git mergereports conflicts (exit code 1), the action does NOT abort immediately. Instead, it invokes the pre-merge-commit hook manually, then checks for unresolved conflicts usinggit ls-files -u. Only if conflicts remain after the hook runs does the action abort that merge, label the PRqueue:failed, and continues with remaining PRs. See Using pre-merge-commit hooks with merge drivers for details on the two-stage resolution pipeline.The resulting batch branch is pushed to
originbefore CI is triggered. -
CI verification — The CI workflow is triggered on the batch branch via
workflow_dispatch. Because the batch branch contains the result of merging every queued PR on top ofmain, CI runs against the exact combined commit that will becomemain— not against each PR in isolation. This guarantees that the commit landing onmainhas passed CI. The action pollsGET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runsevery 10 seconds (up to 1 hour) until the run completes, where{workflow_id}is the workflow file path provided by theci_workflowinput. -
Fast-forward to main — When CI passes, the action updates
mainto point to the batch branch's HEAD SHA using the Update a reference API (PATCH /repos/{owner}/{repo}/git/refs/heads/main) withforce=false. This is a fast-forward only operation — it will fail ifmainhas moved ahead of the batch branch's base (e.g. another push landed while CI was running). -
Cleanup — The batch branch is deleted and
queue:activelabels are removed from the merged PRs.
Because main is updated via the Refs API (a direct SHA update), this
bypasses the Pull Requests merge API entirely. PRs are not "merged"
through GitHub's normal merge button — their commits land on main via
the fast-forward, and GitHub automatically closes the PRs once their
commits appear on the target branch.
When a batch of two or more PRs fails CI, the action dispatches a new run
of itself in bisect mode via workflow_dispatch:
- The batch is split in half:
left = ceil(N/2),right = remainder. - A new batch branch is created with only the left-half PRs.
- CI runs on the left half.
- Left passes — merge it to
main, then dispatch bisection for the right half. - Left fails, single PR — that PR is the culprit; mark it
queue:failedand requeue the right half. - Left fails, multiple PRs — dispatch another bisection to split the left half further.
Worst case: after the initial failed full-batch CI run, bisection needs
ceil(log₂(N)) + 1 additional CI runs to isolate a single failing PR.
The merge-queue workflow must include an actions/checkout step
before the merge-queue-action step, with fetch-depth: 0:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0fetch-depth: 0— the action refuses early on a shallow clone; local merges of PR head SHAs need full history.
That's the entire workflow-side setup. The action itself runs
git config user.email/.name and git remote set-url origin https://x-access-token:<token>@… against the checked-out worktree,
so you don't need to pass token: to actions/checkout or add a
separate "Configure git" step. The token used is whatever you pass
as the token input (typically secrets.MERGE_QUEUE_TOKEN).
A post-step (declared in action.yml, runs even if the main step
failed) resets origin back to the token-less URL, so steps placed
after this action in the same job cannot read the merge-queue token
out of .git/config. Steps placed before the action remain inside
the trust boundary described under
Token requirements and the security notes in
Custom merge drivers.
Override the identity via the git_user_email / git_user_name
inputs if you need a different author on the merge commits.
The action updates main by directly moving the branch ref via the Git
Refs API. This means your branch protection rules must allow the
merge-queue token to push to main. There are two ways to set this up:
GitHub rulesets provide fine-grained control. Create a ruleset for main
that enforces your desired checks for normal development, then bypass
the ruleset for the merge-queue actor:
- Go to Settings → Rules → Rulesets → New ruleset → New branch ruleset.
- Set Target branches to
main. - Under Bypass list, add the actor whose token the action uses:
- If using a GitHub App: add the app (e.g. "My Merge Queue App").
- If using a PAT (classic or fine-grained): add the user who owns the PAT, or add the user to a team and add that team.
- Enable whichever rules you want for regular development (require PR, require status checks, etc.).
- Save.
The bypass ensures the action's UpdateRef call (fast-forward) is not
blocked by rules that would otherwise reject a direct push.
If you use legacy branch protection instead of rulesets:
- Go to Settings → Branches → Branch protection rules and edit the
rule for
main. - You can enable "Require a pull request before merging" and "Require status checks to pass before merging" for normal development.
- Under "Restrict who can push to matching branches", add the user or app whose token the action uses — or leave this unchecked to allow all collaborators with write access to push.
- If you have "Require a pull request before merging" enabled, the merge-queue token's actor must be excluded from this restriction. The simplest way is to add the actor to the "Allow specified actors to bypass required pull requests" list (available under the same protection rule).
Important: If branch protection requires pull requests before merging and the merge-queue token's actor is not bypassed, the fast-forward will be rejected with a 422 error ("Changes must be made through a pull request").
When batching multiple independent PRs, the batch branch will typically
contain merge commits. When main is fast-forwarded to the batch branch,
those merge commits land on main. If you enable "Require linear
history" in your ruleset or branch protection, the fast-forward will be
rejected because merge commits are present.
Do not enable "Require linear history" unless you modify the action to rebase/squash instead of merge. The action's merge strategy produces a non-linear history by design — each PR's merge commit preserves the original branch context.
| Setting | Compatible? | Notes |
|---|---|---|
| Require a pull request before merging | Yes | Merge-queue actor must be in the bypass list so the direct ref update is allowed |
| Require status checks to pass | Yes | Ensure required checks run on the batch commit SHA; bypass only if you intentionally trust the action alone |
| Require linear history | No | Batch branches contain merge commits |
| Require signed commits | Depends | The API-created merge commits are unsigned; bypass the actor or disable |
| Restrict who can push | Yes | Merge-queue actor must be allowed |
| Require deployments to succeed | Yes | Ensure required deployments are reported on the batch commit SHA; bypass only if intentional |
| Block force pushes | Yes | The action uses force=false (fast-forward only) |
The default GITHUB_TOKEN cannot trigger workflow_dispatch events
on other workflows (GitHub prevents recursive triggering). You must use
one of:
- Fine-grained PAT with repository permissions:
contents:write,pull-requests:write,actions:write,issues:write. - Classic PAT with
reposcope. - GitHub App installation token with the same permissions.
Store the token as a repository secret (e.g. MERGE_QUEUE_TOKEN).
Important
If queued PRs can add or change files under .github/workflows/, the
token also needs the workflow scope (classic PAT) or
workflows: write permission (fine-grained PAT / GitHub App).
GitHub rejects any push that touches a workflow file when the token
lacks this, so the action would otherwise be unable to push the batch
branch. When that happens the affected PR is marked queue:failed
with an explanatory comment rather than being retried indefinitely.
The quick-start merge-queue workflow uses:
on:
pull_request:
types: [labeled]
workflow_dispatch:This is the safest default because the workflow uses a write-capable merge
queue token. However, GitHub does not run workflows triggered by
pull_request activity when the pull request has merge conflicts with the
base branch. That means adding the queue label to a conflicted PR may not
start the merge-queue workflow at all.
When a PR is conflicted (or for any reason the pull_request: labeled event
did not start a run), you can manually dispatch the workflow from the Actions
tab and provide the PR number in the batch_prs input:
- Go to Actions → Merge Queue → Run workflow.
- In the batch_prs field enter the PR number, e.g.
187or[187]. - Click Run workflow.
The action will fetch that PR directly by number and process it without requiring the PR to have the queue label. This is the recommended operator fallback for conflicted PRs.
You can also provide multiple PRs at once: [181,187].
What happens to a conflicted PR? The action will attempt to merge it into a batch branch. If the PR still conflicts with
mainat that point, it is labelledqueue:failedwith a comment explaining the conflict. Resolve the conflict on the PR branch, re-run the dispatch, and the action will try again.
Add a schedule trigger so queued PRs are processed periodically even when
the original label event did not start a run:
on:
pull_request:
types: [labeled]
schedule:
- cron: "*/15 * * * *" # every 15 minutes
workflow_dispatch:
inputs:
batch_prs:
type: string
required: false
bisect:
type: boolean
default: falseThis provides automatic recovery for any label events that were skipped.
pull_request_target runs in the context of the base repository and can
access secrets — including MERGE_QUEUE_TOKEN. If the workflow checks out
or executes untrusted PR code before using that token, a malicious PR could
alter the merge or exfiltrate secrets. Stick with pull_request: labeled
and use workflow_dispatch or schedule as the safe fallback.
Your CI workflow (ci_workflow input) must:
- Include
workflow_dispatchin itson:triggers so the action can run it on batch branches. - Run the same checks you care about (tests, linting, builds, etc.).
- Complete within 1 hour (the action's polling timeout).
Example minimal CI workflow:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
workflow_dispatch: # Required for merge-queue-action
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- run: npm testThe merge-queue workflow must use a concurrency group with
cancel-in-progress: false:
concurrency:
group: merge-queue
cancel-in-progress: falseThis ensures that when multiple PRs are labelled in quick succession, the runs queue up rather than cancelling each other. Each run processes whatever is in the queue at that moment.
Consider this timeline:
- PR #1 is labelled
queue→ workflow run A starts. - Run A collects PR #1, moves it to
queue:active, creates a batch branch, triggers CI, and blocks while polling for the CI result. - While CI is still running, PR #2 is labelled
queue→ workflow run B is triggered. - Because of the concurrency group, GitHub Actions holds run B in pending state — it cannot start until run A finishes.
- Run A's CI completes. PR #1 is merged to
main(or marked failed). Run A exits. - Run B starts. It collects PRs with the
queuelabel — PR #1 no longer has it (it was moved toqueue:activethen removed), so only PR #2 is collected. Run B processes PR #2 normally.
If PR #2 had been labelled before run A collected PRs (step 2), both
PRs would have been batched together in run A and tested as a single
combined commit. The concurrency group serialises workflow runs, not
individual PRs — the batch size is determined by how many PRs carry the
queue label at the moment a run starts collecting.
With only the pull_request: labeled trigger, each label event fires
its own workflow run. Because the concurrency group serialises runs,
in practice each run usually processes just one PR. Batching only
occurs when multiple PRs accumulate the queue label while an earlier
run is still in progress — the next pending run will pick them all up.
To get more consistent batching, add a schedule trigger so the
workflow runs periodically and scoops up all queued PRs at once:
on:
pull_request:
types: [labeled]
schedule:
- cron: "*/5 * * * *" # every 5 minutes
workflow_dispatch:
inputs:
batch_prs:
type: string
required: false
bisect:
type: boolean
default: falseWith a schedule trigger, PRs labelled between runs accumulate and are tested together in a single batch, reducing total CI runs.
What if
cancel-in-progressistrue? Run A would be cancelled when run B is triggered, leaving PR #1 stuck inqueue:activewith no run to finish it. Always usecancel-in-progress: false.
| Input | Required | Default | Description |
|---|---|---|---|
token |
yes | — | PAT or GitHub App token with contents:write, pull-requests:write, actions:write, issues:write (the default GITHUB_TOKEN cannot dispatch workflows) |
ci_workflow |
yes | — | Workflow file supporting workflow_dispatch (e.g. .github/workflows/ci.yml) |
batch_size |
no | 5 |
Max PRs per batch |
queue_label |
no | queue |
Label that enqueues a PR |
ci_wait_minutes |
no | 60 |
Minutes to wait for the batch CI run to complete before requeueing. Size above your slowest CI run — a too-small value times out every batch and burns the requeue cap without CI ever failing. |
max_requeues |
no | 10 |
Max times a single PR is requeued before the queue gives up and marks it queue:failed. Bounds every retry path so a permanent failure can't re-trigger the workflow forever. Must be a positive integer; invalid values fall back to the default with a run-log warning. See Bounded retries. |
dry_run |
no | false |
Log intent without mutating |
batch_prs |
no | "" |
PR numbers to process explicitly. Accepts a JSON array ([187] or [181,187]) or a single integer string (187). When provided via workflow_dispatch in normal mode, those PRs are fetched directly without requiring the queue label — the recommended fallback for conflicted PRs. In bisect mode the action sets this automatically. |
git_user_email |
no | merge-queue@users.noreply.github.com |
user.email set on the local repo before merging |
git_user_name |
no | merge-queue-bot |
user.name set on the local repo before merging |
Git supports custom merge drivers
for resolving conflicts on specific file types — e.g. auto-merging
package-lock.json, Cargo.lock, CHANGELOG.md, or generated files
that would otherwise conflict on every parallel PR.
Per-PR merges run via git merge in the runner's checked-out working
tree, so git consults your committed .gitattributes and dispatches
to any registered merge.<name>.driver. Batch branch creation,
fast-forward and deletion still go through the Git Data API, so
rulesets and fast-forward-only semantics are unchanged. If a merge
still conflicts after the driver runs, the PR is reported as
conflicted and labelled queue:failed.
Security note: custom merge drivers execute arbitrary code in the runner on every batched merge, with access to the runner's environment — including
MERGE_QUEUE_TOKENif the workflow exposes it. Anyone who can land a commit on a branch that feeds the queue can change what the driver does, so treat.merge-drivers/**and.gitattributesas protected paths: require code-owner review, or gate them behind a ruleset the same way you gate.github/workflows/.
The driver script must be committed to the repository so it is on
disk after actions/checkout — if it isn't in the working tree at
merge time, git merge has nothing to exec.
-
Commit the driver into the repo, e.g.
.merge-drivers/lockfile-merge.sh. Make it executable and record the bit in git:chmod +x .merge-drivers/lockfile-merge.sh git add .merge-drivers/lockfile-merge.sh git update-index --chmod=+x .merge-drivers/lockfile-merge.sh
The driver must write the resolved content to
%Aand exit0on success, non-zero on unresolvable conflict. See gitattributes(5) for the full contract. -
Commit
.gitattributesmapping paths to the driver name:package-lock.json merge=lockfile pnpm-lock.yaml merge=lockfile CHANGELOG.md merge=union
(
unionis a built-in driver;lockfileabove is the custom one.) -
Register the driver at runtime.
merge.<name>.driverlives in.git/config, which is not tracked. The merge-queue workflow must set it after checkout and before the merge-queue action runs:- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Register custom merge drivers run: | git config merge.lockfile.name "Auto-merge lockfiles" git config merge.lockfile.driver ".merge-drivers/lockfile-merge.sh %O %A %B %L %P" git config merge.lockfile.recursive binary
The
%placeholders are defined by git:Placeholder Meaning %Opath to the common-ancestor version %Apath to the current/ours version (driver writes result here) %Bpath to the other/theirs version %Lconflict-marker size %Ppathname of the file being merged -
Install every binary the driver invokes as an earlier step in the same job, before the merge-queue action runs. When
git mergeexecs the driver,PATHresolves to the runner image's preinstalled baseline plus whatever your workflow installs — nothing else. This includes:- Language runtimes the driver script is written in (
node,python,ruby, …) — the shebang only works if the interpreter is installed. - Package managers the driver shells out to (
npm,pnpm,yarn,cargo,pip,bundle, …) — e.g. apackage-lock.jsondriver typically runsnpm installto regenerate the lockfile. - Domain-specific CLIs the driver depends on (
jq,yq,git-lfs, custom binaries built from source, …).
ubuntu-latestships with a baseline (bash,git,python3,jq, etc.) but does not pin versions and does not include project-specific tooling. If the driver needs a specific version, install it explicitly — don't rely on the runner image. Example for a Node-based lockfile driver:- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "20" - run: npm ci # so `npm install` inside the driver is fast / offline-ish - name: Register merge drivers run: | git config merge.lockfile.driver ".merge-drivers/lockfile-merge.sh %O %A %B %L %P" - uses: jeduden/merge-queue-action@5adb5a76e27e96f1da5efd36f097a2c5233e9ad3 # v0.6.0 with: token: ${{ secrets.MERGE_QUEUE_TOKEN }} ci_workflow: .github/workflows/ci.yml
Pin every install step to a commit SHA, not a tag. The merge-queue workflow runs with
MERGE_QUEUE_TOKENin scope and pushes tomain, so any action that runs beforemerge-queue-actionis part of its trust boundary. Tags like@v4are mutable — whoever owns the upstream repo can repoint them at malicious code, which would then exfiltrate the token or alter merges. Use the same<owner>/<action>@<40-char-SHA> # <human version>form this README uses foractions/checkoutandjeduden/merge-queue-action. Apt/curl/script-based installs should pin too: pin apt packages to a version, verify downloaded binaries against a checksum, and avoidcurl … | bashfrom unpinned URLs. Dependabot'spackage-ecosystem: "github-actions"keeps the SHAs current without giving up the pin.If a binary is missing,
git mergereports the driver as failed and the PR is labelledqueue:failed— the failure surfaces as a merge conflict, not as a clear "command not found", so missing tooling is easy to misdiagnose. Run the driver locally first to enumerate exactly what it depends on. - Language runtimes the driver script is written in (
-
Do not rely on
~/.gitconfigor user-scoped config — Actions runners are ephemeral and the config must be set on every run.
Extend the Quick start workflow with two steps between
actions/checkout and merge-queue-action: one to install whatever
binaries the driver needs, and one to register the driver:
# Install any binaries the driver script invokes (interpreters,
# package managers, CLIs). Skip whatever your driver doesn't use.
# Pin every action to a 40-char commit SHA — tags like @v4 are
# mutable and these steps run with MERGE_QUEUE_TOKEN in scope.
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Register merge drivers
run: |
git config merge.lockfile.name "Auto-merge lockfiles"
git config merge.lockfile.driver ".merge-drivers/lockfile-merge.sh %O %A %B %L %P"
git config merge.lockfile.recursive binaryThe action picks the driver up from .git/config the moment it runs
git merge, and execs it in the runner's PATH — so any binary the
driver shells out to must already be installed by an earlier step.
Identity (user.email/user.name) is set by the action itself, so
you don't need to add it to this step.
Every uses: step in the merge-queue workflow runs in the same job
as merge-queue-action, with access to MERGE_QUEUE_TOKEN and the
ability to alter the working tree before the merge. Pin every one
of them to a full commit SHA, not a floating tag — see the note
under Repository-side setup above.
Git's pre-merge-commit hook runs after all files are merged but before
the merge commit is created, making it ideal for fixing up generated
content that depends on the final merged state of multiple files. This
is particularly useful when a merge driver handles per-file conflicts,
but you need a post-processing step to update derived content (catalogs,
indexes, lock files, etc.) based on the complete merge result.
The action's merge process uses git merge --no-commit followed by
git commit, and manually invokes the pre-merge-commit hook between
these steps. This is necessary because git only invokes the hook when
git merge itself creates a commit, not when using --no-commit. By
manually invoking the hook, the action ensures hooks like mdsmith's
catalog regeneration work correctly during batch merges.
The action supports a two-stage conflict resolution pipeline:
- Merge drivers run during
git mergeand can resolve per-file conflicts based on the file's.gitattributesconfiguration. - Pre-merge-commit hooks are invoked manually by the action after
git merge --no-commit(beforegit commit) and can resolve conflicts that remain after merge drivers have executed, or regenerate content that depends on the final merged state.
Important: The action does NOT abort immediately when git merge
reports conflicts (exit code 1). Instead, it invokes the pre-merge-commit
hook manually, then checks git ls-files -u for unresolved conflicts.
Only if conflicts remain after the hook has executed does the action abort
and label the PR queue:failed.
Conflict resolution flow:
git merge --no-commit
↓
(merge drivers run, may resolve some conflicts)
↓
invoke pre-merge-commit hook (if present)
↓
(hook may resolve remaining conflicts)
↓
Check git ls-files -u
↓
├─ No unresolved files → git commit → merge succeeds
└─ Conflicts remain → abort → PR labeled queue:failed
Critical requirement for conflict-resolving hooks:
If your pre-merge-commit hook resolves remaining conflicts or otherwise
clears unmerged index entries, it MUST stage the resolved paths using
git add so the git index no longer contains unresolved merge entries.
Simply fixing file content in the working tree is not sufficient—git tracks
conflict resolution in the index (staging area).
When a file has a merge conflict, git creates one or more unmerged index
entries for that file (up to stages 1, 2, and 3) representing the common
ancestor, current branch, and incoming branch versions. Running git add
on a resolved file replaces those unmerged entries with a single stage 0
entry, marking the conflict as resolved.
Example hook that resolves conflicts:
#!/bin/bash
# Fix conflicted files
your-tool fix internal/rules/index.md
# REQUIRED: Stage the resolved file to clear conflict markers
git add internal/rules/index.md
exit 0What happens if you don't stage:
- The file content is fixed in the working tree
- Git index still shows stages 1, 2, 3 (unresolved conflict)
git ls-files -ulists the file as conflicted- The action aborts and labels the PR
queue:failed
This enables workflows where merge drivers handle file-level conflicts and hooks perform final cleanup or regeneration that depends on the complete merge result.
The mdsmith Markdown linter uses
this pattern to maintain a generated catalog in PLAN.md:
- Merge driver resolves per-file conflicts in
plan/*.mdfiles using a custom 3-way merge strategy. - pre-merge-commit hook runs after all
plan/*.mdfiles are merged, regenerates the catalog section inPLAN.mdbased on the final merged state, and stages the update. If the hook also resolves any conflicts (e.g., in generated files likeinternal/rules/index.md), it must stage those resolved files withgit addto clear conflict markers from the git index.
Setup:
-
Install the tool in your merge-queue workflow before the action runs:
- name: Install mdsmith run: | mkdir -p "$HOME/.local/bin" curl -fsSL "https://github.com/jeduden/mdsmith/releases/download/v0.7.1/mdsmith-linux-amd64" \ -o "$HOME/.local/bin/mdsmith" chmod +x "$HOME/.local/bin/mdsmith" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
-
Register the merge driver and install the hook (the tool handles hook creation):
- name: Configure mdsmith merge driver run: | mdsmith merge-driver install
This registers
merge.mdsmith.driverin.git/configand creates apre-merge-commithook at.git/hooks/pre-merge-committhat runsmdsmith fixon generated files after each merge. The hook must stage any files it modifies or resolves usinggit addto ensure changes are included in the merge commit. -
Commit
.gitattributesmapping Markdown files to the driver:*.md merge=mdsmith
When the action merges PRs, the driver resolves conflicts in individual
Markdown files, then the pre-merge-commit hook regenerates any derived
content before the merge commit is finalized.
Binary path stability: The tool binary must be installed to a stable path that persists for the hook to find it. Avoid installing to
$RUNNER_TEMPor other ephemeral locations — use$HOME/.local/binor a committed script in the repository itself. The hook embeds the absolute path to the binary when it's created, so the binary must still exist at that path whengit commitinvokes the hook.
Security note: Like merge drivers, pre-merge-commit hooks execute arbitrary code during the merge with access to
MERGE_QUEUE_TOKEN. Treat.git/hooks/creation (or any*-merge installcommand that writes hooks) as a trusted operation — ensure the binary and any config it writes are from a verified source, and consider pinning the installation step to a specific version/checksum.
- Node.js 24+
npm run buildnpm testnpm run typechecksrc/main.ts Entry point
src/action.ts Action orchestration (process & bisect flows)
src/github.ts GitHub REST API client
src/gitops.ts Git operations: branch refs via REST API, per-PR merges via local `git`
src/queue.ts Label state machine
src/batch.ts Batch branch creation and multi-PR merge
src/bisect.ts Pure split function for binary bisection
Consider migrating to Bors-NG, Mergify, or Kodiak when:
- Queue exceeds ~10 PRs regularly
- CI takes longer than 15 minutes (bisection rounds compound)
- You need priority merges, cross-repo deps, or stacked PRs
- Label race conditions become a recurring problem
MIT