DevOps · Release Engineering
Deriving a Changelog from Commit Messages
How a structured commit header turns release notes into a mechanical transformation, which parts of the convention are load-bearing, and where the encoder and the reader disagree.
A commit message is written for one reader at one moment: whoever is looking at the diff that day. A changelog is read months later, by someone who has no access to the diff and only wants to know whether the upgrade will hurt. Attempts to bridge the two usually start by asking contributors to write release notes by hand, which produces notes that are accurate for the last release nobody updated and empty for the ones that mattered. The alternative is to make the commit header carry a fixed grammar so a program can assemble the notes.
That is the whole design of Conventional Commits. The interesting engineering question is not the grammar but which fields a tool can safely rely on.
The header as three fields
The specification defines a commit as an optional type, an optional scope, an optional !, a required description, and optional body and footers:
fix(parser): reject empty key segments
The splitter previously returned a single empty segment for
trailing separators, which broke downstream range checks.
BREAKING CHANGE: parse() now raises ValueError on empty input
Refs: #4471
The type is a noun from a small vocabulary: feat, fix, and whatever the project adds. The scope names the subsystem in parentheses. The description is a short imperative phrase. A ! before the colon marks the commit as breaking, and so does a BREAKING CHANGE: footer in the body, which the specification treats as equivalent.
The vocabulary is deliberately open. feat and fix have defined meanings, a feature and a bug fix, and the specification ties them to SemVer rules directly: feat implies a minor bump, fix implies a patch bump, and a breaking change of either type implies a major bump. Every other type (docs, refactor, perf, test, build, ci, chore) is a project extension with no defined effect on the version. A tool that special-cases the two defined types and treats the rest under a project-supplied mapping gets the specified behaviour; a tool that invents meanings for the extension types gets something the specification does not guarantee.
Why the parser is small and the hard part is not
Parsing is a regex over the first line: capture an optional type, an optional (scope), an optional !, then the description. The complexities are all in the merge commit shape and in the multiline body.
Merge commits have two or more parent commits and their subject line follows the merging party’s conventions, not the project’s. A correct extractor filters on parent count before parsing, since a merge commit’s body is often the concatenation of every commit it brings in. Squash merges are the opposite problem: the squashed subject is whatever the pull request title said, so the discipline has to be enforced at the pull request title, not at the commit, if the repository squashes.
Footers are where the naive approach fails. The specification’s interesting footer is BREAKING CHANGE:, which may appear with a space in the token (the only footer allowed that freedom) and whose value is everything up to the next footer token. Git’s own trailer convention is adjacent but not identical: git interpret-trailers recognises Token: value lines at the end of a message, tolerates continuations, and can be told to add, remove, or rewrite them. Projects that need machine-readable metadata in the body generally settle on git trailers, because that tooling already exists and it handles the block-by-block scanning correctly:
git log --format='%(trailers:key=Reviewed-by,valueonly)' v1.2.0..v1.3.0
The distinction matters because a footer regex written by hand tends to consume the description paragraph that follows it. The specification limits the footer token to a restricted character set and to a single line, which bounds the damage, but a body of prose beginning with a word and a colon will still be misread. Parsers that anchor on known tokens rather than on the generic Token: value shape avoid this.
Reversibility is what makes the mapping safe
The specification’s strongest constraint is that a revert commit should begin revert: followed by the header of the reverted commit. This exists so a tool can subtract an entry from the changelog rather than adding a fix entry that claims a bug was repaired when the truth is that a change was withdrawn.
Without that rule, the changelog accumulates entries that cancel out: a feature added and reverted across the same release window appears as a shipped feature. A generator that understands revert: can drop both the revert and its target when both fall inside the range. Generators that do not understand it produce notes with a feature list that no longer matches the artifact.
The same logic applies to feat! reverted. A reverted breaking change is not breaking, and a version bump computed from the unreverted set is a major bump for nothing. Resolution order matters here: the revert must be matched before the breaking-change flag is aggregated.
Range selection and the changelog boundary
A generator needs two endpoints. The lower one is normally the previous tag reachable from the current commit, which is the last release on this line of history, not simply the last version published overall. git describe --tags --abbrev=0 gives the nearest reachable tag; --match restricts it to a version pattern so that unrelated tags such as environment markers do not become release boundaries.
The upper endpoint is the commit being released, which on a release branch is not the same as the tip of the main line. Selecting main..release vs v1.2.0..v1.3.0 yields different sets, and a changelog assembled from the wrong range will list commits that are not in the artifact. This is the single most common source of changelogs that describe work the binary does not contain, and it is why release trains and branching topology puts the tag on a commit that the artifact was built from rather than on the merge that came after.
Two ranges that look similar are often confused. A..B in git means commits reachable from B but not from A, which is the set of changes introduced since A. A...B means the symmetric difference, which is the set of commits on either side since the merge base. The three-dot form is right for showing what diverged, and wrong for a changelog. A generator that uses three-dot will advertise work from the other line of history as part of this release.
Ordering, grouping, and the parts a tool should not decide
Once the entries are collected, the presentation decisions are authorial, and there is a reason the changelog convention separates them from the commit grammar. Grouping by type gives a fixed set of sections whose names are the project’s choice. Ordering within a section is either chronological or by scope; neither is more correct, but consistency across releases is what makes a changelog skimmable.
What the generator should not do is decide whether an entry is breaking from the type. The specification says a breaking change is declared with ! or a BREAKING CHANGE: footer, and that is the only signal available. Inferring breakage from a diff is not something a commit parser can do; the classification is the author’s judgement, the same judgement that decides the major number described in semantic versioning as a compatibility contract. A generator that aggregates the declared flags and formats them is trustworthy; one that guesses is not.
There is a further limitation on what a derived changelog can say. A commit description explains the change to the code; a changelog entry explains the change to a user of the artifact. For internal refactors the two are the same. For a change in public behaviour the commit often records the implementation route (“stop double-decoding query parameters”) where the changelog wants the observable effect (“query strings with encoded percent signs are no longer rejected”). No grammar closes that gap. Projects that care write the user-facing sentence in a footer or in a dedicated trailer and let the generator pick it up, which keeps the derivable part derived and the authored part authored.
The failure that matters in practice
The grammar’s coverage is only as good as the commits that follow it. A repository with merge commits from branches made before the convention was adopted has a history whose shape is mixed, and a generator run over a wide range will encounter unparsed headers. The correct behaviour is to treat an unparseable commit as an entry with no type — listed under an “Other” heading or omitted — and never to fail the release. A release process that blocks on a malformed commit message couples the artifact to a cosmetic property of history, which is a poor trade: the fix belongs in a lint step at commit time, where the author is still present to correct it, not in the release gate where a message written weeks ago stops a deploy.
References
- Conventional Commits 1.0.0 — Conventional Commits project
- Git documentation — git-interpret-trailers — Git project
- Keep a Changelog — Keep a Changelog project