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