open atlas
↑ Back to track
CI/CD pipelines CICD · 04 · 01

SemVer, conventional commits, and automated changelogs

SemVer encodes a contract — MAJOR breaks consumers, MINOR adds compatibly, PATCH fixes compatibly. Conventional commits make that intent machine-readable so tools compute the bump and generate the changelog. Forget to mark a break and it ships as a MINOR.

CICD Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A library ships 2.4.0 on a Friday. It is a one-word rename — parse() now returns null instead of throwing on bad input, “a tiny improvement.” Over the weekend, three downstream services that wrapped parse() in a try/catch silently start writing null rows to the database, because the throw that their error path depended on never fires. The change went out as a MINOR, so every consumer’s ^2.0.0 range auto-upgraded into it. The author’s defence: “I didn’t remove anything.” But behaviour is the contract. A break that wasn’t marked as a break shipped to everyone who trusted the version number.

SemVer is a promise about what can break

Why does the version number exist at all? Because your consumers automate upgrades — ^2.0.0 silently pulls every MINOR and PATCH. If you mislabel a break, it ships to everyone who trusted you. That contract is the whole point of this section.

A version MAJOR.MINOR.PATCH is not a changelog of effort — it is a compatibility contract aimed at the consumer. SemVer 2.0.0 fixes the meaning of each field precisely:

  • MAJOR — you made a backward-incompatible change. Any consumer-observable break counts: a removed function, a renamed field, a narrowed input range, a changed default, or — as in the hook — a changed behaviour their code relied on. If existing correct usage can now break, it is MAJOR.
  • MINOR — you added functionality in a backward-compatible way. New endpoint, new optional argument, new return field. Old code keeps working untouched.
  • PATCH — backward-compatible bug fix only. The contract is unchanged; you made it honour what it already promised.

The subtle, senior point is that “break” means consumer-observable contract, not “deleted code.” Tightening validation, changing an error type, altering output ordering a consumer parsed — all can be breaking even though nothing was removed. The author who says “I didn’t remove anything” has the wrong mental model.

Two corners matter in practice. 0.y.z is initial development: under SemVer the public API is explicitly not stable, so anything MAY change in any release — pre-1.0 you cannot lean on MINOR/PATCH meaning anything, which is why “0ver” projects (versions that never reach 1.0.0 precisely to dodge the MAJOR promise) frustrate consumers. And pre-release / build metadata extends the format: 1.4.0-rc.1 (a release candidate that sorts before 1.4.0) and 1.4.0+build.42 (build metadata, ignored in precedence) let you publish previews without spending the real number.

Change you madeConventional commitBump (from 2.4.1)
Fix a crash on empty inputfix: handle empty inputPATCH → 2.4.2
Add an optional timeout argfeat: add timeout optionMINOR → 2.5.0
Rename a public fieldfeat!: rename id to uuidMAJOR → 3.0.0
Change parse() to return null vs throwfix: … + BREAKING CHANGE: footerMAJOR → 3.0.0
Reword a doc commentdocs: clarify return typeno release

Conventional commits: the machine-readable signal

The gap between “I know this is breaking” and “the tooling knows” is bridged by Conventional Commits — a tiny grammar on the commit subject and footer that a parser can read. The contract:

fix: a bug fix                          → PATCH
feat: a new feature                     → MINOR
feat!: a feature that breaks the API    → MAJOR   (the ! marks the break)

# or, the same break expressed in a footer:
refactor: drop the legacy code path

BREAKING CHANGE: parse() now returns null instead of throwing.
                 Wrap callers that relied on the throw.

Two ways to signal MAJOR, and both must be present in the message for the tool to see them: a ! after the type/scope (feat!:, fix(api)!:) or a BREAKING CHANGE: footer (note the literal token {BREAKING CHANGE} with the space, not a hyphen). Types that touch no shipped behaviour — docs:, chore:, test:, style:, ci: — produce no version bump at all. This is the load-bearing convention of the whole unit: the bump is computed from the commit type, so an unmarked break is invisible to the machine and ships at the wrong level.

Why this works

Why a footer and a !? The ! is a fast visual flag in the subject line and works even when the body is stripped; the BREAKING CHANGE: footer gives you room to describe the migration. Convention is to use both for anything load-bearing — feat!: in the subject so a one-line squash still carries the signal, and a footer so the generated changelog has a real “what to change in your code” paragraph. The footer alone is the trap: it lives in the commit body, and a squash-merge that keeps only the subject line silently drops it.

Two tools, two philosophies: declare vs infer

Once commits carry intent, a tool turns history into a release. The two dominant choices sit at opposite ends of an explicit-vs-automatic axis.

