| name | conventional-changelog |
| description | Reference for Conventional Commits and automatic CHANGELOG generation. Compares release-please / changesets / git-cliff / towncrier and covers commit format, Keep a Changelog, and semver tag practices. Use when setting up or unifying a release flow. |
Conventional Changelog
A guide to writing commit messages following the Conventional Commits spec and mechanically generating CHANGELOGs and semver tags from them. Pick tools across npm / cargo / python / rust / go and avoid writing CHANGELOGs by hand.
When to use
- Setting up a release flow on a new repo
- Commit messages on an existing repo are inconsistent and you want to introduce CHANGELOG generation
- You tried release-please but it did not fit / you want to evaluate alternatives
- Looking for a changelog tool suited to the language (Rust / Python / Go, etc.)
- Tags and CHANGELOGs keep breaking under manual operation, and you want to automate them
Conventional Commits format
Form: <type>[optional scope]: <subject>
feat(api): add rate-limit middleware
fix(auth): reject expired tokens before DB lookup
docs: clarify OAuth flow diagram
chore: bump deps
List of types and semver impact
| type | semver bump | Included in CHANGELOG |
|---|
feat | minor | ✓ (Added / Features) |
fix | patch | ✓ (Fixed) |
perf | patch | ✓ (Changed) |
refactor | none | × (default) |
docs | none | × |
style | none | × |
test | none | × |
build | none | × |
ci | none | × |
chore | none | × |
revert | depends on the reverted commit | ✓ |
Breaking change (major bump)
Two ways: put BREAKING CHANGE: in the footer, or a ! after the type:
feat(api)!: replace REST endpoint with GraphQL
BREAKING CHANGE: /api/v1/users is removed. Use GraphQL Query.user instead.
Writing both is polite (obvious from the title + detail in the footer).
scope
Optional, but including it makes the CHANGELOG easier to read. In a monorepo, use the package name as the scope:
feat(@pkg/auth): add oauth2 flow
feat(@pkg/billing): stripe migration
release-please also auto-detects the "target package of the change" from the scope (in manifest mode).
subject
- Start lowercase (no trailing period)
- Imperative mood ("add X" / "fix Y"; avoid "added" or "fixes")
- Aim for 50 characters or fewer
body / footer
Body after a blank line, then another blank line before the footer (Closes #123, Co-authored-by: ..., BREAKING CHANGE: ...).
CHANGELOG.md format (Keep a Changelog)
Follows keepachangelog.com. Versions in descending order, semver, and a fixed set of 6 sections:
# Changelog
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- foo bar
## [1.2.0] - 2026-04-19
### Added
- New `--json` output flag for `foo scan` ([#42](https://github.com/owner/repo/pull/42))
### Changed
- `parse_config` now accepts YAML in addition to TOML
### Fixed
- Crash when config file is empty ([#40](https://github.com/owner/repo/issues/40))
### Deprecated
- `--legacy-mode` flag. Use `--mode=legacy` instead.
### Removed
- `foo bar --old-flag` (deprecated in 1.0.0)
### Security
- Upgrade `openssl` to 3.2.0 to address CVE-2025-XXXXX
## [1.1.0] - 2026-03-01
...
[Unreleased]: https://github.com/owner/repo/compare/v1.2.0...HEAD
[1.2.0]: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
The 6 sections:
Added: new features
Changed: changes to existing functionality
Deprecated: scheduled for removal (still present)
Removed: already removed
Fixed: bug fixes
Security: vulnerability fixes
semver tag practices
- Tags in
v<MAJOR>.<MINOR>.<PATCH> form (v1.2.3). The v prefix is the norm
- Pre-releases:
v1.0.0-beta.1 / v1.0.0-rc.1
git tag -a v1.2.3 -m "Release v1.2.3" (annotated tag) → git push origin v1.2.3
- Wire up tag creation and deployment via GitHub Release + Actions with
on: release
Tool comparison
| Tool | Supported languages | Style | CHANGELOG generation | version bump | Main strengths | Weaknesses |
|---|
| release-please | npm / python / rust / go / java / php / ruby | Auto-creates a Release PR | ✓ | ✓ (tag on PR merge) | Monorepo support, stable Google-maintained project | PR-based with a learning curve |
| changesets | npm only | Manual entries via changeset add | ✓ | ✓ | Monorepo + flexibility (npm workspaces) | Not commit-driven |
| conventional-changelog-cli | Language-agnostic (command) | One-shot generation from commits | ✓ | × | Lightweight, appends to existing CHANGELOG | Tags / bumps handled separately by hand |
| standard-version (deprecated) | npm | commit → bump + CHANGELOG | ✓ | ✓ | Simple | Maintenance halted; migration to release-please recommended |
| git-cliff | Language-agnostic (written in Rust) | Highly customizable via TOML config | ✓ | × | Fast, flexible templates, popular in the Rust ecosystem | Tags / bumps separate |
| towncrier | Python | Write a news fragment per PR | ✓ | × | Fragments avoid merge conflicts | Idiosyncratic workflow |
| auto (intuit/auto) | Multiple | Label-based + CI integration | ✓ | ✓ | Driven by GitHub labels | Custom label conventions |
One-line recommendations
- Want to automate a single npm package → release-please
- npm monorepo → release-please (manifest mode) or changesets (for fine-grained control)
- Rust → git-cliff
- Python → towncrier (or release-please)
- Go / generic → git-cliff or release-please
- Just want to generate a CHANGELOG (manual tag management) → conventional-changelog-cli or git-cliff
release-please setup (most recommended path)
Fresh setup on an npm project:
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "node",
"packages": {
".": { "package-name": "my-pkg", "changelog-path": "CHANGELOG.md" }
},
"include-v-in-tag": true,
"bump-minor-pre-major": true
}
{ ".": "0.0.0" }
name: release-please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
Write commits following the Conventional Commits spec and push to main; a "Release PR" is created automatically. Merging it triggers the tag + GitHub Release + CHANGELOG update.
Two forms of publish-linked workflow:
| Form | trigger | Strengths | Weaknesses |
|---|
Job separation within the same workflow (needs: release-please + if: outputs.release_created) | push: main | Self-contained in one file, directly references release-please outputs, easy to grasp | If the release-please job fails, publish is dragged down with it; responsibilities get mixed |
Separate workflow (on: release) | release: { types: [published] } | Separation of concerns, publish runs even for manual releases, re-runs are independent | secrets / permissions must be declared in two places; release-please outputs are unavailable |
Selection guidance: For a single package with a single publish target (npm only), a single workflow is concise. If there are multiple publish targets (npm + GitHub Container Registry etc.) or manual releases are also allowed, use a separate on: release workflow. When using OIDC Trusted Publishing, attaching id-token: write only to the publish job is sufficient, and either form works.
See the publish flow in the npm-release skill (locally managed) for details.
Pre-release (beta / rc) staged releases
When you want to ship a v2.0.0 with breaking changes in the order beta → rc → stable. Use release-please's prerelease / prerelease-type flags:
{
"packages": {
".": {
"package-name": "my-pkg",
"prerelease": true,
"prerelease-type": "beta"
}
}
}
Flow:
- Stack commits with
prerelease-type: "beta" → v2.0.0-beta.1 is cut
- More commits stack up as
v2.0.0-beta.2 / beta.3 ...
- Promote to RC: rewrite config to
"prerelease-type": "rc" → v2.0.0-rc.1
- Stable release:
"prerelease": false (or remove the field) → v2.0.0
How to write the CHANGELOG: release-please finalizes an independent section for each beta/rc (it is not the Keep a Changelog style of accumulating into [Unreleased]). [2.0.0-beta.1] / [2.0.0-beta.2] / [2.0.0-rc.1] / [2.0.0] all remain. The 2.0.0 stable section aggregates every feat! / BREAKING CHANGE from the period.
Monorepo + workspace:* interaction
In a pnpm workspace with @org/core, @org/ui, @org/cli where @org/ui and @org/cli depend on @org/core:
{
"plugins": [
{ "type": "node-workspace", "updatePeerDependencies": true }
],
"packages": {
"packages/core": {
"release-type": "node",
"package-name": "@org/core",
"include-component-in-tag": true
},
"packages/ui": {
"release-type": "node",
"package-name": "@org/ui",
"include-component-in-tag": true
},
"packages/cli": {
include-component-in-tag: true → tag names include the component name, like @org/core-v2.0.0 (avoids monorepo collisions)
node-workspace plugin → detects a bump on @org/core, rewrites dependencies."@org/core" in @org/ui / @org/cli, and adds both as patch bumps to the PR
updatePeerDependencies: true → rewrites peer dependencies the same way (needed on the library side)
separate-pull-requests: false → consolidates bumps for all packages into a single Release PR
Include the package name in the commit scope:
feat(@org/core): add streaming parser
fix(@org/ui): button focus ring on safari
feat(@org/cli)!: rename --config to --profile
The scope automatically determines the target of the change. An entry is written only into the CHANGELOG of the relevant package.
How workspace:* substitution works: Even if you write "@org/core": "workspace:*" in @org/ui/package.json, pnpm automatically substitutes it with ^<actual version> at publish time. release-please leaves the source as workspace:* during bumps without touching it. This assumption lines up perfectly (on the pnpm side, workspace:^ → ^same-major, workspace:~ → ~same-minor).
git-cliff setup (Rust / generic)
cargo install git-cliff
brew install git-cliff
[changelog]
header = "# Changelog\n"
body = """
{% if version %}## [{{ version }}] - {{ timestamp | date(format="%Y-%m-%d") }}{% endif %}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
{% endfor %}
{% endfor %}
"""
[git]
conventional_commits = true
filter_unconventional = true
protect_breaking_commits = true
commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^perf", group = "Changed" },
{ message = "^refactor", group = "Changed" },
{ message = "^deprecat", group = "Deprecated" },
{ message = "^remove", group = "Removed" },
{ message = "^security", group = "Security" },
{ message = "^revert", group = "Reverted" },
{ message = "^(docs|style|test|build|ci|chore)", skip = true },
{ message = ".*", skip = true },
]
tag_pattern = "v[0-9]*"
sort_commits = "newest"
For Rust projects used alongside cargo-release, cargo-release handles the version bump in Cargo.toml and tagging, while git-cliff focuses solely on CHANGELOG generation (loosely coupled). This is also why you would not layer a PR-based automatic bump like release-please on top.
git-cliff --output CHANGELOG.md
git-cliff --unreleased --prepend CHANGELOG.md
git-cliff --tag v1.2.0 --output CHANGELOG.md
Wiring with cargo-release (concrete example)
Put hooks into [package.metadata.release] in Cargo.toml. Running cargo release <level> runs bump → CHANGELOG generation → commit → tag → push in sequence:
[package.metadata.release]
pre-release-hook = ["git", "cliff", "--tag", "v{{version}}", "--unreleased", "--prepend", "CHANGELOG.md"]
tag-name = "v{{version}}"
pre-release-commit-message = "chore(release): v{{version}}"
Order:
cargo release minor --execute → version bump in Cargo.toml
pre-release-hook runs git cliff to finalize the next version's [Unreleased] as [v0.2.0] - 2026-04-19 and update CHANGELOG.md
- cargo-release bundles
Cargo.toml + CHANGELOG.md into the release commit
tag-name creates v0.2.0 as an annotated tag
cargo publish + git push --tags
Note: The [changelog.bump] section on the git-cliff side (features_always_bump_minor etc.) is a git-cliff-specific feature where git-cliff itself decides the bump; do not use it when combined with cargo-release (the bump decision belongs to cargo-release). <level> (patch / minor / major) is chosen by a human.
conventional-changelog-cli (lightweight, language-agnostic)
npm install -g conventional-changelog-cli
conventional-changelog -p angular -i CHANGELOG.md -s -r 0
The main preset is angular (the origin of Conventional Commits). Well-suited to appending to an existing CHANGELOG.
Validating commit messages with pre-commit
Reject commits that violate the Conventional Commits spec at commit time:
commitlint
npm install -D @commitlint/cli @commitlint/config-conventional
module.exports = { extends: ['@commitlint/config-conventional'] };
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.16.0
hooks:
- id: commitlint
stages: [commit-msg]
additional_dependencies: ['@commitlint/config-conventional']
Pitfall: Without additional_dependencies, the hook cannot resolve config-conventional at run time and fails with Cannot find module. Just extends in commitlint.config.js is not enough; you must also declare it explicitly in the hook's isolated environment.
In the mizchi-default pkfire ecosystem, commitlint is wired into a Taskfile.pkl task instead — see the chezmoi-management and pkfire skills.
commitizen (interactive commit creation)
npm install -D commitizen cz-conventional-changelog
"config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } }
git cz
Common failures
- Introducing the commit convention partway through: existing history stays in the broken format. release-please ignores "non-conforming commits" during its scan. No retroactive rewrite is needed; just align from now on
- Using
chore: for everything so no release PR opens: writing chore: for a bug fix omits it from the CHANGELOG / does not bump the version. Pick the type by "kind of change", not by importance (fix is a user-visible fix, chore is internal housekeeping)
- Writing BREAKING CHANGE in the subject and omitting the footer: release-please looks at the
BREAKING CHANGE: in the footer or the ! on the type. Writing "breaking: ..." in the subject will not trigger a major bump
- Mixing manual CHANGELOG edits with the tool: manually editing a CHANGELOG managed by release-please causes drift on the next generation. Add entries only via PRs (use release-please's "Edit" feature)
- Creating a tag first and then opening a release-please PR: release-please creates the tag on PR merge. A manual tag conflicts with it. For manual operation, standardize on conventional-changelog-cli / git-cliff + manual tagging
Red flags
| Thought that comes up | Reality |
|---|
| "Writing the CHANGELOG by hand is more polished" | Generating from commits misses nothing. Handwriting inevitably produces omissions and inconsistent wording |
| "Create the tag first, then write the changelog" | The order is backwards. Stick to commit → tool generates CHANGELOG → tag |
| "Breaking changes are clear if written in the subject" | Tools mechanically read the footer / !. Natural-language phrasing in the subject is ignored |
| "Let's add project-specific types" | You can express things within feat / fix / chore. Custom types are not supported by the tools' presets |
| "Let's fix non-conforming commits via rebase" | Retroactive rewrites pollute history. Just align from now on; the tool will ignore the rest |
Related