The [current] release-fold has now bitten at least seven times. The existing gate ("CHANGELOG entry present") catches one half of it. This proposes the check that catches the other, more damaging half — and argues against the fix that first suggests itself.
The failure
The release job renames the first ## [current] heading into the new version number. A branch open across a release therefore has its heading renamed underneath it on main. Merging main then lands the branch's edits inside an already-tagged section, usually with no git conflict, because the two sides are textually close.
Concretely, on PR #1693 last night:
- Branch writes
## [current] + entry.
- v0.564.0 cut on
main — that heading becomes ## [0.564.0], carrying the branch's own text into the tagged release.
- Branch merges
main. Git auto-merges. The branch's later edits now rewrite the released 0.564.0 section.
Two things were broken, and the gate saw only the second:
- the tagged
0.564.0 section had been silently rewritten, and
- there was no
## [current] left at all.
A consequence that could not be repaired: a factually wrong claim in that entry shipped in v0.564.0 and is in the tagged release for good. It is corrected under [current] rather than by rewriting a tag.
What I do NOT recommend
The obvious fix — have the release job leave an empty ## [current] stub behind when it renames — is a one-line sed change:
sed -i "0,/^## \[current\]$/s//## [current]\n\n## [${VERSION}]/" CHANGELOG.md
The 0,/re/s//repl/ form already limits to the first match, so it is genuinely easy to write. But it would not have caught this case. With the stub, main has an empty ## [current] followed by ## [0.564.0] + content; the branch has ## [current] + the same content. Because the content lines are identical on both sides, git still merges them without conflict. The stub helps only when the branch's entry differs from what was released — the easy case, which is already survivable.
It is also not free: every release commit would then contain an empty section, and [skip changelog] releases would need to not emit one. Small, but not nothing, for a fix that misses the failure that keeps happening.
What I do recommend
Extend the existing CHANGELOG workflow with a second assertion: every already-tagged section must still be byte-identical to its tag.
for tag in $(git tag -l 'v0.*' --sort=-v:refname | head -8); do
v="${tag#v}"
a=$(git show "$tag:CHANGELOG.md" 2>/dev/null | sed -n "/^## \[$v\]/,/^## \[0/p" | sed '$d')
[ -z "$a" ] && continue
b=$(sed -n "/^## \[$v\]/,/^## \[0/p" CHANGELOG.md | sed '$d')
if [ "$(printf '%s' "$a" | cksum)" != "$(printf '%s' "$b" | cksum)" ]; then
echo "::error::CHANGELOG.md section [$v] no longer matches tag $tag."
echo "A release was cut while this branch was open and the merge"
echo "folded this branch's entry into a tagged section. Move it back"
echo "under a fresh '## [current]' and restore [$v] from the tag:"
echo " git show $tag:CHANGELOG.md"
exit 1
fi
done
Verified against the actual failure, not just reasoned about:
- on
main today: checked 8 tags, 0 differ
- on
2d0ba9fd, the merge commit that shipped the corruption: DIFFERS: 0.564.0
Two implementation notes, both learned the hard way here:
The check needs fetch-depth: 0 or an explicit git fetch --tags in that workflow — worth confirming, as the existing step only needs the two SHAs.
Why this is the better trade
The stub prevents a class of merge that is already survivable. The tag check detects the specific corruption that keeps shipping, costs one loop in an existing workflow, needs no change to the release job, and — unlike the stub — is verifiable against a commit we know was broken.
They are not exclusive; the stub could still land. But if only one goes in, this is the one that would have caught every occurrence so far.
Happy to send a PR if the shape looks right.
🤖 Generated with Claude Code
The
[current]release-fold has now bitten at least seven times. The existing gate ("CHANGELOG entry present") catches one half of it. This proposes the check that catches the other, more damaging half — and argues against the fix that first suggests itself.The failure
The release job renames the first
## [current]heading into the new version number. A branch open across a release therefore has its heading renamed underneath it onmain. Mergingmainthen lands the branch's edits inside an already-tagged section, usually with no git conflict, because the two sides are textually close.Concretely, on PR #1693 last night:
## [current]+ entry.main— that heading becomes## [0.564.0], carrying the branch's own text into the tagged release.main. Git auto-merges. The branch's later edits now rewrite the released0.564.0section.Two things were broken, and the gate saw only the second:
0.564.0section had been silently rewritten, and## [current]left at all.A consequence that could not be repaired: a factually wrong claim in that entry shipped in v0.564.0 and is in the tagged release for good. It is corrected under
[current]rather than by rewriting a tag.What I do NOT recommend
The obvious fix — have the release job leave an empty
## [current]stub behind when it renames — is a one-linesedchange:sed -i "0,/^## \[current\]$/s//## [current]\n\n## [${VERSION}]/" CHANGELOG.mdThe
0,/re/s//repl/form already limits to the first match, so it is genuinely easy to write. But it would not have caught this case. With the stub,mainhas an empty## [current]followed by## [0.564.0] + content; the branch has## [current] + the same content. Because the content lines are identical on both sides, git still merges them without conflict. The stub helps only when the branch's entry differs from what was released — the easy case, which is already survivable.It is also not free: every release commit would then contain an empty section, and
[skip changelog]releases would need to not emit one. Small, but not nothing, for a fix that misses the failure that keeps happening.What I do recommend
Extend the existing CHANGELOG workflow with a second assertion: every already-tagged section must still be byte-identical to its tag.
Verified against the actual failure, not just reasoned about:
maintoday:checked 8 tags, 0 differ2d0ba9fd, the merge commit that shipped the corruption:DIFFERS: 0.564.0Two implementation notes, both learned the hard way here:
cksum, notcmp. MSYS2 ships no diffutils, andcmp -sreturns non-zero both when files differ and whencmpis absent — so acmp-based gate reports a false failure on the Windows legs. This bit PR fix(build): resolve the source root once (--target=wasm on installed trees); report sweep signal deaths #1666.git showcalls.The check needs
fetch-depth: 0or an explicitgit fetch --tagsin that workflow — worth confirming, as the existing step only needs the two SHAs.Why this is the better trade
The stub prevents a class of merge that is already survivable. The tag check detects the specific corruption that keeps shipping, costs one loop in an existing workflow, needs no change to the release job, and — unlike the stub — is verifiable against a commit we know was broken.
They are not exclusive; the stub could still land. But if only one goes in, this is the one that would have caught every occurrence so far.
Happy to send a PR if the shape looks right.
🤖 Generated with Claude Code