Curious Pandas

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.

16 min+40 XP for finishing

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 a BREAKING 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.

Visual guideFrom commit messages to a release
From commit messages to a releaseCommits land on main with conventional messages. release-please reads them and keeps one release PR up to date with the next version and a changelog. When you merge that PR, it tags the commit and creates a GitHub release, which can trigger your own publish job.commits on mainfix: feat: feat!:release-pleasereads the messagesrelease PRversion + CHANGELOGyou merge itwhen you choosetag + releasev1.5.0 on GitHubyour publish jobnpm, images, ...on pushupdatesreviewtagstriggersFrom commit messages to a releaseCommits land on main with conventional messages. release-please reads them and keeps one release PR up to date with the next version and a changelog. When you merge that PR, it tags the commit and creates a GitHub release, which can trigger your own publish job.commits on mainfix: feat: feat!:release-pleasereads the messagesrelease PRversion + CHANGELOGyou merge itwhen you choosetag + releasev1.5.0 on GitHubyour publish jobnpm, images, ...on pushupdatesreviewtagstriggers
  1. You push commits to main as usual. Their first lines say what kind of change each one is.
  2. release-please reads only the messages - never the code - and works out the next version: breaking beats feat, feat beats fix.
  3. It keeps one release PR open and up to date: the new version number and the CHANGELOG entry, ready to review.
  4. Nothing is released until you merge it. Then it tags the commit and creates a GitHub release.
  5. 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.

A repository with release-pleasesimulated: the spec's parsing, release-please's default bumps
Commits since release 1.4.2
    Releases
    1. v1.4.2 - where you start. Users installed it with ^1.4.2.
    Check yourself+10 XP

    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?

    Check yourself+10 XP

    Which commit makes release-please propose a MAJOR version?

    Check yourself+10 XP

    Someone merges 'Fixed stuff' to main. What does release-please do with it?

    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.0 in 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-major option (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.3 only accepts 0.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.
    Do it for real · your machine+50 XP bonus

    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: simple

    Then push a conventional commit and watch the release PR appear:

    git commit --allow-empty -m "feat: first release-please release"
    git push

    Merge 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).

    Self-reported. We can't see your machine, and we don't want to. This is a bonus, never required.

    Sources

    Finished reading?

    Missions and quizzes above earn their own XP. This marks the lesson done.