Back to publishing & release Automate semantic versioning Publish to the npm registry Harden the supply chain

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.

When to Use This This path fits a single-package repository (or a monorepo where one package dominates) whose team will commit to a stric 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.
When to Use This — the core idea of this section at a glance.

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.

Commit type to version bump A commit type decision routes fix to patch, feat to minor, and breaking-change markers to major; other types trigger no release. commit type parsed header feat: minor bump fix: patch bump feat! / BREAKING major bump chore / docs no release
semantic-release routes each commit type to a bump: feat to minor, fix to patch, breaking markers to major; chore and docs trigger no release.

Setup Steps

  1. Install the tooling as dev dependencies:
    npm install --save-dev semantic-release \
      @commitlint/cli @commitlint/config-conventional husky
  2. Configure commitlint in commitlint.config.js to enforce the convention:
    module.exports = { extends: ['@commitlint/config-conventional'] };
  3. Enable husky and add a commit-msg hook so malformed messages are rejected locally:
    npx husky init
    echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
  4. Configure semantic-release in .releaserc.json. Order matters — the analyzer must run before the changelog and publish plugins:
    {
      "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"
      ]
    }
    The next branch entry produces prereleases on its own dist-tag, so a feat: merged to next ships as x.y.z-next.N rather than to latest. The [skip ci] marker on the release commit prevents an infinite CI loop.
Setup Steps The next branch entry produces prereleases on its own dist-tag, so a feat: merged to next ships as x.y.z-next.N rather t Setup Steps The next branch entry produces prereleases on its own dist-tag, so a feat: merged to next ships as x.y.z-next.N rather than to latest.
Setup Steps — the core idea of this section at a glance.

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.
What Each Plugin Does What Each Plugin Does in production JavaScript package workflows. What Each Plugin Does What Each Plugin Does in production JavaScript package workflows.
What Each Plugin Does — the core idea of this section at a glance.

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:

CI Integration semantic-release is built to run in CI and refuses to run from a dirty local tree by default. CI Integration semantic-release is built to run in CI and refuses to run from a dirty local tree by default.
CI Integration — the core idea of this section at a glance.
# .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

Validation Commands Validation Commands in production JavaScript package workflows. Validation Commands Validation Commands in production JavaScript package workflows.
Validation Commands — the core idea of this section at a glance.
# 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-msg husky 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 main is always well-formed regardless of messy branch history.
  • Run semantic-release --dry-run in pull-request CI to preview the bump and catch a missing feat/fix before 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 with concurrency.
Prevention & CI Guardrails Prevention & CI Guardrails in production JavaScript package workflows. Prevention & CI Guardrails Prevention & CI Guardrails in production JavaScript package workflows.
Prevention & CI Guardrails — the core idea of this section at a glance.
  • Enforce the commit convention with commitlint in CI so malformed messages fail fast.
  • Mark breaking changes explicitly with ! or a BREAKING 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.

Commit to version How conventional commit types map to bumps. Commit Bump Signal fix: patch bug fix feat: minor new feature feat! / BREAKING major breaking change
The type prefix encodes the release intent, so the version is computed mechanically.

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.

Automation choice semantic-release versus Changesets. Model Intent from Best for semantic-release commit messages single packages Changesets intent files monorepos
Commit-driven never forgets; intent-driven curates changelogs.

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.

Easy + enforced A helper guides the format; a linter enforces it. commitizen prompt guided format commitlint hook + CI reject malformed clean history reliable releases
A commit helper plus a linter keeps the history clean enough to release from.

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.

Branch to channel Each branch maps to a release channel. main stable latest next (prerelease) opt-in channel 1.x maintenance patches
Branch configuration lets one pipeline serve stable, prerelease, and maintenance channels.
{ "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

Semantic Versioning and Release Automation