AI agent skill · by @mizchi
Conventional Changelog
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.
Archived 2026-10-23 · Source updated 2026-09-16. Check the original repository for the latest version.
SKILL.md instructions
# 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](https://keepachangelog.com/). Versions in descending order, semver, and a fixed set of 6 sections:
```markdown
# 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:
```json
// 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
}
```
```json
// .release-please-manifest.json
{ ".": "0.0.0" }
```
```yaml
# .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@v5
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:
```json
// release-please-config.json
{
"packages": {
".": {
"package-name": "my-pkg",
"prerelease": true,
"prerelease-type": "beta"
}
}
}
```
Flow:
1. Stack commits with `prerelease-type: "beta"` → `v2.0.0-beta.1` is cut
2. More commits stack up as `v2.0.0-beta.2` / `beta.3` ...
3. Promote to RC: rewrite config to `"prerelease-type": "rc"` → `v2.0.0-rc.1`
4. 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`:
```json
// 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` → 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)
```bash
cargo install git-cliff
# or
brew install git-cliff
```
```toml
# 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 # Exclude existing non-conforming commits from the 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 }, # Bulk-exclude non-conforming commits (do not rewrite history)
]
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.
```bash
git-cliff --output CHANGELOG.md # Regenerate from full history
git-cliff --unreleased --prepend CHANGELOG.md # Prepend only the unreleased section
git-cliff --tag v1.2.0 --output CHANGELOG.md # Generate for a specific tag
```
### 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:
```toml
# Cargo.toml
[package.metadata.release]
# After the version bump and before the commit, update CHANGELOG with git-cliff
pre-release-hook = ["git", "cliff", "--tag", "v{{version}}", "--unreleased", "--prepend", "CHANGELOG.md"]
# The tag itself is created by cargo-release
tag-name = "v{{version}}"
# Include CHANGELOG.md in the release commit
pre-release-commit-message = "chore(release): v{{version}}"
```
Order:
1. `cargo release minor --execute` → version bump in `Cargo.toml`
2. `pre-release-hook` runs `git cliff` to finalize the next version's `[Unreleased]` as `[v0.2.0] - 2026-04-19` and update `CHANGELOG.md`
3. cargo-release bundles `Cargo.toml` + `CHANGELOG.md` into the release commit
4. `tag-name` creates `v0.2.0` as an annotated tag
5. `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)
```bash
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: full history
```
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
```bash
npm install -D @commitlint/cli @commitlint/config-conventional
```
```js
// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };
```
```yaml
# .pre-commit-config.yaml (pre-commit framework)
- 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)
```bash
npm install -D commitizen cz-conventional-changelog
# package.json
"config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } }
# usage
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
- `npm-release` — automates all the way to publish with release-please + OIDC Trusted Publishing (session-specific local management)
- `apm-usage` references/publishing.md — CHANGELOG conventions when publishing a skill
- `gh-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/