Software Engineering · Documentation
Changelogs as a Curated Projection of Commit History
A changelog is not a formatted log. It selects changes by who observes them, classifies them against a version bump, and states consequences that no commit message contains.
A commit log answers “what did we do to this repository”. A changelog answers “what changed for someone who depends on this”. The two are related by a many-to-one mapping, and treating the second as a formatting pass over the first produces a document that is complete, chronological, and useless: hundreds of entries describing internal renames, none of them saying that a function now returns null where it used to raise.
The distinction is visible in the smallest possible release. Three commits land: one adds a parameter with a default, one renames a private helper, one fixes a parsing bug that made a documented input fail. The changelog has two entries, one under Added and one under Fixed, and the private rename is absent. Nothing was lost; the selection criterion is observability by a consumer, and it is applied per commit rather than per file.
Two audiences impose different selection rules
A commit is written for the person who will read it during a bisect or a blame: it explains a change in terms of the code that was there. A changelog entry is written for someone who may not know the internal names at all, and who needs to decide whether to upgrade. That reader cares about three things: whether their code still compiles, whether it still behaves the same, and whether anything they rely on has been withdrawn.
The selection rule that follows is blunt: include a change if a consumer can observe it, omit it if only a maintainer can. A refactor that preserves all observable behaviour is omitted. A dependency bump is omitted unless it changes the minimum supported version of the language or raises a requirement the consumer must satisfy. A documentation fix is usually omitted, with one exception: when the documentation was wrong about behaviour, the correction tells readers that their mental model needs updating, and that belongs in the release.
Granularity is the second decision. One commit does not imply one entry; a feature spread across twenty commits is one entry, and a single commit that both adds an API and changes an unrelated default is two. Splitting by observable effect is what makes an entry quotable in an upgrade discussion, and it is the same decomposition pressure that a reviewer applies to a diff: the unit is the smallest change that can be discussed on its own.
The six categories describe the kind of change, not the file touched
The widely used Keep a Changelog structure fixes six headings: Added, Changed, Deprecated, Removed, Fixed, and Security, plus an Unreleased section at the top. The categories are a classification by effect, and they are mutually exclusive in a way that forces a decision about what the change actually is.
Added covers new capability, including new parameters with defaults and new configuration keys. Changed covers behaviour that differs without being new: a default value flipped, a return type widened, an error now raised where none was. Deprecated means still present and still working, marked for future removal. Removed means gone, and it is the category most likely to produce a compile failure on upgrade. Fixed means behaviour now matches what was already documented. Security means the change closes an exposure, and it is called out separately because it changes the urgency of upgrading rather than the compatibility surface.
Two classification errors recur. The first is filing a removal under Changed, which hides the compile break from anyone scanning for it. The second is filing a bug fix under Changed because the fix alters output; if the previous output was documented, the fix restores the contract and belongs under Fixed, with the corrected behaviour described. The categories work only if they are read as answers to “what must a consumer do”, not as tags for the diff.
Dates in the version heading are best written as YYYY-MM-DD, because every other format is ambiguous somewhere and a changelog is read across locales. Links to a diff between two tags are cheap to add and let a reader verify an entry against the actual change, which is the only audit path a changelog has.
A version number is a claim the changelog must support
Semantic Versioning fixes a three-part number, MAJOR.MINOR.PATCH, and attaches a meaning to each position: incompatible changes increment the major, backward-compatible additions the minor, backward-compatible fixes the patch. The changelog provides the evidence for that claim, and a mismatch between the two is a defect in one of them.
The frequent mismatch is a major bump with no Removed or Changed entries that justify it, which happens when the bump is used as a marketing event or when the breaking change is buried in a dependency’s release notes. The opposite mismatch is a patch release containing a Changed entry that breaks callers, which is worse because consumers have been told that a patch is safe. When a breaking change ships in a patch release, the versioning scheme has stopped carrying information for everyone downstream, and no amount of changelog prose repairs it.
Version zero deserves a note. SemVer states that the initial development series, 0.y.z, may change anything at any time, so a 0.y bump is permitted to break. Changelogs for that series should still classify entries by effect, because readers treat the numbers as a rough signal and will upgrade across minor versions of a zero series without reading carefully.
Deprecation is the category that buys a major version later. An entry that names the symbol, the replacement, and the release in which removal is planned turns a future breaking change into a scheduled one. The mechanism only works if the deprecation entry and the eventual removal entry both exist, and if the removal lands in a major release; a removal after a deprecation notice is still a Removed entry and still requires the bump. This is the same lifecycle that a removed-feature tombstone records in a versioned documentation tree.
Building entries from commit metadata, and where that stops
Conventional Commits gives commit subjects a parseable shape: a type, an optional scope in parentheses, an optional ! before the colon to mark a breaking change, and a description. feat and fix are defined types; others are permitted and conventionally include docs, perf, refactor, test, build, ci, and chore. A BREAKING CHANGE: footer names the incompatibility in prose.
That metadata supports a mechanical first draft. The mapping is a table from type to section, a filter that drops the types with no consumer-visible effect, and a grouping pass. A compact implementation makes the shape of the transformation clear:
import re
SUBJECT = re.compile(r'^(?P<type>\w+)(?:\((?P<scope>[^)]*)\))?(?P<breaking>!)?: (?P<desc>.+)$')
SECTIONS = {'feat': 'Added', 'fix': 'Fixed', 'perf': 'Changed'}
def entries(subjects):
grouped = {}
for subject in subjects:
m = SUBJECT.match(subject)
if not m:
continue # merge commits, reverts, free-form messages
section = 'Changed' if m['breaking'] else SECTIONS.get(m['type'])
if section:
grouped.setdefault(section, []).append(m['desc'])
return grouped
Four things are missing from this and from every parser of the same shape. First, the mapping of types to sections is a convention, not a consequence of the format, so a repository that uses feat for internal tooling produces spurious Added entries. Second, a squash merge collapses an arbitrary number of commits into one subject line, so the entry describes the branch’s title rather than its contents. Third, a revert is a commit that cancels a previous entry, and the pair must be removed from the changelog entirely rather than listed twice. Fourth, and most important, the ! is a human judgement: no parser can determine whether a change breaks callers, only whether the author believed it did.
The last point is why generated changelogs need an editing pass, and why the editing pass is the part that carries the value. Automation reliably answers “which commits are new since the last tag” and unreliably answers “what should a consumer know”. The first question is cheap to answer with git log --no-merges last-tag..HEAD --format=%s; the second requires knowing the documented contract, which lives in the reference pages rather than in the history.
Entries that fail in specific, predictable ways
Five patterns recur in bad entries, and each has a mechanical cause rather than a stylistic one.
An entry that names an internal artifact — a ticket number, a module path, a refactor — has been written from the commit rather than from the change. An entry that says “various fixes” has collapsed several observations into one, which usually means it was written at release time from memory. An entry that says “performance improvements” without a direction or a bound gives the reader nothing to compare against; “reduces allocations in the parser by reusing the token buffer” states the mechanism. An entry filed under the wrong version happens when the changelog is edited on the release branch after a cherry-pick, so the same change appears in two sections. An entry that describes a new error code is useless unless the code itself is documented somewhere stable, which is the argument for an error code registry as the companion artifact.
Security entries have a timing problem the other categories do not. A line naming the vulnerability and the affected versions tells an attacker what to look for in installations that have not upgraded, and omitting the line leaves defenders unable to judge urgency. The convention that resolves it is to ship the fix and the entry together, to name the affected range, and to leave technical detail until the upgrade window has passed. The entry’s function is to make the upgrade decision, not to publish the analysis.
What the document is for
A changelog is read in three situations: deciding whether to take an upgrade, diagnosing a behaviour change after taking one, and writing a migration plan. All three need the same property, which is that entries are indexed by released version and phrased in terms of observable behaviour. Chronological commit order and full coverage actively work against all three.
The maintenance burden is proportional to the number of releases, not the number of commits, and it is front-loaded: an entry written while the change is in hand costs a sentence, and one reconstructed at release time costs a review of the whole diff. The cheapest reliable arrangement is a fragment file per change, merged into the Unreleased section as part of the same merge that lands the code, so the diff that changes behaviour and the diff that describes it are reviewed together. That is the same proximity argument that puts documentation next to the definition it describes, applied to the one document whose subject is change itself.
References
- Keep a Changelog 1.1.0 — Keep a Changelog
- Semantic Versioning 2.0.0 — SemVer
- Conventional Commits 1.0.0 — Conventional Commits
- git-log(1) Manual Page — Git project