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.

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.

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

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

Semantic Versioning and Release Automation