Forgejo does not re-run your pull request when main moves
A green check next to a pull request is one of the most over-read marks on a
forge. It does not mean “this change is good to merge”. It means something much
narrower: the commit at the head of this PR, merged with the main that existed
when the run started, passed the gates. The second half of that sentence is the
part that expires.
Forgejo only runs PR workflows on PR events
The check that guards every PR in the nix-config repository
states the reason in its own header. .forgejo/workflows/pr-base-guard.yml,
first comment block:
Forgejo does NOT re-run PR workflows when the BASE branch moves: it only runs them on PR events (opened / synchronize / reopened).
synchronize is an event you cause by pushing to the branch. A merge landing
on main is an event on a different branch, so it does not re-trigger your PR’s
workflows. The badge does not go stale, because the forge has no concept of a
check describing an older main — from its side, the check is simply green.
What the guard computes
The guard exists for exactly that gap, and it computes the merge instead of guessing at it:
1
2
3
4
git merge-tree --write-tree --no-messages --name-only origin/main <branch>
# exit 0 = merges clean
# exit 1 = real conflict
# exit >1 = git could not compute the merge at all
The third code is the one worth reading twice. >1 means git could not perform
the merge — unrelated histories being the classic case — and the guard fails that
as unmergeable, with its own message, rather than folding it into “conflicts”.
The remedy it prints is not a rebase: an unmergeable branch has to be recreated
from origin/main and the change re-applied.
Three details make the job trustworthy:
- It checks out
$— the branch head, deliberately not Forgejo’s merge ref, because a merge ref is computed against a current main and would hide the drift being measured. - It runs
git fetch --quiet origin mainfirst and checks against that, not a cached ref. - It fails closed on missing tooling: a capability probe merges
HEADwith itself, and ifmerge-tree --write-treeis unavailable — it needs git 2.38; the fleet runs 2.55 — the job exits 1 rather than reporting a clean merge it never computed.
Conflict is the loud case; clobbering is the quiet one
A red Base drift / conflict check is the case people expect: rebase, resolve,
push. The guard goes further. When the merge is clean it also compares the files
your branch changed since the merge base against the files main changed in
the same window, and emits a ::warning:: when they overlap. It stays green on
purpose — with several PRs an hour, failing every behind-but-clean branch would
force every open PR to rebase and re-run its per-host closure builds whenever
anything else merges.
That warning carries the real risk. In a repository where more than one agent session edits at once, “textually clean” is not “semantically safe”. The guard’s own failure text says not to merge main into the branch as a shortcut, and to resolve on the file’s new structure rather than re-instating your copy, because in this repo a conflict usually means main rewrote that file underneath you. A merge that applies your hunk to a file someone restructured two merges ago can land green and quietly undo their change.
The merge endpoint agrees with the guard. A merge returning 405, or a PR
reading mergeable:false, almost always means the branch is behind main — not
that the forge is broken.
Why this is the default hazard, not an edge case
A shared, fast-moving repository turns base drift from an edge case into the
normal state. When several sessions and several PRs land per hour, a branch cut
twenty minutes ago is already behind, and every merge to main invalidates the
evidence under every other open PR. The discipline this fleet runs on follows
directly: fetch and rebase onto origin/main immediately before every push, and
re-verify the base immediately before merging — the only pre-merge check
that means anything is one computed against the current main.
So the pre-merge check is not the badge. It is, in the moment before you merge:
1
2
git fetch origin
git merge-tree --write-tree --no-messages --name-only origin/main <branch>
and when that returns non-zero: rebase onto the freshly fetched origin/main,
re-apply the change onto the file’s new shape rather than restoring your copy,
re-run the gates, then git push --force-with-lease.
What you can check
- The workflow itself:
.forgejo/workflows/pr-base-guard.ymlin nix-config, and the same job in nix-config-private — workflow namePR base guard, jobBase drift / conflict check,runs-on: [gate],on: pull_requestwith nopaths:filter (docs-only PRs conflict too, and the build check skips them). - The job’s own output. It prints
branch head:,origin/main:,merge base:andbranch is N commit(s) behind origin/mainbefore it judges anything, so the distance from main is visible rather than inferred. - The required-check context, read back from the commit status rather than
guessed:
PR base guard / Base drift / conflict check (pull_request). - The command, run locally, immediately before you click merge.
For the runner pools and the host graph these jobs run on — and why the guard
floats on the light gate label instead of a host’s single slot — see
how the stack is built.