The auto-merge that dropped a security gate
Auto-merge does not automate the judgement “this change is correct”. It automates a different sentence: “this change was correct at some earlier moment”. Those are the same statement only while the base branch holds still — and on a repo taking several pull requests an hour, it does not.
What actually landed
The failure on this fleet was not a bad merge in any dramatic sense. It was textual. Git can auto-merge a workflow file “successfully” and still drop a job’s steps, because a merge is textual and the two edits were structurally different. Measured here: a rebase over a concurrent pull request silently removed a signature-verification gate from the one job that actually publishes — a green pipeline with a missing security gate.
Nothing about that outcome is visible in the result. The file has no conflict markers, the pull request is green, and the job that lost its gate still runs — without the thing that was protecting it.
A conflict resolution is worse, not better: it is new code that no test has seen.
The merge commit is not the commit that passed CI, and most CI systems do not build the
merge result unless you explicitly ask them to. So the artifact that reaches main is
frequently a revision nobody evaluated.
Required checks are evaluated against a base that can move
The second half of the problem is in the forge. Required status checks are evaluated against the base branch as it was when they ran — and Forgejo does not re-run pull-request workflows when the base branch moves: it runs them on pull-request events (opened, synchronized, reopened). Nothing else.
A pull request that was green against main an hour ago therefore still shows green
after several other pull requests landed underneath it. The check is a true statement
about an earlier main. The workflow that exists because of this is explicit about
it:
Forgejo does NOT re-run PR workflows when the BASE branch moves: it only runs them on PR events (opened / synchronize / reopened).
That comment sits at the top of .forgejo/workflows/pr-base-guard.yml in the public
ymrtech/nix-config repository. The job inside re-checks the pull request head
against the current origin/main using the same three-way merge the merge itself
would perform — git merge-tree --write-tree — so a real conflict fails in seconds
instead of surfacing at merge time as a 405, or after the merge as a post-merge deploy
rollback.
Three details are worth stealing:
- A required context is the workflow name, a literal ` / `, the job name, then the event in parens. The suffix is part of the string, so renaming a workflow or a job silently breaks enforcement — the required context then never reports.
- Only unfiltered jobs may be required. A
paths:-filtered orif:-skipped job never posts a passing status, so requiring one blocks every merge forever. Exactly two jobs on this repo qualify and are required:Secret Scan / gitleaks scan (pull_request)andPR base guard / Base drift / conflict check (pull_request). - It deliberately does not fail a behind-but-clean branch. With several pull
requests landing per hour, requiring branch-equals-main would invalidate every open
pull request on each merge. It covers the real risk — a conflicting branch — and
warns when
mainmoved and touched the same files.
Make the safe path the cheap one
The counter-argument is that auto-merge exists because the manual step is boring, and a boring step that is merely required gets skipped. Discipline is not a control. So the fix is not “be more careful” — it is to leave auto-merge in place and put the verification where the work already happens.
The invariant is short: the gate must be re-evaluated against the merge RESULT, and
a human reads every conflict resolution. The base guard is that idea made cheap —
one [gate]-labelled job, no nix, seconds of runtime, unfiltered, so it runs on every
pull request.
The other half is refusing to trust the merge call — this chain learned it the same way:
1
FATAL: PR #$LOCK_PR reported merged but main's flake.lock does not pin clientsv@$MYSHA
The comment above it records why: a 200 from the merge endpoint without the merge
applying was reported as success, because the wait that followed matched a
pre-existing deploy of the old main head. The lock never reached main and nothing
noticed. The remedy has the same shape as everywhere else on this site — assert the
effect, not the status code.
What you can check
- Whether your forge re-runs checks when the base branch updates. Most do not, and
the consequence is that “green” is a claim about a
mainthat may have moved. Test it rather than assuming: push a second pull request that changes a file the first one touched, and see whether the first one’s checks re-run. - Whether a recent merge commit differs from the commit CI actually tested. If the
revision that landed on
mainis not the head that went green, no check saw what you shipped. - Whether a conflict was resolved by a person. A resolution is code; it needs the same reading as any other.
This is the same argument as a green deploy that deployed nothing: the check is about a particular revision of a particular tree, and that deploy had computed its scope from a single commit’s diff. For the gate that verifies a change after it lands — the health check and the automatic rollback on the host itself — see how the stack is built.