← All posts

Engineering · 2026.10.02 · 6 min · Updated 2026.10.26

Guarding main When AI Agents Open PRs in Parallel: GitHub Required Checks, a PROD Preflight, and a Merge Script

GitHub branch protection alone couldn't hold up with several AI agents opening PRs and a release commit after every merge, so I fixed the required checks and added a PROD deploy preflight and a merge script.

Reading
Theme
Size
Width

One day every PR in the repo sat at BLOCKED. CI was all green and nothing could merge.

I'm the only person maintaining this repo, but there are often several AI agent sessions opening PRs at the same time.

GitHub branch protection alone can't hold the line there: required checks fight with path filters, and turning on strict makes every PR rerun CI over and over.

In the end I skipped long-lived branches and left strict off. Instead I added two things: a preflight check before PROD deploys, and a merge script that queues merges and catches the PR up with main when needed.

Every merge adds a release commit

Each PR used to bump the version file itself. With several agents opening PRs at once, they all fought over the same version number and kept conflicting.

Now a PR only drops a changeset fragment, a small file describing the change, into changelog.d/. After the merge, a release workflow computes the version, assembles the changelog, and pushes the tag.

The side effect is that every merge leaves an extra release commit on main carrying [skip ci], a marker that tells GitHub Actions not to trigger workflows for that push.

Several of the design choices below trace back to this.

Required checks vs. path filters

That day, main's branch protection had re-enabled itself and come back with the old required check names, the checks a PR must pass before it can merge.

On top of that, the CI workflows used workflow-level paths: filters. A backend-only PR never triggered the frontend workflow, so the frontend check never reported, and every PR got stuck.

Associated checks stay in a "Pending" state and block merging

That line is from GitHub's guide to troubleshooting required status checks, describing a whole workflow skipped by path filtering. The same page lists skipped as a successful status.

Bypassing with --admin would amount to merging on red, so I fixed branch protection first. The required checks now use the current names.

For pull_request I removed the workflow-level paths:, so every PR triggers the workflow, which first checks whether any relevant files changed. If none did, the main job shows as skipped, and branch protection treats skipped as passing.

If the detection step itself fails, the main job runs the full suite.

Before and after: workflow-level paths: filters meant a backend-only PR never triggered the frontend workflow, so the frontend check never reported and every PR sat at BLOCKED; now paths: is removed for pull_request, every PR triggers and first checks for relevant file changes, the main job shows skipped when nothing relevant changed, which branch protection treats as passing, and runs the full suite if detection fails.

The lesson: whenever you rename a CI job, update the required checks too, or every PR gets stuck again.

No long-lived branches, no strict

Strict is the branch protection setting that requires a branch to be up to date with its base before merging. I passed on these options:

Option Why I didn't use it
Long-lived STG/PROD branches Doesn't make CI faster; adds two more merges, hotfixes need cherry-picks, and the branches drift
Run only affected tests on PRs It's a single crate and the most frequently changed code is the shared layer, so the savings are only part of 30-odd seconds of tests. A missed test turns main red and blocks STG deploys
Strict branch protection Every merge produces a release commit, so with strict on, every PR has to keep catching up with main and rerunning CI

The holes those options were meant to plug are real, though.

PROD deploys are manual and can target any commit on main. Hit deploy right after a merge and E2E might not have finished, or might already have failed.

Without strict, parallel merges can land the next PR before the previous release is pushed, and the release bot's push gets rejected.

A migration PR that's behind main can merge, and version collisions or dependency problems only show up when main goes red after the merge.

PROD deploy preflight

Both PROD deploy workflows run a preflight before the build. The target commit must have a successful E2E run triggered by a push to main, and if CI ran, its latest run must also have succeeded.

Any query error counts as a failure. Infra-only changes or emergencies can skip it, but a reason is required.

The tricky part is that main's HEAD is usually a release commit. It carries [skip ci], so it has no E2E record.

The preflight checks its single parent instead and verifies that the two differ only in the version files and changelog: the two package JSON files must be identical once their version fields are removed.

Real changes disguised as a release commit get caught here. When the preflight blocks a deploy, it walks back to the most recent release tag that would pass and suggests deploying that one instead.

There are things it can't catch. Rerunning an old PROD run or using the deploy script on my laptop both bypass the preflight. It also only looks at E2E and doesn't confirm whether the STG deploy succeeded.

Flow chart of the PROD deploy preflight: a regular commit is checked directly, while a release commit marked skip ci has no E2E record, so its single parent is checked and the two may differ only in version files and changelog. The commit then needs a successful E2E from a push to main and, if CI ran, a successful latest run; query errors count as failures. A pass leads to build and deploy, a block suggests the latest passing release tag. Infra-only or emergency deploys can skip with a reason; rerunning an old PROD run or the laptop deploy script bypasses it.

A merge script instead of strict

scripts/merge-pr.sh <PR#> first takes a lock under the git common dir, the git directory shared by all worktrees of a repo, so multiple sessions on the same machine queue up.

Then it checks the PR state, waits for the required checks (the list is read from branch protection), waits for the previous release to land, and finally runs gh pr merge --squash --match-head-commit. A commit pushed after CI was confirmed green won't get merged along with it.

If a migration PR is behind main, the script runs gh pr update-branch first and waits for another round of CI.

This is my replacement for strict. Version collisions and dependency problems only show up once the PR has caught up with main before merging, but only migration PRs have to pay that cost.

Flow chart of the merge script: take a lock so sessions on one machine queue up, check PR state, wait for required checks, wait for the previous release to land, then squash merge without picking up commits pushed after CI went green; a migration PR behind main first runs gh pr update-branch and waits for another round of CI, replacing strict.

I ran the whole flow with a test PR that carried a migration and was deliberately behind main, and a second process queued for the lock as expected. Later the CRAP metric check got hooked in here too; the details are in using the CRAP metric to find the functions that most need tests.

The limitation is that the lock only works on one machine. Merging directly from the GitHub web UI or from a cloud session isn't protected.

#GitHub Actions#CI/CD#Deployment#Branch Protection

Letters

Loading comments…

Next

Tools

Automating a Bitfinex Lending Rate Report: A Claude Code Routine Opens a PR, a Human Merges It

The rate report is calculated only from Bitfinex's public candles, so anyone can reproduce it. A Claude Code cloud routine opens a PR every month, but nothing merges until a person reviews it.