Auto Merge Pull Requests After Checks Pass (2026)
Nov 20268 min read
How we auto merge pull requests once the build check passes, with a 170 line webhook handler: signature check, file blocklist, sha guard, dry run.
- Auto merge
- Webhooks
- Release automation
To auto merge pull requests safely, merge only on a passing build of the exact commit you checked, only for the kinds of change you have decided need no reviewer, and refuse anything else loudly. On mkdirhq.com a small webhook handler does this for blog posts. Everything else still waits for a founder.
I'm Dheer, co-founder at mkdir (mkdirhq.com). A scheduled agent writes a post for this site twice a week and opens a pull request for it. Nobody reads it before it goes live. The build has a post checker, a lint step and a smoke test, and when those pass the post is released. This is how the merge half of that works, and what went wrong while we built it.
Why not use GitHub's built-in auto-merge?
GitHub has a native auto-merge. Someone with write access turns it on for a pull request, and GitHub merges once the required reviews and status checks pass. It needs the repository setting switched on and a branch rule that names which checks are required.
That covers a lot of teams, and if it covers yours, use it. We needed two things it does not do. First, a rule about files. A blog pull request that also edits the contact form's server code or the hosting config must not merge on its own, however green the build is. Native auto-merge merges whatever the required checks accept, so that rule would have to become a check of its own. Second, someone still has to switch it on for every pull request. Our agents are not allowed to.
So the first version was a CI workflow on the code host's own runners, 52 lines of shell. It listened for finished check runs, found the pull request, grepped its file list against a blocklist and called the merge command. A second workflow, 81 lines, wrote the preview link into each pull request description.
What happened when the CI minutes ran out?
On 2026-10-22 the account ran out of its monthly CI minutes and that day's jobs stopped. The merge job was one of them. A release process that halts when a monthly allowance runs out is a process you have to babysit.
The site already runs on a serverless platform, and its builds run there too. The same day we moved both jobs into one small serverless function of its own, deleted the two workflow files and wrote a rule into the repo instructions: no new CI workflows. The handler is 170 lines of JavaScript today. It has no dependencies and no storage.
A function that answers webhooks costs almost nothing on our host's free plan. That plan allows 100,000 requests a day and 10 ms of CPU time per request, as of November 2026. Most of our handler's time goes to waiting on GitHub's API, and waiting does not count as CPU time.
How does a webhook auto merge work, step by step?
GitHub sends the handler a check_run event each time a check changes. Most are thrown away within a few lines. Here is the full path for the ones that are not.
| Step | What the handler does | What goes wrong without it |
|---|---|---|
| 1. Verify | Recomputes the HMAC SHA-256 of the raw body with the webhook secret and compares it in constant time | Anyone who finds the address can post a fake "build passed" event |
| 2. Filter | Keeps only completed, successful runs of our build check, on any branch except main | It reacts to its own check runs, failed builds or other checks |
| 3. Find the pull request | By branch name; if GitHub left the branch out, by the commit | Silent misses (this happened, see below) |
| 4. Match the commit | Stops unless the pull request's head is the commit that was built | It merges code that nobody built |
| 5. Write the preview | Puts the preview address and links to the changed pages at the top of the description | Reviewers hunt for the preview |
| 6. Check the files | Comments and stops if any changed file matches the blocklist | A post sneaks in a config change |
| 7. Merge | Squash merge, sending the built commit's sha with the request | A push after the build gets merged unbuilt |
Step 4 and step 7 look like the same check twice, but they guard different moments. Step 4 runs before the slow part: reading the file list and editing the description. Someone could push to the branch in between. The merge endpoint takes a sha, and if the branch head no longer matches it, GitHub answers 409 and merges nothing. That last check happens on GitHub's side, at the moment of the merge, so there is no gap left.
The blocklist is one regular expression. It covers the form endpoints, the build scripts, the bot itself, the framework and hosting configs, the dependency files and anything named like an environment file. If a blog branch touches one of those, the handler comments on the pull request with the file names and leaves it for a person. That has not fired yet, which is what you want from a tripwire.
Merges happen as squash merges, so each post lands on main as one commit with the pull request's title. Undoing a post is then one revert.
How do you verify a GitHub webhook signature?
GitHub signs each delivery with the secret you gave the webhook. The signature arrives in the X-Hub-Signature-256 header, as sha256= followed by a hex HMAC of the body. You compute the same value yourself and compare.
Two details catch people. You must hash the raw body exactly as it arrived, so read it as text before you parse it as JSON. And you must not compare with ==, which can return early on the first wrong character. GitHub's own docs say so. Our runtime offers a timingSafeEqual that takes two buffers of the same length, so we check the lengths first and then call it.
A bad signature gets a 401 and nothing else. An unsigned request never reaches the code that talks to GitHub.
What broke after it went live?
Two things, both on the first day.
The daily safety net could not read check runs. The cron exists for the case where a webhook is lost: once a day, at 06:17 UTC, it lists open blog pull requests and asks GitHub whether their build passed. The token we could give the handler is scoped to this one repository, with read and write on contents and pull requests. It could not get read access to check runs, so the cron's first call was refused. We wrapped each pull request in its own try and catch. Now the cron logs "skipped" and moves on, and the webhook does the real work.
Then a check run arrived with no branch. Pull request #71, a batch of copy edits, built fine, and the handler wrote no preview link. The event's check_suite.head_branch was null. GitHub's webhook docs describe that for pushes to forks. This was not a fork. The first version treated a missing branch as a reason to ignore the event, so it ignored it.
The fix is a few lines. With no branch name, the handler asks GitHub which pull requests contain the built commit, keeps the open ones that target main, and takes the branch name from the pull request itself. The branch rule for merging now reads that name, not the one in the event.
Neither bug could have merged something it should not have. Both made the bot do less. Every check in the table fails toward doing nothing, and that was deliberate.
How do you test an auto merge bot without merging anything?
With a switch. The handler has a DRY_RUN setting. When it is on, the bot does every step except the merge and posts a comment saying it would have merged, with the commit it would have used.
We opened a throwaway pull request on a blog branch, titled "TEST ONLY", let the build pass and read the comment. Then we closed it, flipped the setting and redeployed. That is the whole test plan, and I would not ship a bot like this without it. The first real merge is the wrong moment to learn your branch filter has a typo.
Since then the bot has released five posts. Each one merged between 83 seconds and four minutes after its pull request opened, and nearly all of that was the build. The merges show up under my own account, because the token belongs to it. If you build one of these, decide that on purpose. A dedicated bot account or a GitHub App keeps the history easier to read.
What would we change for a bigger team?
Anyone who can push a branch whose name starts with blog- to this repository can get a pull request merged, as long as it only touches allowed files and the build passes. With two founders and a couple of scheduled agents, that is acceptable. With twenty people it is not. There, I would check who opened the pull request, or require a label only certain accounts can add.
I would also move from a personal token to a GitHub App. An App can be given read access to checks, which would make the safety net work, and its merges would carry its own name.
- > Merge on the exact sha that was built, and pass that sha to the merge call.
- > Verify the signature on the raw body, in constant time, before parsing anything.
- > Keep a file blocklist for automatic merges, and make a match comment instead of failing silently.
- > Ship with a dry run switch and use it once on a throwaway pull request.
- > When an event is missing a field you rely on, look the thing up instead of ignoring the event.
- > Make every failure path end in "do nothing", then add a slow safety net for lost events.
If you run a business and do not write code, here is the question to ask whoever ships your site or internal tools: what decides that a change is safe to release without a person? If the answer is "the build passed", ask what the build does not look at.
Sources
- The handler, its history, the removed workflows and pull requests #66, #71 and #97 to #105: this site's own repository, 2026-10-22 to 2026-11-07
- Native auto-merge requirements and who can enable it: GitHub Docs, Automatically merging a pull request (docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request)
- X-Hub-Signature-256, HMAC SHA-256 and the advice against a plain == comparison: GitHub Docs, Validating webhook deliveries (docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)
- head_branch can be null in check_run events: GitHub Docs, Webhook events and payloads, check_run (docs.github.com/en/webhooks/webhook-events-and-payloads)
- The merge endpoint's sha parameter and its 409 response: GitHub REST API docs, Merge a pull request (docs.github.com/en/rest/pulls/pulls#merge-a-pull-request)
- Free plan limits (100,000 requests a day, 10 ms CPU per request), as of November 2026: our hosting platform's published limits page