Never Write a Changelog Agai
Automated Changelogs
TL;DR:
- Problem: Manual changelogs get forgotten and become outdated
- Solution: Enforce structured commit messages (Conventional Commits) and auto-generate changelogs from git history
- Tools: commitlint + git-cliff + GitLab CI
- Result: Every merge to
mainauto-updates the changelog. Zero human intervention. - Time investment: ~30 minutes setup, saves hours
The Problem
One day while investigating a production bug I found myself scrolling through dozens of commits with messages like "fixed stuff" and "updated logic" - looking for the change that had introduced a memory leak that was resulting in pod OOMKills. After 45 minutes of this, I thought to myself: 'surely there's a better way'. This is when the idea of having a Changelog.md file for the project was concieved.
What is a Changelog?
A CHANGELOG.md file is a document that tracks all notable changes made to a project over time. It's a human and machine readable history of what's been added, changed, fixed, or removed in each version of your project.
The First Attempt: Manual Updates
My genius idea was for engineers to manually enter change logs upon completion of work/features. The idea seemed great at first as we all agreed that we needed a way to track changes without diving into commits. We added logs to the file and pledged honesty oaths to physically check if MRs had Change log entries.
This approach worked for a couple of months. Then reality set in:
- MRs started merging without changelog entries
- The file accumulated merge conflicts from parallel branches
- When we did remember to update it, everyone formatted entries differently
- Even I started forgetting to add entries or check for them in code reviews
The wake up call came when our Lead Engineer asked, "Do we still maintain the changelog?" I looked at the file - last updated... let's just say it had been a while. It was apparent that we needed an automated approach - something that would't rely on human memory.
The Automated Solution
Instead of relying on human memory and discipline, I decided to build a system that would generate the changelog automatically from our commit history. The logic was simple: if we're already writing commit messages, why not structure them in a way that a tool can parse and organize into a changelog?
The Flow
Here's what happens now every time code merges to main:
Developer writes commit
↓
commitlint validates format
↓
Merge to main
↓
GitLab CI triggers
↓
git-cliff reads history
↓
Groups commits by type
↓
Updates CHANGELOG.md
↓
Auto-commits back to main
Zero manual intervention. Zero forgotten entries. Zero merge conflicts.
The Tools
I landed on a combination of four tools that work together:
1. Conventional Commits
A standardized format for commit messages that looks like this:
feat(auth): add OAuth login support
fix(api): handle null reference in user mapper
perf(db): add index on created_at column
The format is: <type>(<scope>): <description>
2. commitlint
Enforces the Conventional Commits format on every merge request. If someone tries to merge with a commit like "fixed stuff" or "updates", the pipeline fails. This ensures we never have malformed commits reaching main.
3. git-cliff
A Rust-based changelog generator that reads your git history, parses the conventional commits, and renders them into a formatted CHANGELOG.md. It's fast, configurable, and handles all the heavy lifting.
4. GitLab CI
Runs git-cliff automatically on every push to main and commits the updated changelog back to the repository.
How It Works
When you write a commit following the convention:
feat(api): add user export endpoint
git-cliff automatically categorizes it and adds it to the changelog:
### Features
- **api:** add user export endpoint ("a1b2c3d"(https://gitlab.../commit/a1b2c3d))
Different commit types go into different sections:
| Commit Type | Changelog Section |
|---|---|
feat |
Features |
fix |
Bug Fixes |
perf |
Performance |
refactor |
Refactoring |
docs |
Documentation |
chore, test, ci |
Skipped entirely |
Key Configuration Decisions
1. Enforcing lowercase scopes
We configured commitlint to require lowercase scopes (feat(api): not feat(API):). This keeps the changelog visually consistent and prevents the same scope from appearing multiple times due to casing differences.
2. Linking every commit
Each changelog entry includes a link back to the actual commit in GitLab. This makes it trivial to trace a change back to its code, PR discussion, and context.
# In cliff.toml
body = """
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message }} \
([{{ commit.id | truncate(length=7, end="") }}](https://gitlab.../commit/{{ commit.id }}))
"""
3. The [skip ci] flag
When the CI job commits the updated CHANGELOG.md back to main, it includes [skip ci] in the commit message:
git commit -m "chore(release): update changelog [skip ci]"
Without this, we'd trigger an infinite loop: the changelog commit would trigger CI, which would generate a new changelog commit, which would trigger CI again...
4. Filtering unconventional commits
We set filter_unconventional = true in the git-cliff config. This means commits that do't follow the format simply do't appear in the changelog. It's strict, but it forces consistency.
The Result
Now our CHANGELOG.md looks like this:
# Changelog
## "Unreleased"
### Features
- **jobs:** add reconciliation summary Slack notification ("a1b2c3d"(https://gitlab.../commit/a1b2c3d))
- **api:** add bulk export endpoint ("e4f5g6h"(https://gitlab.../commit/e4f5g6h))
### Bug Fixes
- **auth:** handle expired token edge case ("i7j8k9l"(https://gitlab.../commit/i7j8k9l))
## [1.2.0] - 2025-03-15
### Features
- **batch:** implement async processing ("m9n0p1q"(https://gitlab.../commit/m9n0p1q))
Clean, organized, and always current. The setup takes about 30 minutes, and once it's running, you'll never manually write a changelog entry again.