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
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.
feat(search): find frogs by locationfeatscope: searchspec: minor bumpchangelog: Features
Valid under the spec and commitlint’s default config.
fix: stop the map jumping on zoomfixspec: patch bumpchangelog: Bug Fixes
Valid under the spec and commitlint’s default config.
feat!: drop the v1 frogs endpointfeatbreakingspec: major bumpchangelog: BREAKING CHANGES
Valid under the spec and commitlint’s default config.
chore: bump eslintchorespec: no version meaningchangelog: Miscellaneous Chores (hidden)
Valid under the spec and commitlint’s default config.
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: valueform.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: #42For 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.
| Type | Use it for | Version bump | Changelog section |
|---|---|---|---|
feat | A new feature someone can use | minor | Features |
fix | A bug fix | patch | Bug Fixes |
perf | Faster, same behaviour | none (tools: patch) | Performance Improvements |
revert | Undoes an earlier commit | none | Reverts |
docs | Documentation only | none | hidden |
refactor | Code change, no behaviour change | none | hidden |
test | Tests only | none | hidden |
build | Build system or dependencies | none | hidden |
ci | CI configuration | none | hidden |
style | Formatting, whitespace | none | hidden |
chore | Anything else that ships nothing | none | hidden |
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 tag | release-please | semantic-release (no config) |
|---|---|---|
| Any breaking change | major | major, but only from a footer |
| feat, no breaking | minor | minor |
| fix or perf only | patch | patch |
| revert only | patch | no release |
| chore, docs, ci, test only | no release PR | no 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
| Commit | Verdict |
|---|---|
fix(search): return results for one-letter queries | Good. Scope, lower case, says what changed. |
feat: add CSV export to reports | Good. Minor release, listed under Features. |
feat:add CSV export | Spec error: no space after the colon. release-please still reads it; commitlint and semantic-release don’t. |
Fix: Login button | commitlint fails it on both capitals: the type and the description. |
fix: stuff | Valid, and useless in a changelog. The tools can’t fix vague. |
chore: rewrite the billing engine | Valid, 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.