Why
An entry added to a released section of CHANGELOG.md is invisible to every check.
It has now happened twice, in the same place, for the same reason:
The mechanism is not carelessness, it is a property of the file. A release rolls
[Unreleased] into [X.Y.Z] and leaves a fresh [Unreleased] behind that carries only the
sections the next PRs add. So right after a release, "the first ### Fixed in the file"
belongs to the version just published. Anything that appends relative to a section heading —
a script, an editor macro, a person scrolling — lands in the wrong one, and the result reads
perfectly.
test/changelog-rule.sh cannot see it: it validates the entries inside [Unreleased], so
an entry that is not there is not checked, it is absent.
Build
A check that a released section has not changed. The cheapest honest form: every
## [X.Y.Z] section is immutable once its tag exists, so compare each one against the same
section at that tag and fail on a difference.
git show vX.Y.Z:CHANGELOG.md # the section as released
That is exact rather than heuristic, needs no new file to maintain, and gives a message that
can say which version was edited and how. It needs the tags present in the checkout — CI
fetches them for the release workflow, but the depth used by the other jobs has to be checked
before relying on it.
Deliberate consequence: a correction to a published section — a typo, a broken link — then
fails the gate. That is the right default (the section is history), and the escape is to say
so in the PR and let a human override, not to weaken the check.
Validation
- Moving one word in a released section turns the check red, naming the version.
[Unreleased] is untouched by the new check — the word-count rule already owns it.
- The check is added to the list
CLAUDE.md tells a contributor to run before opening a PR, or
it is a check nobody runs.
Out of scope
Preventing the misfile in the first place (a --unreleased insertion helper, a lint on section
order). Worth considering later; catching it is what stops it shipping.
Why
An entry added to a released section of
CHANGELOG.mdis invisible to every check.It has now happened twice, in the same place, for the same reason:
unlessentry landed in the published[0.12.0]section. Fixed by hand, and thesame PR found that the entry had never been word-checked either, because the rule only looks
inside
[Unreleased].[0.14.0]section, four days later.The mechanism is not carelessness, it is a property of the file. A release rolls
[Unreleased]into[X.Y.Z]and leaves a fresh[Unreleased]behind that carries only thesections the next PRs add. So right after a release, "the first
### Fixedin the file"belongs to the version just published. Anything that appends relative to a section heading —
a script, an editor macro, a person scrolling — lands in the wrong one, and the result reads
perfectly.
test/changelog-rule.shcannot see it: it validates the entries inside[Unreleased], soan entry that is not there is not checked, it is absent.
Build
A check that a released section has not changed. The cheapest honest form: every
## [X.Y.Z]section is immutable once its tag exists, so compare each one against the samesection at that tag and fail on a difference.
That is exact rather than heuristic, needs no new file to maintain, and gives a message that
can say which version was edited and how. It needs the tags present in the checkout — CI
fetches them for the release workflow, but the depth used by the other jobs has to be checked
before relying on it.
Deliberate consequence: a correction to a published section — a typo, a broken link — then
fails the gate. That is the right default (the section is history), and the escape is to say
so in the PR and let a human override, not to weaken the check.
Validation
[Unreleased]is untouched by the new check — the word-count rule already owns it.CLAUDE.mdtells a contributor to run before opening a PR, orit is a check nobody runs.
Out of scope
Preventing the misfile in the first place (a
--unreleasedinsertion helper, a lint on sectionorder). Worth considering later; catching it is what stops it shipping.