A Lightweight Merge Queue using GitHub Actions

2025-05-08 by Philip Zeyliger and Josh Bleecher Snyder

For a repo with sufficiently fast tests and sufficiently infrequent commits, GitHub Actions is more than enough to implement a simple merge queue, which can also resolve formatting issues.

We want origin/main to always have passing tests and well-formatted code. When a repo has enough contributors, it's desirable to automate running the tests before pushing to main. GitHub itself offers a merge queue for pull requests, but if you're not using PRs, it is easy to put together a lightweight alternative, as we recently did.

To use it, you run:

git push -f origin HEAD:refs/heads/queue-main-$USER

This pushes the current commit to a GitHub branch with a magic name. A GitHub Action notices the change, runs formatting tools, pushing any necessary formatting changes back to the queue, runs the test workflows in parallel, and, finally, if/when all the tests pass, does a non-force push to main.

Diagram showing the merge queue workflow

If you have few enough commits per day, and your tests run fast enough, this works acceptably well, but conflicts can still occur. (It's a bit counterintuitive, like the birthday problem: the probability of having 2 commits in the same minute across 480 minutes is about 9%.) So far, we've judged the occasional conflict-and-rebase as preferable to breaking main!

We also decided to have the queue fix any formatting issues instead of rejecting the push. Ultimately, there's always some editor or tool or script that doesn't take into account prettier or gofumpt, and time and attention are precious. 99% of the time all we'd do with the formatting change is apply a deterministic tool and retry. Just say no to failing pushes because of whitespace!

If you want to replicate our fun, here's a quick walkthrough of the interesting bits of our hook.

Main Queue

The main queue is .github/workflows/queue-main.yml.

We want this Action to trigger on a change to any branch named queue-main-$USER:

name: Main Branch Commit Queue
on:
  push:
    branches:
      - "queue-main-*"

The queue action itself doesn't need to do anything but read the repo, so we restrict permissions. We'll end up granting more permissions to sub-actions soon, though.

permissions: read-all

The first job is running the formatter. We block on that up front so that the code that gets tested is the same code that gets pushed. We need to give the formatting workflow write permissions, and tell it that it is in "fix" rather than "check" mode. We’ll step through the formatting workflow in the next section.

jobs:
  formatting:
    uses: ./.github/workflows/formatting.yml
    permissions:
      contents: write
    with:
      auto_fix: true

Now we run all of our tests, specifying the commit SHA of our (possibly newly) formatted code. Still nested under the jobs key, we fan out our testing actions in parallel, going back to minimal permissions:

  go-test:
    needs: [formatting]
    uses: ./.github/workflows/go_test.yml
    permissions: read-all
    with:
      ref: ${{ needs.formatting.outputs.commit_sha }}

  ui-test:
    needs: [formatting]
    uses: ./.github/workflows/webui_test.yml
    permissions: read-all
    with:
      ref: ${{ needs.formatting.outputs.commit_sha }}

Finally, we push the results to main. We make sure to push the correct SHA.

  push-to-main:
    runs-on: ubuntu-latest
    needs: [go-test, ui-test, formatting]
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Push to main
        run: |
          COMMIT_TO_PUSH="HEAD"
          if [[ "${{ needs.formatting.outputs.commit_sha }}" != "" ]]; then
            echo "Using formatted commit: ${{ needs.formatting.outputs.commit_sha }}"
            COMMIT_TO_PUSH="${{ needs.formatting.outputs.commit_sha }}"
          fi
          git push https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git "${COMMIT_TO_PUSH}":main
        env:
          GITHUB_TOKEN: ${{ github.token }}

Dev Queue

"Testing GitHub Actions" is usually an oxymoron.

But we have a dev hook, .github/workflows/queue-dev.yml, that runs on queue-dev-* instead of queue-main-*. It does all the same steps, except it doesn't push to main. This makes it possible to iterate on the queue action without impacting anything else.

Formatting

Here's a walkthrough of .github/workflows/formatting.yml.

The formatting workflow needs to support both "check" and "fix" modes and needs to return the new commit to be pushed. It must not trigger directly when using the main/dev merge queues. And it needs to be able to write back to the main repo.

name: Code Formatting
on:
  workflow_call:
    inputs:
      auto_fix:
        description: "Automatically fix formatting issues instead of just checking"
        required: false
        type: boolean
        default: false
    outputs:
      commit_sha:
        description: "The SHA of the commit with formatting fixes"
        value: ${{ jobs.formatting.outputs.commit_sha }}
  push:
    branches-ignore:
      - "queue-main-*"
      - "queue-dev-*"
  pull_request:

permissions:
  contents: write

We need to grab the relevant commit and run the formatters in the correct mode:

jobs:
  formatting:
    outputs:
      commit_sha: ${{ steps.get_commit.outputs.commit_sha }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref || github.ref }}
          # Need full history for queue-main pushes
          fetch-depth: 0

      # omitted here: installing prettier, gofumpt, see original file if interested

      - name: Check formatting
        if: inputs.auto_fix != true
        run: bin/run-formatters.sh check

      - name: Fix formatting
        if: inputs.auto_fix == true
        run: bin/run-formatters.sh fix

