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 -r 0 flag regenerates the entire changelog from all commits. Without it, only new commits since the last run are added.
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",
},
};
};
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"
]
}
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 }}
CI/CD Integration Patterns
Release Trigger Strategies
Automating changelog generation requires reliable release triggers. The most common patterns:
Tag-Based Triggers Push a version tag → CI runs semantic-release → changelog auto-generated
git tag v1.2.0 && git push origin v1.2.0
Merge-to-Main Triggers Every merge to main triggers a release pipeline:
# .github/workflows/release-on-merge.yml
on:
pull_request:
types: [closed]
branches: [main]
jobs:
release:
if: github.event.pull_request.merged == true
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: npx semantic-release
Commit Message Patterns Trigger releases based on commit messages:
on:
push:
branches: [main]
paths:
- "src/**"
jobs:
release:
if: "contains(github.event.head_commit.message, 'release:') || contains(github.event.head_commit.message, 'feat:') || contains(github.event.head_commit.message, 'fix:')"
Commitlint Integration
Enforce conventional commits at the PR level before merging:
npm install --save-dev @commitlint/config-conventional @commitlint/cli
// commitlint.config.js
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert",
],
],
"type-case": [2, "always", "lower-case"],
"type-empty": [2, "never"],
"subject-empty": [2, "never"],
"subject-full-stop": [2, "never", "."],
},
};
# .github/workflows/commitlint.yml
name: Lint Commits
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v5
Changelog Validation in CI
Add a validation step that fails if the changelog would be empty or suspiciously small:
// scripts/validate-changelog.js
import conventionalChangelog from "conventional-changelog";
import { readFileSync } from "fs";
const context = { version: "1.0.0", title: "Changelog" };
const options = { preset: "angular" };
let entryCount = 0;
let hasBreakingChanges = false;
conventionalChangelog(options, context)
.on("data", (chunk) => {
const text = chunk.toString();
if (text.includes("### Features") || text.includes("### Bug Fixes")) {
entryCount++;
}
if (text.includes("BREAKING")) {
hasBreakingChanges = true;
}
})
.on("end", () => {
if (entryCount === 0) {
console.error("Validation failed: Changelog has zero entries");
process.exit(1);
}
if (hasBreakingChanges) {
console.log("Breaking changes detected — manual review recommended");
}
console.log(`Changelog validation passed with ${entryCount} entries`);
});
Run this in CI before publishing:
node scripts/validate-changelog.js && npx semantic-release
Pre-commit Hooks for Commit Enforcement
Set up local commit message validation before push:
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'
Or validate on commit-msg hook:
#!/bin/sh
npx commitlint --edit ${1}
This catches non-conventional commits before they enter your history.
Production Failure Scenarios
| 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-off Analysis
| 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 Considerations
- 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
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
Interview Questions
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.
In most changelog tools like conventional-changelog, you use configuration to filter by commit type. For example, you can include only feat, fix, and BREAKING CHANGE commits while excluding chore, docs, and test. This is done through the preset configuration or by defining custom commitGroups in the changelog config. The trade-off is that excluding types reduces noise but may miss important context about refactors or dependency updates.
--first-parent follows only the main branch line when traversing history. Merge commits are included but only their message is used. Without it, all commits from merged branches appear as individual entries, causing duplicate and confusing entries. For teams using merge strategies, --first-parent produces cleaner changelogs by only showing commits that went directly into the main branch.
Use the -r flag to regenerate from a specific point. Run conventional-changelog from the date/tag when the switch happened using --from and --to options. Older commits can either be: manually added in a "Legacy History" section, excluded entirely, or processed with a custom parser that attempts to extract meaningful information from non-standard messages. Semantic-release supports a preset option where you can configure custom parsing rules for legacy formats.
Including full commit hashes exposes your repository's commit graph publicly, which can aid attackers in understanding your codebase's evolution. However, shortened hashes (7 characters) and links to commit pages (e.g., GitHub) are standard practice and acceptable. Never include internal ticket numbers that reference security vulnerabilities. For regulated industries, ensure no sensitive identifiers leak through commit metadata. Consider generating separate internal changelogs with full detail and external ones with sanitized information.
Configure the skip option in your preset or writer options to exclude types like chore, docs, test, refactor, and style. Breaking changes are detected via the BREAKING CHANGE: footer or ! notation — these are checked regardless of commit type. In conventional-changelog, set skip in writerOpts to return null for excluded types, but the breaking change detection runs on all commits before filtering.
A revert commit formatted as revert: message will appear in the changelog as a revert entry, which may not be meaningful to end users. To handle this properly: add custom logic to detect reverts (check for "This reverts commit" in body), optionally filter them out via writerOpts transform, or add a revert: type that gets grouped separately. Semantic-release has no special handling for reverts — they appear as regular commits unless you customize the parser.
Use the context object in the template to provide repository metadata, then construct links in your transform function:
- Pass
owner,repository,host(e.g., "https://github.com") to the changelog preset - In the transform, add
commit.issueandcommit.PRlinks - Update the template to render issue/PR links in the output
For GitHub specifically, use context.host + '/' + context.owner + '/' + context.repository + '/issues/' + commit.issue. Semantic-release's GitHub plugin automatically adds issue references when configured with the GitHub repository.
Common causes: (1) No git tags exist yet — it compares HEAD to the oldest tag; if no tags, it produces nothing. (2) Commits don't follow the configured preset (e.g., using "feat:" with Angular preset when "Feature:" is required). (3) All commits were filtered out (chore, docs, test types). (4) Using shallow clone (fetch-depth: 1) in CI — needs full history to find tags. (5) The -r flag was omitted, so it only adds commits since last run, and there were no new commits matching the criteria.
Layer multiple defenses: (1) Pre-commit hooks via husky that run commitlint on every commit. (2) PR title enforcement via GitHub branch rules — squash merge uses PR title as commit message. (3) CI pipeline check that fails if non-conventional commits are detected in a merge. (4) CODEOWNERS file requiring review for merge. (5) Documentation in CONTRIBUTING.md with examples. (6) For legacy commits, use a "chore(legacy):" prefix for manual entries and configure the parser to include these in the changelog.
The -s (or --same-file) flag appends new changelog entries to the existing file instead of overwriting it. Without -s, the generated changelog is written to stdout or a new file. You'd omit -s when: generating a standalone changelog for distribution, creating a temporary changelog for review, or outputting to different formats. You'd use -s for the typical workflow where you prepend to an existing CHANGELOG.md on each release.
Keep a Changelog format emphasizes human readability with sections like Added, Changed, Deprecated, Removed, Fixed, Security. It does not automatically parse git history — it's a manual template. Conventional-changelog automates generation but uses a machine-oriented format grouped by commit type (Features, Bug Fixes, etc.). Keep a Changelog is better for end-user facing release notes; conventional-changelog is better for automated pipeline integration. Many teams use both: auto-generate with conventional-changelog, then transform into Keep a Changelog format for public releases.
Steps: (1) Ensure all future commits follow conventional format. (2) Add a historical cutoff date — generate automated changelog from that date forward using --from flag. (3) Create a "Legacy Changelog" section for pre-conventional commits manually. (4) Set up commitlint to prevent future drift. (5) Consider using -r 0 once to regenerate everything if commit history is clean enough. For messy histories, maintain a manual "Legacy History" section above the automated output.
For repositories with 100k+ commits, changelog generation can take 30-60 seconds due to git log traversal and string parsing. Mitigations: use shallow clone (fetch-depth: 100 or enough for your release cadence), cache parsed results between runs, use --first-parent to reduce commit volume, skip merge commits with gitRawCommitsOpts.merges = null. git-cliff is notably faster than Node.js-based tools for large repos since it's a compiled binary.
Changelogs for international audiences should: use simple English avoiding idioms, keep technical terms in English with explanations for non-technical readers, separate language-specific content from formatting (use template i18n), consider generating locale-specific changelogs via separate templates, use clear date formats (YYYY-MM-DD) that are universally understood. Automated changelogs preserve the commit subject language — for global consumption, consider a translation step or separate localized changelog files.
Key metrics: Commit convention adherence rate (what % of commits follow conventional format), changelog coverage (% of actual changes reflected in changelog), user-facing clarity score (surveys or comments from users), time-to-release (does automation reduce release time?), missing features rate (how often are significant changes omitted), breaking change detection accuracy (are breaking changes properly flagged?). Track these weekly in CI dashboards to catch drift early.
Further Reading
- conventional-changelog GitHub
- semantic-release Documentation
- Keep a Changelog
- Auto Release Tool
- Changesets for Monorepos
- Lerna Changelog
Conclusion
A good changelog tells the story of a release — what changed, who contributed, and what to watch for. Automated changelog generation from conventional commits eliminates the friction of manual release notes and ensures nothing gets lost between releases.
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.