Automated Changelog Generation: From Commit History to Release Notes
Build automated changelog pipelines from git commit history using conventional commits, conventional-changelog, and semantic-release. Learn parsing, templating, and production patterns.
Introduction
A changelog is the bridge between your code and your users. It answers the question every stakeholder asks: “What changed?” Yet most teams write changelogs manually — a tedious, error-prone process that falls behind within weeks. Automated changelog generation solves this by parsing your git history and producing structured release notes.
The secret sauce is conventional commits. When every commit follows a structured format, machines can categorize, filter, and format them into beautiful changelogs. Tools like conventional-changelog, auto, and semantic-release turn your commit log into a living document that updates with every release.
This post covers the architecture of automated changelog systems, parsing strategies, template customization, and production-hardened patterns. If your team ships software regularly, this is infrastructure you can’t afford to skip.
When to Use / When Not to Use
Use automated changelogs when:
- You release software on a regular cadence (weekly, biweekly, monthly)
- You use conventional commits or another structured commit format
- You have external users or consumers who need to know what changed
- You publish packages to npm, PyPI, or other registries
- You want to reduce release overhead
Skip them when:
- You release rarely (quarterly or less) and manual notes are manageable
- Your commit history is chaotic with no structure to parse
- You’re building internal tools where release notes aren’t consumed
- Your team isn’t ready to adopt commit conventions
Core Concepts
Automated changelog generation works in three phases:
- Parse — Read git log and extract structured data from commit messages
- Categorize — Group commits by type (features, fixes, breaking changes, etc.)
- Render — Apply a template to produce formatted markdown
The input is your git log. The output is a CHANGELOG.md file. The engine is a parser-template pipeline.
flowchart LR
A[Git Repository] --> B[git log]
B --> C[Commit Parser]
C --> D{Categorize}
D -->|feat| E[Features]
D -->|fix| F[Bug Fixes]
D -->|BREAKING| G[Breaking Changes]
D -->|other| H[Other]
E --> I[Template Engine]
F --> I
G --> I
H --> I
I --> J[CHANGELOG.md]
Architecture and Flow Diagram
sequenceDiagram
participant Rel as Release Trigger
participant Git as Git Log
participant Parser as Commit Parser
participant Filter as Commit Filter
participant Sort as Sorter
participant Tmpl as Template Engine
participant FS as CHANGELOG.md
Rel->>Git: Get commits since last tag
Git-->>Rel: Raw commit list
Rel->>Parser: Parse each commit
Parser->>Parser: Extract type, scope, subject
Parser->>Parser: Detect breaking changes
Parser-->>Rel: Structured commits
Rel->>Filter: Remove chore/docs/test commits
Filter-->>Rel: Release-worthy commits
Rel->>Sort: Group by type, sort by scope
Sort-->>Rel: Categorized commits
Rel->>Tmpl: Render with template
Tmpl->>Tmpl: Apply markdown template
Tmpl-->>Rel: Formatted changelog
Rel->>FS: Prepend to CHANGELOG.md
Step-by-Step Guide
1. Install conventional-changelog
npm install --save-dev conventional-changelog-cli
2. Generate Your First Changelog
npx conventional-changelog -p angular -i CHANGELOG.md -s
The -p angular preset uses Angular’s commit convention. The -i flag specifies the input/output file, and -s enables in-place editing.
3. Configure as an npm Script
{
"scripts": {
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0"
}
}
The -r 0 flag regenerates the entire changelog from all commits. Without it, only new commits since the last run are added.
4. Customize the Template
Create a custom template for your project’s branding:
// changelog-template.js
module.exports = function (options) {
return {
writerOpts: {
transform: (commit, context) => {
// Skip certain commits
if (commit.type === "chore" || commit.type === "test") {
return null;
}
// Add link to commit
commit.shortHash = commit.hash.substring(0, 7);
commit.link = `${context.host}/${context.owner}/${context.repository}/commit/${commit.hash}`;
return commit;
},
groupBy: "type",
commitGroupsSort: (a, b) => {
const order = [
"Breaking Changes",
"Features",
"Bug Fixes",
"Performance",
"Documentation",
];
return order.indexOf(a.title) - order.indexOf(b.title);
},
commitsSort: ["scope", "subject"],
noteGroupsSort: "title",
notesSort: "text",
},
};
};
5. Integrate with semantic-release
For fully automated releases with changelog generation:
npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git
Create .releaserc.json:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"
]
}
6. Add to CI Pipeline
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Production Failure Scenarios + Mitigations
| Scenario | Impact | Mitigation |
|---|---|---|
| Parser fails on non-conventional commit | Changelog generation stops | Use --skip-unparsed flag or pre-filter commits |
| Duplicate entries in changelog | Confusing release notes | Use -s flag for in-place deduplication |
| Missing git tags | Can’t determine commit range | Ensure tags are pushed; use git fetch --tags in CI |
| Template rendering error | Broken changelog format | Test template locally before CI deployment |
| Large repository history | Slow changelog generation | Use shallow clone with sufficient depth; cache parsed results |
| Merge commits polluting log | Duplicate feature entries | Configure parser to skip merge commits or use --first-parent |
Trade-offs
| Aspect | Automated Changelog | Manual Changelog |
|---|---|---|
| Accuracy | High (directly from commits) | Variable (human error) |
| Effort | Zero after setup | Ongoing per release |
| Customization | Template-bound | Unlimited |
| Commit discipline required | Yes (conventional commits) | No |
| Breaking change detection | Automatic | Manual review |
| Historical migration | Can regenerate from scratch | Must write manually |
Implementation Snippets
Generate changelog for specific version range:
# From last tag to HEAD
npx conventional-changelog -p angular -i CHANGELOG.md -s
# Specific range
npx conventional-changelog -p angular -i CHANGELOG.md -s -r 1
# All history
npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0
Custom parser configuration:
// conventional-changelog.config.js
module.exports = {
preset: "angular",
tagPrefix: "v",
skip: {
bump: false,
changelog: false,
commit: false,
tag: false,
},
gitRawCommitsOpts: {
merges: null, // Exclude merge commits
},
};
Programmatic API usage:
import conventionalChangelog from "conventional-changelog";
import { createWriteStream } from "fs";
const stream = conventionalChangelog({
preset: "angular",
tagPrefix: "v",
});
stream.pipe(createWriteStream("CHANGELOG.md", { flags: "a" }));
stream.on("end", () => console.log("Changelog updated"));
Filter commits by scope:
# Only include commits with 'api' scope
npx conventional-changelog -p angular -i CHANGELOG.md -s \
--config ./changelog-config.js
// changelog-config.js
module.exports = {
writerOpts: {
transform: (commit) => {
if (commit.scope === "api") {
return commit;
}
return false; // Filter out non-api commits
},
},
};
Observability Checklist
- Logs: Log number of commits processed per release
- Metrics: Track changelog generation time and file size growth
- Alerts: Alert when changelog generation fails in CI
- Dashboards: Monitor release frequency and commit type distribution
- Traces: Trace commit → changelog entry → release note for audit
Security/Compliance Notes
- Changelogs are public documents — never include internal ticket numbers that expose security vulnerabilities
- For regulated software, ensure changelogs include compliance-relevant changes (security patches, data handling updates)
- Link to CVE IDs when fixing security issues:
fix(auth): patch XSS vulnerability (CVE-2024-1234) - Consider generating separate internal and external changelogs for sensitive projects
Common Pitfalls / Anti-Patterns
| Anti-Pattern | Why It’s Bad | Fix |
|---|---|---|
| Including every commit | Noise overwhelms signal | Filter out chore, test, style commits |
| No deduplication | Same change listed multiple times | Use -s flag for in-place editing |
| Ignoring merge commits | Duplicate entries in changelog | Configure parser to skip or squash merges |
| Manual edits to CHANGELOG.md | Gets overwritten on next run | Add CHANGELOG.md to .gitignore for auto-generation or use semantic-release git plugin |
| Not testing templates | Broken formatting in production | Render template locally before CI |
| Forgetting tag prefix | Can’t find version tags | Set tagPrefix: 'v' in config |
Quick Recap Checklist
- Install
conventional-changelog-cli - Configure preset matching your commit convention
- Add npm script for changelog generation
- Set up custom template if needed
- Integrate with semantic-release for full automation
- Add CI workflow for automated releases
- Configure commit filtering to reduce noise
- Test changelog generation locally before deploying
Interview Q&A
It reads git log between the current HEAD and the last git tag. The tag is determined by parsing version numbers from existing tags. Commits are then filtered by type (features, fixes, breaking changes) and formatted using the selected preset template.
conventional-changelog only generates the changelog file. semantic-release is a full release automation tool that analyzes commits, determines the next version, generates the changelog, creates git tags, and publishes to registries. semantic-release uses conventional-changelog under the hood.
Use the prepend mode of conventional-changelog. Generate the automated section, then manually add entries above it. Alternatively, use semantic-release with a custom template that includes a manual section, or maintain a separate RELEASE_NOTES.md for human-written content alongside the auto-generated changelog.
Squash merges create a single new commit that doesn't preserve the original commit messages' structure. The squashed commit message may not follow conventional commit format, causing the parser to skip it. Configure your repository to use rebase merges or ensure the squash message follows the convention.
Use lerna-changelog or changesets which are designed for monorepos. They track changes per package and generate individual changelogs. Alternatively, configure conventional-changelog with custom filters to group commits by package scope.
Extended Architecture Diagram
flowchart LR
subgraph "Input"
A[Git Repository] --> B[git log]
B --> C[Raw Commits]
end
subgraph "Parsing"
C --> D[Commit Parser]
D --> E{Conventional Format?}
E -->|Yes| F[Extract type, scope, subject]
E -->|No| G[Skip or flag]
F --> H[Detect BREAKING CHANGE]
end
subgraph "Categorization"
H --> I{Group by Type}
I -->|feat| J[Features]
I -->|fix| K[Bug Fixes]
I -->|perf| L[Performance]
I -->|BREAKING| M[Breaking Changes]
I -->|docs/chore/test| N[Excluded]
end
subgraph "Rendering"
J --> O[Template Engine]
K --> O
L --> O
M --> O
O --> P[Apply Markdown Template]
P --> Q[CHANGELOG.md]
end
Extended Production Failure Scenario
Missing Conventional Commits Causing Incomplete Changelog
A developer squash-merges a PR with 15 commits into a single commit message that doesn’t follow conventional format: “Merge pull request #42 from feature/auth”. The changelog generator processes this commit, finds no recognized type prefix, and skips it entirely. The release ships with new authentication features but the changelog shows zero features — only unrelated bug fixes from other commits. Users have no idea what changed.
Mitigation: Enforce conventional commits at the PR level with commitlint on the CI pipeline. Configure squash merge to preserve the PR title as the commit message, and require PR titles to follow the conventional format. Add a changelog validation step that fails if a release contains zero feature entries but has code changes.
Extended Trade-offs
| Aspect | conventional-changelog | git-cliff | Manual |
|---|---|---|---|
| Automation | Full — runs as CLI or library | Full — single binary, config-driven | None |
| Customization | Template-based, JavaScript config | TOML config, custom templates, regex | Unlimited |
| Maintenance | npm dependency, regular updates | Standalone binary, minimal deps | Ongoing human effort |
| Ecosystem | Node.js native, semantic-release integration | Language-agnostic, works with any repo | N/A |
| Learning curve | Medium — JS config, template syntax | Low — TOML config is straightforward | N/A |
| Monorepo support | Via lerna-changelog or custom config | Built-in with git-cliff groups | Manual per-package |
Implementation Snippet: git-cliff Configuration
# cliff.toml
[changelog]
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
footer = """
<!-- generated by git-cliff -->
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^docs", group = "Documentation" },
{ message = "^BREAKING", group = "Breaking Changes" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore", skip = true },
{ message = "^ci", skip = true },
{ message = "^test", skip = true },
]
protect_breaking_changes = true
filter_commits = true
tag_pattern = "v[0-9].*"
sort_commits = "newest"
# Generate changelog with git-cliff
git-cliff --output CHANGELOG.md
# Generate for specific tag range
git-cliff v1.0.0..v1.2.0 --output CHANGELOG.md
# Unreleased changes only
git-cliff --unreleased --output CHANGELOG.md
Resources
Category
Related Posts
Commit Message Conventions: Conventional Commits, Angular Style, and Semantic Commits
Master commit message conventions including Conventional Commits, Angular style, and semantic commits. Learn automated changelog generation, linting enforcement, and team-wide standards.
Automated Releases and Tagging
Automate Git releases with tags, release notes, GitHub Releases, and CI/CD integration for consistent, repeatable software delivery.
Automated Release Pipeline: From Git Commit to Production Deployment
Build a complete automated release pipeline with Git, CI/CD, semantic versioning, changelog generation, and zero-touch deployment. Hands-on tutorial for production-ready releases.