And finally, if formatting made any changes, we need to push those back so that we can test and keep them. If there’s only a single commit, we amend it, to keep a clean git history. If there are multiple changes outstanding, we add a new cleanup commit at the end. We could amend each commit in turn, to make formatting fully atomic, but that can cause rebase pain; we’ve opted for a slightly ugly history over rebase annoyance. (Other teams might reasonably make different choices here.)

      # Commit formatting fixes if auto_fix is true
      - name: Commit and push formatting fixes if needed
        if: inputs.auto_fix == true
        run: |
          # Only proceed if there are changes to commit
          if [[ -z $(git status --porcelain) ]]; then
            echo "No formatting changes detected, skipping commit"
            exit 0
          fi

          git config --global user.name "Autoformatter"
          git config --global user.email "bot@sketch.dev"
          git add .

          git fetch origin main
          MERGE_BASE=$(git merge-base HEAD origin/main)
          COMMIT_COUNT=$(git rev-list --count $MERGE_BASE..HEAD)

          if [ "$COMMIT_COUNT" -eq 1 ]; then
            echo "Found exactly one commit since merge-base with origin/main. Amending the commit."
            git commit --amend --no-edit
          else
            echo "Found multiple commits ($COMMIT_COUNT) since merge-base with origin/main. Creating a new commit."
            git commit -m "all: fix formatting"
          fi

          git push -f https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git HEAD

Lastly, we expose the new branch head for use by the other steps.

      - name: Get commit SHA
        id: get_commit
        run: echo "commit_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT

Tests

The test actions are pretty straightforward. The one interesting piece is making sure that we are testing the correct commit. This uses a bit of trickery. When inputs.ref is set by the queue, it gets checked out. Otherwise it is empty, and action/checkout defaults to the standard checkout.

      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.ref }}

Improvements

There are definitely improvements we could make, and probably will eventually. For example, it'd be nice for the merge queue to attempt a rebase when you lose a commit race.

But even the fast and loose version we have is pretty darn nice, and like many small-company tools, is simple enough that you rapidly can grok it and tweak it to your heart's content.

Should I copy this?

Fill in the blank:

> I just came across some code and thought, "wait, how did this ever work?" The answer, of course, is _______.

If you have spent your career at larger companies, the answer is probably something like: Test coverage!

If you have spent your career at startups, the answer is more likely: It didn't!

There's a radical amount of divergence in the experiences, needs, and tooling of "software engineers". Sadly, it's easy to forget this, and end up in silly arguments.

Merge queues are often found at bigger companies, which must, eventually, serialize the work being done by many programmers, and do so without introducing test regressions and chaos.

We are a small, young company, staffed by experienced folk. We operate very fast and light: No PRs, no pre-commit codereview. (Post-commit codereview happens exactly as much as people feel like it, without ego at stake.) If you're doing the work, you make the decisions.

This merge queue works well for us. It's not mandated, but we all choose to use it.

If it sounds like a good fit for you, steal at will.

sketch.dev · merde.ai · pi.dev