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 |
|---|---|---|
feat | MINOR bump | New features |
fix | PATCH bump | Bug fixes |
feat! or BREAKING CHANGE | MAJOR bump | Breaking API changes |
docs | None | Documentation only |
refactor | None | Code restructure, no behavior change |
perf | PATCH bump | Performance improvements |
test | None | Adding or fixing tests |
chore | None | Maintenance, dependency updates |
ci | None | CI/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
rulesto 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.