Guide + free tool

Conventional commits, and what your release tool does with them

The format, every type, how breaking changes work, and a checker below that reads your actual git log and tells you which version release-please and semantic-release will cut from it. They don’t always agree. That surprised me too.

Definition

What conventional commits are

Conventional Commits is a spec for the first line of a commit message: a type, an optional scope, a colon and a space, then a short description. Something like fix(auth): stop the double submit. The type says what kind of change it is, and three of them carry meaning for your version number. fix is a patch release, feat is a minor release, and anything marked breaking is a major release. That mapping is the whole point. Once every commit says what it is, a tool can read your history, work out the next semantic version, and write the changelog for you. The current version of the spec is 1.0.0, and it is short enough to read in five minutes at conventionalcommits.org. This page is what I wish it had said about the tools.

Free tool

Check your commits

Paste the output of git log --oneline since your last tag, or one full commit message. You get each commit checked against the spec and against commitlint’s default rules, and the version each release tool would cut. The sample is set up to show the two tools disagreeing, so look at that first.

Commit checker

5 commits, 1 with errors. Nothing leaves your browser.

release-please

2.0.0 major

A breaking change.

semantic-release, default config

1.5.0 minor

At least one feat. A "!" commit was skipped.

  1. feat(search): find frogs by location

    featscope: searchspec: minor bumpchangelog: Features

    Valid under the spec and commitlint’s default config.

  2. fix: stop the map jumping on zoom

    fixspec: patch bumpchangelog: Bug Fixes

    Valid under the spec and commitlint’s default config.

  3. feat!: drop the v1 frogs endpoint

    featbreakingspec: major bumpchangelog: BREAKING CHANGES

    Valid under the spec and commitlint’s default config.

  4. chore: bump eslint

    chorespec: no version meaningchangelog: Miscellaneous Chores (hidden)

    Valid under the spec and commitlint’s default config.

  5. docs: Fix typo in README.

    docsspec: no version meaningchangelog: Documentation (hidden)

    • commitlint · subject-case commitlint rejects a description that starts with a capital letter. Lower-case the first word.
    • commitlint · subject-full-stop No full stop at the end of the description.

This tells you what the release tools will do with your commits. Writing the post about the release is a separate job, and that one I automated.

The rules

The format

<type>[optional scope][!]: <description>

[optional body]

[optional footer(s)]
  • The type comes first, then an optional scope in parentheses, then an optional !, then a colon and exactly one space. Drop the space and the spec, commitlint and semantic-release all stop reading the commit.
  • The description is short and says what changed. commitlint’s default config also wants it to start lower case and not end with a full stop, and caps the whole first line at 100 characters.
  • The body is optional and starts after one blank line. Say why, not what.
  • Footers go last, after another blank line, in Token: value form. Refs: #42, Reviewed-by: Sam, and the one that matters, BREAKING CHANGE: ....

A full message using all of it:

feat(api)!: return frogs sorted by name

Sorting used to follow insertion order, which nobody expected
and three people filed bugs about.

BREAKING CHANGE: clients that relied on insertion order must sort.
Refs: #42

For the one-screen version to keep open while you type, there is a cheat sheet.

Reference

Commit types

The spec only defines feat and fix. Everything else comes from the Angular convention the spec grew out of, and it’s the list commitlint enforces by default. The last column is where each one lands in the changelog release-please writes. “Hidden” means it’s parsed and then left out, and a release made only of hidden types doesn’t happen at all.

TypeUse it forVersion bumpChangelog section
featA new feature someone can useminorFeatures
fixA bug fixpatchBug Fixes
perfFaster, same behaviournone (tools: patch)Performance Improvements
revertUndoes an earlier commitnoneReverts
docsDocumentation onlynonehidden
refactorCode change, no behaviour changenonehidden
testTests onlynonehidden
buildBuild system or dependenciesnonehidden
ciCI configurationnonehidden
styleFormatting, whitespacenonehidden
choreAnything else that ships nothingnonehidden

You can invent types, and the spec is fine with it. commitlint’s default config is not: wip: or feature: fails its type-enum rule. Pick the list above unless you have a reason, and if you have a reason, write it into the commitlint config so the linter knows too.

The one that bites

Breaking changes

