For two years our library releases worked like this: someone noticed enough changes had piled up, opened the git log, guessed whether anything was breaking, picked a version number, and wrote release notes from memory. We shipped a minor release that removed a public function, and we shipped a "major" that only fixed typos. Downstream teams stopped trusting our version numbers, which defeats the point of semantic versioning.

The fix was not a new process meeting. It was making commit messages machine-readable so the version bump and the changelog fall out of the history automatically. This post is the setup we now run on a TypeScript library and two services: the commit format, enforcement at commit and pull request time, and the two release tools we have used in production, semantic-release and release-please.

The format in one screen

Conventional Commits 1.0.0 defines a small grammar on top of normal git messages:

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

[optional body]

[optional footer(s)]

Real examples from our history:

feat(client): add retry option to createClient

fix(auth): refresh token before expiry instead of after

perf(parser): avoid re-tokenising unchanged lines

feat(api)!: drop support for callback-style handlers

BREAKING CHANGE: handlers must return a Promise. Wrap old callbacks with
util.promisify before passing them to register().

The spec itself only gives meaning to two types: feat maps to a minor bump and fix to a patch bump. A ! after the type or scope, or a BREAKING CHANGE: footer, maps to a major bump. Everything else (docs, chore, refactor, test, ci, build, perf, style) comes from the widely used Angular convention and, by default, does not trigger a release. We kept that list, because every tool we looked at understands it out of the box.

Two details matter more than they look:

  • The description is what appears in the changelog. We write it for the person reading release notes, not for the reviewer: "add retry option to createClient", not "address review comments".
  • Scopes should be a short fixed list that matches your packages or modules. A free-form scope turns the changelog into noise.

Enforce it at commit time with commitlint

A convention nobody checks decays within a month. commitlint parses the message and fails on anything that does not match. With commitlint 19, which is ESM-only, the config is:

npm install --save-dev @commitlint/cli@19 @commitlint/config-conventional@19 husky@9
npx husky init
// commitlint.config.js
export default {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "scope-enum": [2, "always", ["api", "auth", "client", "parser", "deps", "ci"]],
    "subject-case": [2, "never", ["start-case", "pascal-case", "upper-case"]],
    "body-max-line-length": [1, "always", 100],
  },
};

If your package.json does not set "type": "module", name the file commitlint.config.mjs. The husky hook is one line:

echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg

A bad message now fails locally:

$ git commit -m "Fixed the login thing"
input: Fixed the login thing
   subject may not be empty [subject-empty]
   type may not be empty [type-empty]

   found 2 problems, 0 warnings
husky - commit-msg script failed (code 1)

Local hooks are a convenience, not a gate. Anyone can skip them with --no-verify, and commits made in the GitHub web editor never run them. The real gate is in CI.

Enforce it in CI, and decide what to do about squash merges

We squash-merge pull requests, which means the commit that lands on main takes its message from the pull request title, not from the individual commits. So we lint the PR title, which is what release tooling will actually read:

# .github/workflows/pr-title.yml
name: pr-title
on:
  pull_request_target:
    types: [opened, edited, synchronize, reopened]
permissions:
  pull-requests: read
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: amannn/action-semantic-pull-request@v5
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          scopes: |
            api
            auth
            client
            parser
            deps
            ci
          requireScope: false

The action only reads the title through the API and never checks out the PR's code, which is why pull_request_target is acceptable here. Do not add a checkout step to this workflow.

If your team uses merge commits or rebase-merges instead, lint every commit in the range in CI:

npx commitlint --from "origin/${GITHUB_BASE_REF}" --to HEAD --verbose

In GitHub repository settings we also set the default squash commit message to "Pull request title and description", so a BREAKING CHANGE: footer in the PR description survives the squash.

Option A: semantic-release, fully automatic

semantic-release runs in CI on every push to the release branch. It reads the commits since the last tag, computes the next version, generates notes, tags, publishes to npm and creates a GitHub release, with no human step. Our .releaserc.json with semantic-release 24:

