Commit messages that cut releases
A version number is a promise to everyone who depends on you. Write commits the way Conventional Commits asks, let release-please turn them into versions and changelogs - and watch one wrong word ship a breaking change as a patch.
A version number looks like decoration. It’s a promise. Semantic Versioning spells it out: bump the MAJOR version “when you make incompatible API changes”, the MINOR version “when you add functionality in a backward compatible manner”, and the PATCH version “when you make backward compatible bug fixes”.
People’s tools act on that promise without asking. When you npm install a package, npm writes the dependency with a ^ in front by default. And ^1.4.2 means >=1.4.2 <2.0.0: every minor and patch release is accepted automatically, on the next install, by every project that depends on you. A major release is the only kind nobody gets by accident.
So the question that matters at release time is: is anything in here breaking? Answering it by hand, for every release, from memory, is how breaking changes ship as patches.
Say it in the commit
Conventional Commits moves the answer to the moment you know it best: when you write the change. Every commit message starts with a type:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
fix:patches a bug. It lines up with a PATCH release.feat:adds a feature. It lines up with a MINOR release.- A
!after the type or scope, or aBREAKING CHANGE:footer, marks a breaking API change. It lines up with MAJOR - whatever the type. - Other types -
docs:,chore:,refactor:,test:,ci:and so on - are allowed and “have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE)”.
Two separate commits, straight from the spec’s examples. A ! in the first line:
feat(api)!: send an email to the customer when a product is shipped
A BREAKING CHANGE: footer:
feat: allow provided config object to extend other configs
BREAKING CHANGE: `extends` key in config file is now used for extending other config files
Types aren’t case-sensitive, but BREAKING CHANGE must be uppercase (BREAKING-CHANGE means the same thing). This site’s own repository is a real example: run git log --oneline in it and nearly every commit starts with a type such as feat:, fix:, docs:, chore: or ci: - the rest are merge commits.
Let a robot read them
release-please reads those messages on your main branch and keeps a release PR open: a pull request that bumps the version and adds a CHANGELOG entry for everything since the last release. Each push updates it. When you merge it, release-please tags the commit and creates a GitHub release. Publishing - to npm, a registry, an app store - stays your job, triggered by that release.
- You push commits to main as usual. Their first lines say what kind of change each one is.
- release-please reads only the messages - never the code - and works out the next version: breaking beats feat, feat beats fix.
- It keeps one release PR open and up to date: the new version number and the CHANGELOG entry, ready to review.
- Nothing is released until you merge it. Then it tags the commit and creates a GitHub release.
- Publishing stays yours: a workflow that runs on the new release does the upload.
Its defaults follow the spec: fix proposes a patch, feat a minor, and anything marked breaking a major. The releasable units are feat, fix and deps commits (the README adds docs for Java and Python), so in most projects a week of chore: and docs: commits opens no release PR at all. In the CHANGELOG, breaking changes get their own section at the top - release-please’s own changelog shows one under ### ⚠ BREAKING CHANGES - followed by Features and Bug Fixes.
Try it
Your package is at v1.4.2, and your users installed it with ^1.4.2. Make four changes, write a message for each, and merge the release PRs. The release PR below only ever reads what you type.
Since the last release (v2.3.1) main has: 'fix: handle empty carts', 'feat: CSV export', 'docs: update README'. What does the release PR propose?
The biggest bump wins: feat means MINOR, and SemVer resets the patch to 0 when the minor goes up. The docs commit has no effect on the version.
Which commit makes release-please propose a MAJOR version?
Only the ! (or a BREAKING CHANGE: footer) marks a breaking change. The type alone - even 'chore' - says nothing about compatibility, and release-please believes the message, not the diff.
Someone merges 'Fixed stuff' to main. What does release-please do with it?
A message without a type isn't something release-please can classify, so it's left out of the version and the changelog. A linter such as commitlint in CI is how teams stop these from landing in the first place.
Details that bite
- Squash merges. The release-please README highly recommends squash-merging for a linear history, and both squash and merge commits work. With a squash, the one commit that lands is the one release-please reads - so that message has to be conventional, not just the commits inside the PR.
- Overriding the number. A commit with
Release-As: 2.0.0in its body opens a release PR for exactly that version. - Before 1.0.0. SemVer says “Major version zero (0.y.z) is for initial development. Anything MAY change at any time.” By default a breaking change still takes release-please from 0.x to 1.0.0; its
bump-minor-pre-majoroption (off unless you set it) keeps breaking changes as minor bumps while you’re below 1.0.0. And note that npm’s^0.2.3only accepts0.2.x- caret ranges are already cautious below 1.0. - This site doesn’t use it. A website has no one pinning
^1.4- visitors always get the latest page. Release automation pays off when other code depends on yours: libraries, CLIs, APIs, Helm charts.
Everything above ran in your browser. Now try it in a real terminal: any Linux machine, a Mac's Terminal app, or Windows with WSL. Nothing to install beyond that.
In a GitHub repository of your own, add .github/workflows/release-please.yml, following the release-please-action README:
on:
push:
branches:
- main
permissions:
contents: write
issues: write
pull-requests: write
name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
release-type: simpleThen push a conventional commit and watch the release PR appear:
git commit --allow-empty -m "feat: first release-please release"
git pushMerge the PR it opens, then look at the new tag and GitHub release. For a monorepo or more control, the README covers the manifest files (release-please-config.json and .release-please-manifest.json).
Sources
- Conventional Commits 1.0.0 - specification and examples
- Semantic Versioning 2.0.0
- googleapis/release-please - README
- release-please - manifest releaser options (bump-minor-pre-major)
- googleapis/release-please-action - README
- conventional-changelog-conventionalcommits - default commit types and sections
- release-please's own CHANGELOG (a real "BREAKING CHANGES" section, v17.0.0)
- node-semver - caret ranges
- npm docs - save-prefix (default "^")
- commitlint
Missions and quizzes above earn their own XP. This marks the lesson done.