Skip to content

Repository files navigation

merge-queue-action

CI codecov lint: biome License: MIT

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.

Quick start

1. Create the workflow files

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

2. Create a token

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

3. Configure branch protection

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.

4. Create the queue labels (one-time)

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/repo

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

5. Use it

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.

How it works

  1. PRs labelled queue are collected oldest-first.
  2. Up to batch_size PRs are merged (server-side) into a temporary merge-queue/batch-* branch.
  3. CI is triggered on the batch branch (the merged result of all PRs combined with main) via workflow_dispatch.
  4. CI passesmain is fast-forwarded, batch branch deleted.
  5. CI fails, batch = 1 — the PR is labelled queue:failed with a comment.
  6. CI fails, batch > 1 — the action dispatches itself to bisect the batch, recursively splitting until the failing PR is isolated.

Label state machine

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

Bounded retries (no infinite loops)

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-N label. 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_requeues times (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 to queue:failed, and posts a "retry limit reached" comment instead of re-adding the queue label.
  • 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.

How merging works in detail

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.

  1. Batch branch creation — A new branch merge-queue/batch-<ID> is created from the current tip of main using the Create a reference API (POST /repos/{owner}/{repo}/git/refs), then fetched and checked out locally.

  2. Local merges — Each PR's head SHA is fetched and merged into the batch branch using a two-step process: git merge --no-commit followed by git commit. This allows both merge drivers (which run during git merge) and pre-merge-commit hooks (which are invoked manually before git commit) to resolve conflicts automatically.

    Conflict resolution: When git merge reports conflicts (exit code 1), the action does NOT abort immediately. Instead, it invokes the pre-merge-commit hook manually, then checks for unresolved conflicts using git ls-files -u. Only if conflicts remain after the hook runs does the action abort that merge, label the PR queue: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 origin before CI is triggered.

  3. 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 of main, CI runs against the exact combined commit that will become main — not against each PR in isolation. This guarantees that the commit landing on main has passed CI. The action polls GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs every 10 seconds (up to 1 hour) until the run completes, where {workflow_id} is the workflow file path provided by the ci_workflow input.

  4. Fast-forward to main — When CI passes, the action updates main to point to the batch branch's HEAD SHA using the Update a reference API (PATCH /repos/{owner}/{repo}/git/refs/heads/main) with force=false. This is a fast-forward only operation — it will fail if main has moved ahead of the batch branch's base (e.g. another push landed while CI was running).

  5. Cleanup — The batch branch is deleted and queue:active labels 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.

Bisection on failure

When a batch of two or more PRs fails CI, the action dispatches a new run of itself in bisect mode via workflow_dispatch:

  1. The batch is split in half: left = ceil(N/2), right = remainder.
  2. A new batch branch is created with only the left-half PRs.
  3. CI runs on the left half.
  4. Left passes — merge it to main, then dispatch bisection for the right half.
  5. Left fails, single PR — that PR is the culprit; mark it queue:failed and requeue the right half.
  6. 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.

Repository setup

Workflow requirements

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: 0
  • fetch-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.

Required repository / ruleset configuration

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:

Option A: Repository rulesets (recommended)

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:

  1. Go to Settings → Rules → Rulesets → New ruleset → New branch ruleset.
  2. Set Target branches to main.
  3. 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.
  4. Enable whichever rules you want for regular development (require PR, require status checks, etc.).
  5. Save.

The bypass ensures the action's UpdateRef call (fast-forward) is not blocked by rules that would otherwise reject a direct push.

Option B: Branch protection rules (classic)

If you use legacy branch protection instead of rulesets:

  1. Go to Settings → Branches → Branch protection rules and edit the rule for main.
  2. You can enable "Require a pull request before merging" and "Require status checks to pass before merging" for normal development.
  3. 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.
  4. 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").

What about "Require linear history"?

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.

Summary of ruleset/protection settings

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)

Token requirements

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 repo scope.
  • 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.

Conflicted PRs and label triggers

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.

Recommended fallback: workflow_dispatch with batch_prs

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:

  1. Go to Actions → Merge Queue → Run workflow.
  2. In the batch_prs field enter the PR number, e.g. 187 or [187].
  3. 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 main at that point, it is labelled queue:failed with a comment explaining the conflict. Resolve the conflict on the PR branch, re-run the dispatch, and the action will try again.

Alternative: schedule trigger

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

This provides automatic recovery for any label events that were skipped.

Why not switch to pull_request_target?

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.

CI workflow requirements

Your CI workflow (ci_workflow input) must:

  1. Include workflow_dispatch in its on: triggers so the action can run it on batch branches.
  2. Run the same checks you care about (tests, linting, builds, etc.).
  3. 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 test

