releaser-pleaser can tag a commit that is not on the default branch (fast-forward merge)
On a project using the fast-forward merge method, releaser-pleaser can cut the version tag at a commit that is not on the default branch — silently omitting anything merged to main while the Release MR was open.
Observed twice on phpboyscout/go/config, both times losing documentation only. Nothing about the mechanism limits it to documentation.
Evidence
config v0.12.1, tagged 2026-07-31:
Release MR !94 (merged) head (sha) |
1c17efd18edcf465c3355653b2cd171d67f1bf2e |
Tag v0.12.1 points at |
1c17efd18edcf465c3355653b2cd171d67f1bf2e |
main after merge |
dd4b82efa9e9c24f192da30a970376e53333b27b |
merge_commit_sha |
null (fast-forward, no merge commit) |
git merge-base --is-ancestor v0.12.1 origin/main → falseThe tag equals the MR's own head SHA exactly. main carries a different commit with the same title, whose parent is the commit that merged while the Release MR was open.
Checking the previous four tags on the same project:
| Tag | Commit | On main? |
|---|---|---|
| v0.12.1 | 1c17efd1 |
no |
| v0.12.0 | ec600bfe |
yes |
| v0.11.0 | 7319bc20 |
no |
| v0.10.0 | 06702601 |
yes |
Two of four. git log --all --grep="chore(main): release v0.11.0" returns three distinct commits, which is the rebase churn showing through.
What appears to be happening
Stated as inference — the SHAs and the merge_commit_sha: null are measured, the mechanism is deduced from them:
- The Release MR is opened from
mainat commit A. - Other work merges;
mainmoves to B. - The Release MR is merged. Fast-forward merge cannot replay a stale branch as-is, so the source branch is rebased onto B, producing B' — which is what
mainbecomes. - The MR record still reports its pre-rebase head, and the tag is created there.
So the tag names a commit whose parent is A, not B. Everything between A and B is missing from the tagged tree.
Why it matters more than it looks
- The whole
phpboyscout/gogroup usesmerge_method: ffby convention, so every project in it has this shape. - A Release MR is routinely open for a while — long enough for an ordinary MR to land. That is the trigger, and it is normal working practice rather than an edge case.
- Both observed losses were documentation, which is luck. In v0.12.1 the missing commit added a correctness warning to two adapter pages; in v0.11.0 it flipped three spec statuses. Had either been a code fix, the tagged module would have silently shipped without it — and a Go module version cannot be re-cut once
proxy.golang.orghas served it.
Suggested directions
Not prescribing an implementation — whoever owns this will know whether it belongs in the component or upstream in apricote/releaser-pleaser:
- Tag the default branch's post-merge HEAD rather than the Release MR's recorded head. That is the commit the release actually corresponds to.
- Fail loudly instead. Assert
git merge-base --is-ancestor <tag> <default-branch>after tagging and fail the job when it does not hold. Cheap, and it is exactly the assertion that failed here — a release that cannot be cut correctly is better than one cut wrongly and unnoticed. - Refuse to merge a stale Release MR, so the rebase never happens: have the component re-open or refresh the Release MR when the default branch moves.
(2) is worth having regardless of whether (1) or (3) lands, because it turns a silent wrong release into a visible failed job.
Detection today
Anyone releasing from a fast-forward project can check with:
git fetch --tags && git merge-base --is-ancestor <tag> origin/mainNon-zero means the tag is not on the default branch and the release is missing commits.