A guide to writing commit messages using the Conventional Commits convention and mechanically generating CHANGELOGs and semver tags from them. Choose tools across npm / cargo / python / rust / go so you don't have to write the CHANGELOG by hand.
When to use
- Set up a release flow for a new repo
- The commit messages in an existing repo are inconsistent, and you want to introduce CHANGELOG generation
- You tried release-please but it doesn't fit / want to consider another tool
- You are looking for a changelog tool that fits your language (Rust / Python / Go, etc.)
- Tags and CHANGELOG tend to break under manual operation, and you want to automate them
Conventional Commits format
Format: <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 preceding item | ✓ |
Breaking change(major bump)
Two ways: write BREAKING CHANGE: in the footer, or write !:
feat(api)!: replace REST endpoint with GraphQL
BREAKING CHANGE: /api/v1/users is removed. Use GraphQL Query.user instead.
Writing both is the most considerate (immediately clear from the title + details in the footer).
scope
Optional, but adding 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
The scope also automatically determines the "target package for changes" in release-please (manifest mode).
subject
- Start with a lowercase letter (no trailing period)
- Imperative mood ("add X" / "fix Y", avoid "added" or "fixes")
- Aim for 50 characters or fewer
body / footer
Separate with a blank line, then the body, and another blank line before the footer (Closes #123, Co-authored-by: ..., BREAKING CHANGE: ...)。
CHANGELOG.md format (Keep a Changelog)
keepachangelog.com It complies with
# 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
6 sections:
Added: New featuresChanged: Changes to existing functionalityDeprecated: Deprecations (still present)Removed: RemovedFixed: Bug fixesSecurity: Vulnerability fixes
semver tag practices
- tag は
v<MAJOR>.<MINOR>.<PATCH>format (v1.2.3)。vprefix is the mainstream - pre-release は
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- For linking tagging and deployment, use GitHub Release + Actions
on: releaseto set it up
Tool comparison
| Tool | Supported languages | Approach | CHANGELOG generation | version bump | Main advantages | Weaknesses |
|---|---|---|---|---|---|---|
| release-please | npm / python / rust / go / java / php / ruby | Release PRs are created automatically | ○ | ○ (PR merge で tag) | Monorepo support, made by Google and stable | PR-based with a learning curve |
| changesets | npm only | changeset add Recorded manually in |
○ | ○ | Monorepo + flexible (npm workspaces) | Not commit-driven |
| conventional-changelog-cli | Language-agnostic (command) | Generate one release from a commit | ○ | × | Lightweight, appends to existing CHANGELOG | tag / bump are done separately and manually |
| standard-version (deprecated) | npm | commit → bump + CHANGELOG | ○ | ○ | Simple | Maintenance stopped, migration to release-please recommended |
| git-cliff | Language-agnostic (built in Rust) | Highly customizable via TOML configuration | ○ | × | Fast, flexible templates, favored in the Rust ecosystem | tag / bump are separate |
| towncrier | Python | Write a news fragment for each PR | ○ | × | Avoid merge-time conflicts with fragments | Unique workflow |
| auto (intuit/auto) | Multiple | Label-based + CI integration | ○ | ○ | GitHub label-driven | Custom label conventions |
Recommendation in one line
- Want to automate with a single npm package → release-please
- npm monorepo → release-please (manifest mode) or changesets (fine-grained control)
- Rust → git-cliff
- Python → towncrier(or release-please)
- Go / general purpose → git-cliff or release-please
- Want to generate only the CHANGELOG (tag management is manual) → conventional-changelog-cli or git-cliff
release-please setup (most recommended path)
When introducing it to a new npm project:
// release-please-config.json
{
"$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
}
// .release-please-manifest.json
{ ".": "0.0.0" }
# .github/workflows/release-please.yml
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
When you write commits following the conventional convention and push them to main, a "Release PR" is created automatically. When it is merged, tag + GitHub Release + CHANGELOG updates run.
Two forms of the publish-linked workflow:
| Form | trigger | Advantages | Drawbacks |
|---|---|---|---|
Split jobs within the same workflow (needs: release-please + if: outputs.release_created) |
push: main |
Complete in one file, can reference release-please outputs directly, easy to grasp at a glance | 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 also runs for manual releases, re-runs are independent | Must write secrets / permissions in two places, cannot use release-please outputs |
Selection guidelines: If it is a single package and a single publish destination (npm only), the same workflow is simpler. If there are multiple publish destinations (such as npm + GitHub Container Registry) or manual releases are also allowed, use on: release separate workflows. If you use OIDC Trusted Publishing, id-token: write you only need to grant permissions on the publish job side, and either approach works.
For details, see npm-release the publish flow in the skill (locally managed).
Staged releases for Pre-release (beta / rc)
When you want to release v2.0.0 containing a breaking change in the order beta → rc → official. Use release-please's prerelease / prerelease-type flag:
// release-please-config.json
{
"packages": {
".": {
"package-name": "my-pkg",
"prerelease": true,
"prerelease-type": "beta"
}
}
}
Flow:
prerelease-type: "beta"accumulate commits withv2.0.0-beta.1is cut- with additional commits
v2.0.0-beta.2/beta.3... accumulate - When promoting to RC: change the config to
"prerelease-type": "rc"rewrite to →v2.0.0-rc.1 - At official release:
"prerelease": false(or delete the field) →v2.0.0
How to write the CHANGELOG: release-please は Finalize an independent section for each beta/rc (this is not the Keep a Changelog [Unreleased] method of accumulating in one place).[2.0.0-beta.1] / [2.0.0-beta.2] / [2.0.0-rc.1] / [2.0.0] all remain.2.0.0 In the official version's section, all within the period feat! / BREAKING CHANGE are aggregated.
Monorepo + workspace:* interaction of
pnpm workspace で @org/core, @org/ui, @org/cli has, and@org/ui と @org/cli が @org/core when depending on:
// release-please-config.json
{
"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": {
"release-type": "node",
"package-name": "@org/cli",
"include-component-in-tag": true
}
},
"separate-pull-requests": false,
"include-v-in-tag": true
}
include-component-in-tag: true→ the tag name is@org/core-v2.0.0includes the component name as in (monorepo collision avoidance)node-workspaceplugin →@org/coredetects the bump on the side and@org/ui/@org/cliのdependencies."@org/core"rewrites, and adds both to the PR as a patch bumpupdatePeerDependencies: true→ peer dependencies are rewritten similarly (needed on the library side)separate-pull-requests: false→ aggregate the bumps of all packages into a single Release PR
package name in the commit scopeoperation of including:
feat(@org/core): add streaming parser
fix(@org/ui): button focus ring on safari
feat(@org/cli)!: rename --config to --profile
automatically determines the change target from the scope. An entry is written only to the CHANGELOG of the corresponding package.
workspace:* replacement mechanism: @org/ui/package.json で "@org/core": "workspace:*" even if written as, pnpm automatically replaces it at publish time with ^<実バージョン> . On the release-please side, at bump time workspace:* is left as is and the source is not modified. With this premise, things line up exactly (on the pnpm side, workspace:^ → ^同major、workspace:~ → ~同minor)。
git-cliff setup (Rust / general-purpose)
cargo install git-cliff
# or
brew install git-cliff
# cliff.toml
[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 # 既存の規約違反 commit を CHANGELOG から除外
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"
In Rust-oriented operations, when used together with cargo-release ,cargo-release が Cargo.toml handles the version bump and tag creation, and git-cliff focuses solely on CHANGELOG generation (loose coupling). This is also the reason for not introducing PR-based automatic bumping like release-please.
git-cliff --output CHANGELOG.md # 全履歴から再生成
git-cliff --unreleased --prepend CHANGELOG.md # 未リリース分だけ上に追記
git-cliff --tag v1.2.0 --output CHANGELOG.md # 特定 tag 向けに生成
Wiring with cargo-release (concrete example)
Cargo.toml の [package.metadata.release] set up a hook in.cargo release <level> When you run, bump → CHANGELOG generation → commit → tag → push run in sequence:
# Cargo.toml
[package.metadata.release]
# version bump 後・commit 前に git-cliff で CHANGELOG を更新
pre-release-hook = ["git", "cliff", "--tag", "v{{version}}", "--unreleased", "--prepend", "CHANGELOG.md"]
# tag は cargo-release 側が打つ
tag-name = "v{{version}}"
# release commit に CHANGELOG.md を含める
pre-release-commit-message = "chore(release): v{{version}}"
Order:
cargo release minor --execute→Cargo.tomlの version bumppre-release-hookでgit cliffis finalized as the next version's[Unreleased]を[v0.2.0] - 2026-04-19andCHANGELOG.mdis updated- cargo-release が
Cargo.toml+CHANGELOG.mdare combined into a release commit tag-nameでv0.2.0is created as an annotated tagcargo publish+git push --tags
Note: git-cliff side's [changelog.bump] section (features_always_bump_minor etc.) is a unique feature where git-cliff itself decides the bump, and should not be used when used together with cargo-release (the main body of bumping is cargo-release).<level> (patch / minor / major) is premised on being 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
# -p: preset (angular / atom / ember / eslint / jquery / jshint)
# -i: input, -s: same file, -r 0: 全履歴
The preset is mainly angular(the origin of conventional commits). It is suited to operations that append to an existing CHANGELOG.
Validate commit messages with pre-commit
Reject conventional convention violations at commit time:
commitlint
npm install -D @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };
# .pre-commit-config.yaml (prek / pre-commit)
- 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: additional_dependencies If you do not add, at hook execution time config-conventional cannot be resolved and Cannot find module it fails with.commitlint.config.js で extends Just doing is insufficient; it must also be made explicit in the hook's isolated environment.
prek vs pre-commit: prek is a Rust-made pre-commit-compatible implementation (https://github.com/j178/prek)。設定ファイル (.pre-commit-config.yaml) and the hook specification is common, with execution speed 5-10x faster. The command is prek install / prek run. In the mizchi environment, prek is used (see the chezmoi-management skill).
commitizen (interactive commit creation)
npm install -D commitizen cz-conventional-changelog
# package.json
"config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } }
# 使用
git cz
Common failures
- Introducing commit conventions midway: Existing history stays in its broken format. release-please ignores "non-conforming commits" during scan. Retroactive rewriting is unnecessary; just get it right from next time on
chore:is all that's used, so no release PR is created: even though it's a bug fix,chore:written this way means it won't appear in the CHANGELOG / the version won't be bumped either.Choose the type by "kind of change", not by importance(fix is a user-visible fix, chore is internal cleanup)- Writing BREAKING CHANGE in the subject and omitting the footer: release-please は footer の
BREAKING CHANGE:か type の!is checked. Even if you write "breaking: ..." in the subject, it won't trigger a major bump - Manually editing the CHANGELOG + using the tool together: If you manually edit a CHANGELOG managed by release-please, it will drift on the next generation. Always add entries via a PR (use release-please's "Edit" feature)
- Tagging first and then creating a PR with release-please: release-please tags at PR merge time. This conflicts with manual tagging. If operating manually, standardize on conventional-changelog-cli / git-cliff + manual tagging
Red flags
| Thoughts that come up | Reality |
|---|---|
| "The CHANGELOG is more carefully written by hand" | Generating from commits means nothing is missed. Handwriting always causes omissions and inconsistent notation |
| "Tag first, then write the changelog" | The order is reversed. Follow commit → tool generates CHANGELOG → tag |
| "Breaking changes are understood if written in the subject" | tool は footer / ! is read mechanically. Natural-language text in the subject is ignored |
| "Let's add project-specific types" | feat / fix / chore can be expressed within the scope of. Custom types are not supported by the tool's preset |
| "Fix conventional-violating commits with rebase" | Retroactive rewriting pollutes history. Violations are fine as long as you get them right from next time on; the tool ignores them |
Related
npm-release— Automate through to publish with release-please + OIDC Trusted Publishing (session-specific local management)apm-usagereferences/publishing.md — CHANGELOG conventions when publishing a skillgh-fix-ci— when the release workflow fails in CI- Keep a Changelog: https://keepachangelog.com/
- Conventional Commits: https://www.conventionalcommits.org/
- release-please: https://github.com/googleapis/release-please
- git-cliff: https://git-cliff.org/