How commits are attributed to releases¶
Generating a changelog is two problems, not one. Classifying a commit — is this a feature, a fix, a breaking change — is the easy half. The hard half is deciding which release a commit belongs to, and that is where changelog generators usually get it wrong.
Why dates are the wrong answer¶
The obvious approach is to sort commits by date and cut the list at each tag: everything
between v1.1.0 and v1.2.0 belongs to v1.2.0. It works on a repository where every
commit was made in order on one branch, and that repository does not exist.
Two ordinary things break it:
- A branch opened before a release and merged after it. Its commits are older than the release tag by date, but they were not in that release. A date cut files them under a version that shipped without them.
- A tag on a merge commit. That is the normal shape of a merged Release MR — the tag lands on the merge, whose committer date can sit anywhere relative to the work it brings in.
Committer dates are also writable. A rebase, a cherry-pick or an amend moves them, and a changelog that trusts them tells a different story after a rebase than before it.
Ancestry, oldest tag first¶
changelog asks a different question: which release can reach this commit? Tags are
sorted by semver, oldest first, and each release walks the commits reachable from its tag.
A commit already claimed by an earlier walk is skipped, so every commit is attributed to
the earliest release that contains it — which is the release it actually shipped in.
Then whatever HEAD can reach that no tag claimed becomes Unreleased, at the top.
This is why the answer does not change when you rebase, and why a long-lived branch merged after a release lands in the release that merged it rather than the one it was branched from.
Merge commits contribute nothing¶
A commit with more than one parent never produces an entry, even when its message is a valid Conventional Commit, and even when a release tag points at it.
The merge is bookkeeping — it records that a branch joined, not that something changed for a user. The changes came in on the commits it brought with it, and those are attributed normally. Counting the merge as well would list every merged change twice: once as itself, once as "feat: merge the side branch".
The tag on that merge commit still closes its release. Attribution and entry-collection are separate steps, so a tag can mark a boundary without its own commit saying anything.
Two tags on one commit¶
Re-tagging happens: a release is cut, something goes wrong before it ships, and a second tag lands on the same commit. Ancestry alone cannot split those commits — the second tag reaches exactly what the first one did, so it has no entries of its own.
Silently dropping the second release would be a lie by omission, because the version was published and someone will search the changelog for it. So the lower semver claims the commits and the higher tag is emitted as a heading with an empty body. Reading it, you can tell that the version exists and that nothing new landed in it — which is the truth.
Non-semver tags are invisible¶
A tag that is not valid semver — release-4, nightly, 2026.07 — is discarded before
attribution begins. It does not order, it does not bound, and it does not appear.
The consequence is worth stating plainly: its commits are not lost, they are attributed to
whichever semver release does reach them. A repository tagged only with non-semver names
produces a changelog with a single Unreleased section containing everything.
This is a deliberate narrowing rather than an oversight. Semver is what makes "newest first" a defined order at all; with arbitrary tag names there is no correct ordering to fall back on, and guessing one would produce a changelog that looks right and is not.
Where this leaves the output¶
- Entries within a release are ordered by committer time, newest first. Dates are wrong for attribution but perfectly serviceable for presentation inside a release that has already been decided.
- The
Unreleasedsection is a normal section in every other respect, which is why it counts towardsWithMaxReleases. - A release whose commits were all dropped by classification — all
testandci— has no entries and no collision, so it is omitted entirely rather than emitted empty. That is the one place where the "never let a version disappear" rule does not hold, and it is worth knowing before you go looking for a version that shipped nothing but CI changes.
The mechanics — which types map where, what the emitted markdown looks like — are in commit classification and generated output format.