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