What Are Conventional Commits?

Conventional Commits is a specification for writing commit messages that are both human-readable and machine-parseable. The format is simple:

type(scope): subject

optional body

optional footer

Real examples:

feat(auth): add OAuth2 login with Google
fix(api): handle null response from payment gateway
docs: update README installation steps
feat(checkout)!: replace cart API
chore(deps): bump next from 14.0.0 to 15.0.0

The power is not just in readable history — it is in automation. Tools can parse these messages to automatically determine version bumps, generate changelogs, and publish releases. That's what makes conventional commits worth adopting as a git workflow strategies.

The Types and What They Mean

Type SemVer impact Use for
featMINOR bumpNew features
fixPATCH bumpBug fixes
feat! or BREAKING CHANGEMAJOR bumpBreaking API changes
docsNoneDocumentation only
refactorNoneCode restructure, no behavior change
perfPATCH bumpPerformance improvements
testNoneAdding or fixing tests
choreNoneMaintenance, dependency updates
ciNoneCI/CD configuration

Step 1: Enforce the Format with commitlint + Husky

Writing the right format manually is error-prone. Automate enforcement with commitlint running as a git hook via Husky.

npm install --save-dev @commitlint/cli @commitlint/config-conventional husky

Create the commitlint config:

# commitlint.config.cjs
module.exports = {
  extends: ['@commitlint/config-conventional'],
};

Set up Husky and add the commit-msg hook:

npx husky init
cat > .husky/commit-msg <<'HOOK'
npx --no -- commitlint --edit "$1"
HOOK

The local hook checks messages when Git invokes it. Developers can bypass local hooks, so validate the final merge message or commit range in CI too. If you use squash merges, check the pull request title or resulting squash message.

Step 2: Add lint-staged for Code Quality Gates

While you're setting up Husky, add lint-staged to also run linters on staged files before each commit — catching errors before they hit the repo.

npm install --save-dev lint-staged eslint prettier

Merge this fragment into package.json; do not replace the whole file. Configure ESLint and Prettier for your project before enabling these tasks:

{
  "lint-staged": {
    "*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"],
    "*.{css,md,json}": ["prettier --write"]
  }
}

Add the pre-commit hook:

echo "npx lint-staged" > .husky/pre-commit

Now your workflow is: stage changes → pre-commit hook lints and formats → commit-msg hook validates the message → commit recorded.

Step 3: Auto-generate Changelogs with git-cliff or conventional-changelog

Once your commits follow the convention, generating a changelog is trivial.

Option A: git-cliff (recommended — fast, highly configurable)

# Install
cargo install git-cliff
# or use the npm wrapper
npm install --save-dev git-cliff

# Generate CHANGELOG.md
git cliff --output CHANGELOG.md

Option B: conventional-changelog-cli

npm install --save-dev conventional-changelog-cli
npx conventional-changelog -p angular -i CHANGELOG.md -s

Step 4: Automate Releases with semantic-release

This is the payoff. semantic-release reads your conventional commits, determines the next version automatically, publishes to npm (or any registry), creates a GitHub release, and generates the changelog — all in your CI pipeline without human intervention.

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

Create .releaserc.json:

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

This release step belongs after checkout, dependency installation and your tests in an existing GitHub Actions job. Fetch full Git history and tags, use a Node version supported by your installed semantic-release version, and configure the GitHub permissions and npm authentication for your publishing method. Trusted publishing and token-based publishing have different setup requirements. First run npx semantic-release --dry-run to inspect the planned release:

- name: Release
  env:
    GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
    NPM_TOKEN: \${{ secrets.NPM_TOKEN }}
  run: npx semantic-release

From this point: merge a fix: commit to main → 1.4.3 is automatically published. Merge a feat: → 1.5.0. Merge a feat!: → 2.0.0. No manual version bumps, no forgotten changelog entries.

The Complete Workflow

Developer writes code
       ↓
git add .
       ↓
pre-commit hook → lint-staged (ESLint + Prettier)
       ↓
git commit -m "feat(auth): add magic link login"
       ↓
commit-msg hook → commitlint validates format
       ↓
Commit recorded in local branch
       ↓
git push → PR → merge to main
       ↓
CI pipeline → tests pass
       ↓
semantic-release:
  reads commits since last release
  determines: feat = MINOR bump
  publishes: v1.5.0
  generates: CHANGELOG.md update
  creates: GitHub release

Team Adoption Tips

  • Use the formatter tool first — have developers use the Conventional Commits Formatter to compose messages until the format becomes natural
  • Start with commitlint in warn mode — change rules to use level 1 (warn) instead of 2 (error) for the first week to avoid blocking developers while they learn
  • Add a CONTRIBUTING.md section — document the commit format with examples specific to your codebase
  • Consider a VSCode extension — Conventional Commits (vivaxy.vscode-conventional-commits) adds a guided commit UI to VS Code

Once the full pipeline is running, releases become a side effect of writing good commits rather than a manual process requiring a release engineer. Use the SemVer Calculator to understand which version bump applies to your next change before you write the commit.

Verify the hook before relying on it

Run the linter directly with one valid message and one invalid message. The second command should fail:

printf '%s\n' 'feat(search): add filtering' | npx --no -- commitlint
printf '%s\n' 'miscellaneous changes' | npx --no -- commitlint

Inspect .husky/commit-msg: it must contain the literal "$1" so the hook receives Git's commit-message filename when it runs. Expanding that variable while creating the file can leave a broken hook. A quoted heredoc in the setup command prevents that.

Conventional Commits defines message syntax; commitlint checks it, and semantic-release applies a configured release policy. A commit message alone does not publish anything. The preset in the example aligns the analyzer and release notes with the Conventional Commits convention; custom rules can change version behavior.

Use the feature and release branch examples for the surrounding Git steps. The message formatter helps compose a message, but does not install hooks or run CI for your repository.

Sources