changesets makes intent explicit and per-PR. When you finish work you run changeset add, pick the affected packages, choose the bump, and write a human summary; it drops a markdown file into .changeset/ that travels with your branch and goes through code review. At release time changeset version consumes every pending file, computes each package’s new version, and writes the changelog; changeset publish ships them. Because the contributor declares the bump in a reviewed file, it is excellent for monorepos — it understands cross-package dependency bumps — and it never has to guess from commit text.

---
"@acme/parser": major
"@acme/cli": patch
---

parse() now returns null on bad input instead of throwing.
Callers that relied on the throw must add an explicit null check.

release-please makes intent inferred and automatic. A bot parses your conventional commits on main, computes the next version, and maintains a standing “release PR” whose diff is the bumped version files plus a generated CHANGELOG.md. The PR stays continuously up to date as more commits land; when you merge it, release-please tags the release and publishes. You write no intent files — the commit is the intent — which is frictionless until a commit is mislabelled, because the bot believes the commit, not your intentions.

Pick the best fit

A monorepo of ~12 published packages needs automated, reviewable releases. Several packages depend on each other, and contributors squash-merge PRs. Pick the release mechanism.

The failure mode: an unmarked break ships as a MINOR

Everything above exists to prevent one specific incident, and it is the most common one in release automation. A contributor makes a breaking change but writes feat: improve parse() — no !, no footer. The tool dutifully computes a MINOR, because that is what the commit says. The new version flows out, and every consumer pinned to a caret range (^2.0.0, the npm default) auto-upgrades into the break without a single human deciding to. The version number lied, and the contract that consumers trusted to gate breaking changes never fired.

Squash-merge is the amplifier. If the break was signalled only in a BREAKING CHANGE: footer (in the commit body) and the PR is squash-merged with just the subject line kept, the footer evaporates and the tool sees a plain feat: — MINOR again. The defences are concrete: enforce the convention with commitlint in CI so an unparseable or untyped commit fails the check; require a feat!:-style subject marker (not only a footer) for breaks so a squash still carries it; or use changesets, where the bump lives in a reviewed file that a squash cannot strip. And generate the changelog from the same signal — a human-written CHANGELOG.md drifts from reality the moment someone forgets to update it; a generated one is, by construction, exactly what the commits said shipped.

Quiz

You change a function so it returns null on bad input instead of throwing — existing callers relied on the throw. From 2.4.1, what is the correct bump, and how do you signal it?

Quiz

A breaking change was signalled only in a BREAKING CHANGE: footer in the commit body. The PR is squash-merged keeping just the subject 'feat: improve parser'. release-please bumps MINOR. Why, and what prevents it?

Order the steps

Order the steps of a commit-driven automated release (from the developer's keystroke to a tagged release):

  1. 1 Developer writes a conventional commit (feat: / fix: / feat!:) capturing the intent
  2. 2 Tool analyses commits since the last release and classifies each by type
  3. 3 Tool computes the next version: highest of PATCH (fix) / MINOR (feat) / MAJOR (break)
  4. 4 Tool generates the CHANGELOG entry from the classified commits
  5. 5 Maintainer merges the release PR → version is tagged and the release published
Recall before you leave
  1. 01
    Map fix / feat / feat! to their SemVer bumps, and explain why 'I didn't delete anything' is the wrong test for whether a change is breaking.
  2. 02
    Contrast changesets and release-please, and describe the squash-merge failure they each survive or fall to.
Recap

A version number is a contract aimed at the consumer, not a log of effort: MAJOR means you made a backward-incompatible change, MINOR a backward-compatible addition, PATCH a backward-compatible fix — and the senior point is that “break” means any consumer-observable contract change, including changed behaviour that correct existing callers relied on, not just deleted code. Remember the corners: 0.y.z is initial development where nothing is promised, and -rc.1 / +build extend the format for previews. Conventional commits make that intent machine-readable — fix: → PATCH, feat: → MINOR, feat!: or a BREAKING CHANGE: footer → MAJOR — so a tool can compute the bump and generate the changelog instead of a human guessing. changesets captures intent explicitly in a reviewed .changeset/ file per PR, which is monorepo-aware and survives squash-merge; release-please infers it from commits and maintains a release PR, which is frictionless but only as correct as the commit labels. The incident the whole unit guards against is an unmarked break shipping as a MINOR — auto-upgrading every caret-pinned consumer into it — and its amplifier, a squash-merge that drops a body-only BREAKING CHANGE: footer. Put the break marker in the subject line, enforce it in CI, and generate the changelog from the same signal so it never drifts from what actually shipped. Now when you see a feat: commit touching error-handling behaviour, you’ll pause and ask whether existing callers depended on the old behaviour — because that’s exactly how an unmarked MAJOR ships as MINOR.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.