There are two ways to mark one. A ! before the colon, as in feat!: drop node 18, or a footer that starts with BREAKING CHANGE:. The spec treats them the same, and it also accepts BREAKING-CHANGE with a hyphen. The footer has to be in capitals. A lower-case breaking change: is not breaking under the spec.

Here’s the one I didn’t expect. semantic-release, with no config, parses commits with the older Angular preset, and that preset has no idea what ! means. So feat!: drop node 18 doesn’t just fail to bump the major version. The commit doesn’t parse, and semantic-release releases nothing at all. I ran it to be sure: a feat!: commit on 1.0.0, default config, no release. The same commit with the conventionalcommits preset turned on gave 2.0.0. The fix is two lines of config, and the semantic-release guide has them.

The same preset is also why a lower-case breaking change: footer does trigger a major release in semantic-release’s default setup while release-please ignores it. If you write the footer in capitals and use the conventionalcommits preset everywhere, the tools agree.

The payoff

From commits to version numbers

A release tool looks at every commit since the last tag and takes the biggest bump it finds. One breaking change among forty fixes is still a major release. Where the tools differ is everything that isn’t feat, fix or breaking:

Commits since the last tagrelease-pleasesemantic-release (no config)
Any breaking changemajormajor, but only from a footer
feat, no breakingminorminor
fix or perf onlypatchpatch
revert onlypatchno release
chore, docs, ci, test onlyno release PRno release

Below 1.0.0, release-please has an option, bump-minor-pre-major, that makes breaking changes bump the minor number instead, since everything before 1.0.0 is allowed to break anyway. It’s off by default, which is how people end up at 1.0.0 the first time they write feat!. Whether that’s fine is a semver question, and the semantic versioning page covers it.

Calibration

Good and bad examples

CommitVerdict
fix(search): return results for one-letter queriesGood. Scope, lower case, says what changed.
feat: add CSV export to reportsGood. Minor release, listed under Features.
feat:add CSV exportSpec error: no space after the colon. release-please still reads it; commitlint and semantic-release don’t.
Fix: Login buttoncommitlint fails it on both capitals: the type and the description.
fix: stuffValid, and useless in a changelog. The tools can’t fix vague.
chore: rewrite the billing engineValid, and a lie. It’s hidden from the changelog, and on its own it triggers no release.

Where it goes wrong on GitHub

Squash merges and PR titles

If you squash merge, the PR title becomes the commit on your main branch, so the PR title is the conventional commit. That’s the easiest place to enforce the format, and it means contributors can write whatever they like on their branch.

If you use GitHub’s merge commits, watch out. I pointed release-please at a test repo with one merged PR, and the fix showed up twice in the changelog. GitHub writes the PR title into the body of the merge commit, and release-please reads every conventional line in a commit body as another commit. So it counted the branch commit and the merge commit. Squash merging makes it go away.

The ecosystem

Tools that read these commits

  • commitlint rejects commits that break the format, usually from a git hook or CI.
  • Commitizen asks you questions and writes the commit for you.
  • release-please keeps a release PR open with the next version and changelog. Merge it to release.
  • semantic-release releases on every push that warrants one. No PR in between.
  • Changesets is the exception. It ignores commit messages and has you write a changeset file per change instead.
  • git-cliff turns the history into a changelog and nothing else.
  • standard-version did the local version of all this and is deprecated. There’s a migration guide.

If you’re choosing, the short version: release-please if you want a human to press merge on each release, semantic-release if you don’t, Changesets for a monorepo of npm packages where contributors should describe their own changes. And there’s a comparison of changelog generators if a changelog is all you want.

Honesty

What this doesn’t fix

Conventional commits get you a correct version number and a changelog that writes itself. They don’t get anyone to read it. The changelog is written for people who already use the thing and went looking. Everyone else finds out you shipped when you tell them, and a commit message is the wrong voice for that: feat(search): find frogs by location is a perfect commit and a terrible post.

That gap is the one I built Merge & Tell for. It reads each merged pull request, the diff and not just the title, and drafts the post for each network. I read every one before it goes out. The updates page is that running on this product.

The changelog is written. The announcement isn't.

You were going to merge the PR anyway.

Conventional commits: types, examples and a checker