Never Write a Changelog Agai
Photo by Richard Bell on Unsplash
Tech April 15, 2026 · By Garv

Never Write a Changelog Agai

Automated Changelogs

Share: Twitter Facebook LinkedIn

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 main auto-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.

How did you find this article?

Garv

Garv

Blog administrator and primary author

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.