Concurrency

The merge-queue workflow must use a concurrency group with cancel-in-progress: false:

concurrency:
  group: merge-queue
  cancel-in-progress: false

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

Example: overlapping label events

Consider this timeline:

  1. PR #1 is labelled queue → workflow run A starts.
  2. Run A collects PR #1, moves it to queue:active, creates a batch branch, triggers CI, and blocks while polling for the CI result.
  3. While CI is still running, PR #2 is labelled queue → workflow run B is triggered.
  4. Because of the concurrency group, GitHub Actions holds run B in pending state — it cannot start until run A finishes.
  5. Run A's CI completes. PR #1 is merged to main (or marked failed). Run A exits.
  6. Run B starts. It collects PRs with the queue label — PR #1 no longer has it (it was moved to queue:active then 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.

When does batching actually happen?

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

With a schedule trigger, PRs labelled between runs accumulate and are tested together in a single batch, reducing total CI runs.

What if cancel-in-progress is true? Run A would be cancelled when run B is triggered, leaving PR #1 stuck in queue:active with no run to finish it. Always use cancel-in-progress: false.

Inputs

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

Custom merge drivers

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.

How it works

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_TOKEN if 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 .gitattributes as protected paths: require code-owner review, or gate them behind a ruleset the same way you gate .github/workflows/.

Repository-side setup

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.

  1. 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 %A and exit 0 on success, non-zero on unresolvable conflict. See gitattributes(5) for the full contract.

  2. Commit .gitattributes mapping paths to the driver name:

    package-lock.json merge=lockfile
    pnpm-lock.yaml    merge=lockfile
    CHANGELOG.md      merge=union

    (union is a built-in driver; lockfile above is the custom one.)

  3. Register the driver at runtime. merge.<name>.driver lives 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
    %O path to the common-ancestor version
    %A path to the current/ours version (driver writes result here)
    %B path to the other/theirs version
    %L conflict-marker size
    %P pathname of the file being merged
  4. Install every binary the driver invokes as an earlier step in the same job, before the merge-queue action runs. When git merge execs the driver, PATH resolves 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. a package-lock.json driver typically runs npm install to regenerate the lockfile.
    • Domain-specific CLIs the driver depends on (jq, yq, git-lfs, custom binaries built from source, …).

    ubuntu-latest ships 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_TOKEN in scope and pushes to main, so any action that runs before merge-queue-action is part of its trust boundary. Tags like @v4 are 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 for actions/checkout and jeduden/merge-queue-action. Apt/curl/script-based installs should pin too: pin apt packages to a version, verify downloaded binaries against a checksum, and avoid curl … | bash from unpinned URLs. Dependabot's package-ecosystem: "github-actions" keeps the SHAs current without giving up the pin.

    If a binary is missing, git merge reports the driver as failed and the PR is labelled queue: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.

  5. Do not rely on ~/.gitconfig or user-scoped config — Actions runners are ephemeral and the config must be set on every run.

Wiring it into the workflow

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 binary

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

Using pre-merge-commit hooks with merge drivers

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.

How conflict resolution works

The action supports a two-stage conflict resolution pipeline:

  1. Merge drivers run during git merge and can resolve per-file conflicts based on the file's .gitattributes configuration.
  2. Pre-merge-commit hooks are invoked manually by the action after git merge --no-commit (before git 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 0

What 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 -u lists 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.

Example: mdsmith merge-driver + pre-merge-commit hook

The mdsmith Markdown linter uses this pattern to maintain a generated catalog in PLAN.md:

  1. Merge driver resolves per-file conflicts in plan/*.md files using a custom 3-way merge strategy.
  2. pre-merge-commit hook runs after all plan/*.md files are merged, regenerates the catalog section in PLAN.md based on the final merged state, and stages the update. If the hook also resolves any conflicts (e.g., in generated files like internal/rules/index.md), it must stage those resolved files with git add to clear conflict markers from the git index.

Setup:

  1. 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"
  2. 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.driver in .git/config and creates a pre-merge-commit hook at .git/hooks/pre-merge-commit that runs mdsmith fix on generated files after each merge. The hook must stage any files it modifies or resolves using git add to ensure changes are included in the merge commit.

  3. Commit .gitattributes mapping 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_TEMP or other ephemeral locations — use $HOME/.local/bin or 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 when git commit invokes 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 install command 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.

Development

Prerequisites

  • Node.js 24+

Build

npm run build

Test

npm test

Type check

npm run typecheck

Project structure

src/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

When to upgrade to a full merge-queue server

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

License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages