Configuring Conventional Commits and semantic-release
Wire up Conventional Commits, enforce the format with commitlint and husky, and let semantic-release infer the version, write the changelog, tag, and publish — fully unattended — from your commit history alone.
When to Use This
This path fits a single-package repository (or a monorepo where one package dominates) whose team will commit to a strict message format. The payoff is a release with zero manual steps: merge to main, and the next version is computed and published automatically. If you instead want each change's release intent reviewed in a pull request, or you run a monorepo with many independently-versioned packages, prefer the intent-file approach in Automating Releases with Changesets.
The Conventional Commits Spec
A Conventional Commit message has a structured header and optional body and footers:
():
The type is what drives the version bump. The mapping is fixed and is the same semantic-versioning contract described in Semantic Versioning and Release Automation:
| Commit | Resulting bump |
|---|---|
fix: correct off-by-one in parser |
patch |
feat: add retry option |
minor |
feat!: drop Node 16 support |
major (the !) |
feat: add x + BREAKING CHANGE: footer |
major |
docs:, chore:, test:, refactor:, style:, ci: |
none (no release) |
A breaking change is signaled either by a ! after the type/scope or by a BREAKING CHANGE: footer; both force a major bump regardless of the type. Commits that produce no release (chore, docs, and so on) are still recorded but do not trigger a publish.
Setup Steps
- Install the tooling as dev dependencies:
npm install --save-dev semantic-release \ @commitlint/cli @commitlint/config-conventional husky - Configure commitlint in
commitlint.config.jsto enforce the convention:module.exports = { extends: ['@commitlint/config-conventional'] }; - Enable husky and add a
commit-msghook so malformed messages are rejected locally:npx husky init echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg - Configure semantic-release in
.releaserc.json. Order matters — the analyzer must run before the changelog and publish plugins:
The{ "branches": ["main", { "name": "next", "prerelease": true }], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }], "@semantic-release/npm", ["@semantic-release/git", { "assets": ["CHANGELOG.md", "package.json"], "message": "chore(release): ${nextRelease.version} [skip ci]" }], "@semantic-release/github" ] }nextbranch entry produces prereleases on its own dist-tag, so afeat:merged tonextships asx.y.z-next.Nrather than tolatest. The[skip ci]marker on the release commit prevents an infinite CI loop.
What Each Plugin Does
| Plugin | Role |
|---|---|
commit-analyzer |
Parses commits, decides the bump (or no release). |
release-notes-generator |
Builds the release notes from commit subjects. |
changelog |
Writes/updates CHANGELOG.md. |
npm |
Sets the version and runs npm publish. |
git |
Commits the changelog and version, creates the tag. |
github |
Creates the GitHub Release and uploads notes. |
CI Integration
semantic-release is built to run in CI and refuses to run from a dirty local tree by default. The workflow installs from a frozen lockfile, builds, then runs the release on a push to main:
# .github/workflows/release.yml
name: release
on:
push:
branches: [main, next]
concurrency: release-${{ github.ref }}
permissions:
contents: write # push version commit + tag, create the release
issues: write # comment on released issues/PRs
id-token: write # OIDC for npm provenance
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history is required to analyze commits
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm test
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Validation Commands
# Dry-run: see the computed next version and notes without publishing
npx semantic-release --dry-run
# Lint the most recent commit message against the convention
npx commitlint --from HEAD~1 --to HEAD --verbose
# Confirm the husky hook is installed and executable
cat .husky/commit-msg && ls -l .husky/
# After a release, verify the tag and published version match
git describe --tags --abbrev=0
npm view your-pkg version
Prevention & CI Guardrails
- Enforce the commit format in two places: the local
commit-msghusky hook and a CI commitlint job on the PR, since hooks can be bypassed with--no-verify. - Require squash-merges with a Conventional Commit title, so the merged commit on
mainis always well-formed regardless of messy branch history. - Run
semantic-release --dry-runin pull-request CI to preview the bump and catch a missingfeat/fixbefore merge. - Keep
fetch-depth: 0; commit analysis against a shallow clone silently mis-bumps or finds no release. - Add
[skip ci]to the release commit message to break the publish-triggers-CI loop, and guard the job withconcurrency.
- Enforce the commit convention with commitlint in CI so malformed messages fail fast.
- Mark breaking changes explicitly with
!or aBREAKING CHANGE:footer. - Use a commit helper (commitizen) so contributors write conventional messages easily.
- Run semantic-release only from the protected release branch, not from feature branches.
How the commit convention maps to versions
The power of conventional commits is that the message format encodes the release intent, so the version can be computed mechanically. The type prefix determines the bump: fix: yields a patch, feat: a minor, and either a ! after the type (feat!:) or a BREAKING CHANGE: footer yields a major, regardless of the type. Other types like docs:, chore:, and refactor: are non-releasing by default, so a commit that touches only documentation does not trigger a version. semantic-release reads every commit since the last release, takes the highest bump implied, and computes the next version from it.
This mapping is what makes the automation reliable — but only if the commits are honest about what they contain. A breaking change committed as fix: will ship as a patch, reaching consumers on caret ranges without warning, which is exactly the contract violation semantic versioning exists to prevent. The discipline is therefore to mark breaking changes explicitly and to choose the type that matches the actual change, not the one that feels convenient. A commit linter enforces the format, and a commit helper makes writing conventional messages easy, so the mapping from message to version stays trustworthy across every contributor.
Choosing semantic-release or Changesets
semantic-release and Changesets are the two dominant release-automation approaches, and the choice shapes the daily workflow. semantic-release infers the bump from conventional commit messages, so nothing is forgotten and the release is fully automatic, but correctness depends on commit discipline and it is happiest with a single package. Changesets asks contributors to write an explicit changeset per change, which produces curated changelogs and handles independently-versioned monorepo packages naturally, at the cost that the intent can be forgotten without a CI gate.
The practical dividing line is repository shape and changelog needs. A single package where every commit already follows a convention is well served by semantic-release's zero-extra-step automation, especially if the team values commits as the single source of release intent. A monorepo publishing several packages with changelogs a human will actually read usually prefers Changesets, with a changeset status check enforcing the intent. Neither is wrong; the decision is about whether release intent lives in commit messages or in explicit intent files, and enforcing whichever you choose — commitlint for semantic-release, a status check for Changesets — is what makes the version always derive from the changes rather than depending on someone remembering a step.
Making conventional commits easy for contributors
The reliability of a commit-driven release depends on every contributor writing well-formed conventional commits, which is a discipline that tooling can make effortless rather than a burden. A commit helper like commitizen prompts for the type, scope, and description interactively, so a contributor selects feat or fix from a menu and answers a few questions rather than remembering the exact format. This removes the most common source of malformed messages — a contributor who forgets the convention or mistypes the prefix — by turning commit authoring into a guided flow.
Enforcement completes the picture. A commitlint check, run on commit via a git hook and again in CI, rejects a message that does not follow the convention, so a malformed commit fails fast rather than shipping the wrong version. The combination — a helper that makes the right format easy and a linter that makes the wrong format impossible to merge — is what keeps the commit history clean enough for semantic-release to depend on it. Without both, a single mislabeled commit can ship a breaking change as a patch; with them, the mapping from commit to version stays trustworthy across a whole team, which is the precondition for trusting the automation to release without human version-picking.
Branch configuration and prerelease channels
semantic-release supports releasing from multiple branches, which is how it handles prerelease channels and maintenance releases. Configuring a next branch as a prerelease channel means merges there publish x.y.z-next.N versions under a next dist-tag, so a risky release can be validated by opt-in consumers before it reaches latest. A maintenance branch like 1.x can publish patches to an older major line, so you can support a previous version without holding back the main line.
{ "branches": ["main", { "name": "next", "prerelease": true }, "1.x"] }
This branch-to-channel mapping is what lets a single automated pipeline serve stable releases, prereleases, and maintenance patches from one configuration. A commit's branch determines which channel it publishes to, so the same conventional-commit analysis drives every channel — a fix: on 1.x patches the maintenance line, a feat: on next ships a prerelease minor, and a merge to main cuts the stable release. Getting the branch configuration right is what turns semantic-release from a single-channel tool into one that manages a package's whole release surface automatically.
Frequently Asked Questions
Why does semantic-release report "no release" after I merged a feature?
The merged commit's type was not one that triggers a release — likely a chore:, docs:, or an untyped message that the analyzer ignores. Only fix, feat, and breaking-change markers produce a version; check the exact commit subject on main.
How do I force a major release without a code-level breaking change?
Add a BREAKING CHANGE: footer (or a ! after the type) to a commit. The analyzer treats that as a major regardless of the type, so even a feat!: or a fix with the footer bumps the major version.
Can I use semantic-release in a monorepo? It can, but it is single-package by design and needs extra configuration per package to scope commits and tags. For multiple independently-versioned packages, the changesets approach is usually a better fit.
My release commit triggered another CI run that tried to release again — how do I stop it?
Add [skip ci] to the @semantic-release/git commit message (as in the config above) so the platform skips CI for that automated commit, and protect the job with a concurrency group to prevent overlapping runs.
How does semantic-release decide the version bump?
From the conventional commit types since the last release: fix: is a patch, feat: a minor, and a ! or BREAKING CHANGE: footer a major. It takes the highest bump implied by the commits and computes the next version, so the message format directly determines the release.
What happens if a commit doesn't follow the convention?
semantic-release may compute the wrong bump or none at all, since it reads the message to determine the release. Enforce the convention with commitlint in CI so malformed messages fail the PR, and use a commit helper so contributors write conventional messages easily.
semantic-release or Changesets — which should I use?
semantic-release (commit-driven) for a single package with commit discipline and fully automatic releases; Changesets (intent files) for monorepos with curated changelogs. Enforce whichever you choose — commitlint or a changeset status check — so the version always derives from the changes.
How do I get contributors to write conventional commits?
Use a commit helper like commitizen that prompts interactively for the type, scope, and description, and enforce the format with a commitlint git hook plus a CI check. The helper makes the right format easy and the linter makes the wrong format impossible to merge.
How do I publish prereleases with semantic-release?
Configure a prerelease branch in the branches array (e.g. { "name": "next", "prerelease": true }). Merges there publish x.y.z-next.N under a next dist-tag for opt-in consumers, while main cuts stable releases — one pipeline serving multiple channels.
Related
- Automating Releases with Changesets — the intent-file alternative, better for monorepos and reviewable releases.
- npm Registry Publishing Workflows — the authentication and access the npm plugin relies on.
- Lockfile Management Strategies — frozen installs that keep automated releases reproducible.