{
  "branches": ["main", { "name": "next", "prerelease": true }],
  "plugins": [
    ["@semantic-release/commit-analyzer", { "preset": "conventionalcommits" }],
    ["@semantic-release/release-notes-generator", { "preset": "conventionalcommits" }],
    ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }],
    "@semantic-release/npm",
    ["@semantic-release/git", {
      "assets": ["CHANGELOG.md", "package.json", "package-lock.json"],
      "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
    }],
    "@semantic-release/github"
  ]
}

The conventionalcommits preset needs its own package:

npm install --save-dev semantic-release@24 conventional-changelog-conventionalcommits \
  @semantic-release/changelog @semantic-release/git

And the workflow:

# .github/workflows/release.yml
name: release
on:
  push:
    branches: [main, next]
permissions:
  contents: write
  issues: write
  pull-requests: write
  id-token: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

fetch-depth: 0 is required. With the default shallow clone semantic-release cannot see earlier tags and will decide this is the first release. Run npx semantic-release --dry-run locally first; it prints the version it would publish and the notes, without tagging anything.

The @semantic-release/changelog and @semantic-release/git plugins commit the changelog back to main. The semantic-release maintainers recommend against that for most projects, since the GitHub release already holds the notes, and pushing back to a protected branch needs a token that can bypass protection. We kept it for one library whose users read CHANGELOG.md on npm, and removed it everywhere else.

Option B: release-please, a release pull request

release-please, from Google, takes a gentler approach. On each push to main it opens or updates a "release PR" that contains the version bump and the changelog entry. Nothing is released until a human merges that PR. We prefer this for services, where we want to choose when a deploy tag gets cut.

# .github/workflows/release-please.yml
name: release-please
on:
  push:
    branches: [main]
permissions:
  contents: write
  pull-requests: write
jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json

The action moved from the google-github-actions organisation to googleapis with v4; old references still redirect, but update them. The manifest config:

{
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
  "packages": {
    ".": {
      "release-type": "node",
      "bump-minor-pre-major": true,
      "changelog-sections": [
        { "type": "feat", "section": "Features" },
        { "type": "fix", "section": "Bug Fixes" },
        { "type": "perf", "section": "Performance" },
        { "type": "revert", "section": "Reverts" },
        { "type": "docs", "section": "Documentation", "hidden": true }
      ]
    }
  }
}
{ ".": "1.4.2" }

bump-minor-pre-major matters for 0.x packages: without it, a breaking change on 0.9.0 jumps straight to 1.0.0. For monorepos, add one entry per package path under packages and in the manifest; release-please then opens per-package bumps in one PR. One caveat: pull requests created with the default GITHUB_TOKEN do not trigger other workflows, so CI will not run on the release PR unless you pass a GitHub App token or a fine-grained token via the token input.

Choosing between them

Question semantic-release release-please
Who decides when to release? Every qualifying push releases A human merges the release PR
Where is the changelog? GitHub release (optionally a file) CHANGELOG.md in the release PR
Monorepos Needs community plugins Built in via manifest
Ecosystems npm first, plugins for others Node, Python, Java, Go, Rust, Helm, generic
Failure mode Bad commit type ships a wrong version immediately Wrong bump is visible in the PR and fixable before merge

Common mistakes

Mistake Why it hurts Do this instead
Linting commits but squash-merging The landed message is the PR title, which nobody checked Lint PR titles in CI
Shallow clone in the release job Tool cannot find previous tags, recomputes from scratch fetch-depth: 0
chore: used for user-visible changes The change is left out of release notes and no version bump happens Use feat or fix when users notice it
Breaking change hidden in the body Analyzer only reads ! or the BREAKING CHANGE: footer Put ! in the header and explain in the footer
Free-form scopes Changelog grouped by typos Enforce scope-enum

Key takeaways

  • Conventional Commits turn version bumps into a function of history: fix is patch, feat is minor, ! or BREAKING CHANGE: is major.
  • Enforce the format where it lands on the release branch: PR titles for squash merges, commit ranges otherwise. Local hooks are optional comfort.
  • semantic-release fits libraries that should publish on every merge; release-please fits services and monorepos where a human chooses the moment.
  • Write commit descriptions for the changelog reader, because that is where they end up.
  • Always dry-run the release tool on a branch before turning it on.

References