Skip to content

Automated Changelogs with Git-Cliff

You've meticulously crafted your commit messages using the Conventional Commits standard. Now what? Do you manually copy-paste them into a CHANGELOG.md file like some kind of digital peasant?

Absolutely not. That's what interns are for. And since we don't have any, we'll automate it.

Automated changelog generation is one of the biggest payoffs of a disciplined commit history. It turns your git log into a structured, human-readable document that tells the story of your project's evolution.

Why Automate Your Changelog?

"But I can just write it myself! It only takes a few minutes."

Sure, and you could also churn your own butter. The point isn't just about saving time; it's about consistency, accuracy, and professionalism.

  • It's Always Up-to-Date: An automated changelog is never forgotten. It's generated from the single source of truth: your Git history.
  • It's Professional: A well-formatted changelog shows users and stakeholders that you run a tight ship. It communicates progress clearly and effectively.
  • It's Effortless: Once set up, it requires zero extra work. Your commit messages are your release notes.

Introducing git-cliff

There are many tools for this, but I'm a big fan of git-cliff. It's fast, highly customizable, and written in Rust, because of course it is...

It parses your Git history, filters commits based on our conventional commit types (feat, fix, etc.), and generates a beautiful Markdown file.

Installation

You can install git-cliff in several ways. Pick your favorite.

shell
apk add git-cliff
shell
pacman -S git-cliff
shell
# Install via an npm wrapper
npm install -g git-cliff-npm
shell
# For Windows users
winget install orhun.git-cliff
shell
# On macOS or Linux
brew install git-cliff
shell
# If you have the Rust toolchain installed
cargo install git-cliff

Usage in CI/CD with Docker

The easiest and most portable way to run git-cliff in a CI/CD pipeline is by using its official Docker image. This avoids having to install it on your runners and ensures you're always using a specific, tested version.

Here’s an example of a GitLab CI job that generates the changelog and attaches it as a build artifact. The same principle applies to GitHub Actions, Jenkins, or any other CI system.

yaml
# .gitlab-ci.yml

stages:
  - release

create_changelog:
  stage: release
  image: orhunp/git-cliff:latest
  script:
    # We need to fetch the full history to parse all commits
    - git fetch --all --tags
    # Generate the changelog
    - git-cliff --output CHANGELOG.md --tag ${CI_COMMIT_TAG}
  artifacts:
    paths:
      - CHANGELOG.md
  rules:
    # Only run this job when a new tag is pushed
    - if: $CI_COMMIT_TAG

This job will automatically:

  1. Run only when you push a new Git tag.
  2. Use the git-cliff Docker image.
  3. Fetch all Git history to ensure the log is complete.
  4. Generate a CHANGELOG.md file for the specific tag being released.
  5. Save the CHANGELOG.md as a downloadable artifact for the release.

Now your release process is not only automated but also documented. That's how you run a professional operation.

Basic Usage

To generate a changelog for all versions, you can run:

bash
git-cliff --output CHANGELOG.md

To generate the notes for a specific upcoming version (e.g., v1.1.0):

bash
git-cliff --tag v1.1.0 > new_release_notes.md

Configuration

The real power of git-cliff comes from its configuration file, cliff.toml. You can generate a default one to get started:

shell
git-cliff --init

This gives you a decent starting point. git-cliff is highly customizable. Tailor it to your needs. Here's a this cliff.toml I use with the conventional commits format described in our workflow, emphasing on scope being ticket IDs. It's minimal but effective.

toml
[changelog]
# template for the changelog footer
header = """
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
    ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%d-%b-%Y") }}
{% else %}\
    ## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
    ### {{ group | striptags | trim | upper_first }}
    {% for commit in commits %}
        - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
            {% if commit.breaking %}[**breaking**] {% endif %}\
            {{ commit.message | upper_first }}\
    {% endfor %}
{% endfor %}\n

Full changelog available at [{{ previous.version }}...{{ version }}]({{ get_env(name="CI_PROJECT_URL") }}/-/compare/{{ previous.version }}...{{ version }})
"""
# template for the changelog footer
footer = """
"""
# remove the leading and trailing s
trim = true

[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = []
# regex for parsing and grouping commits
commit_parsers = [
  { message = "^feat", group = "<!-- 0 -->🚀 Features" },
  { message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
  { message = "^doc", group = "<!-- 3 -->📚 Documentation" },
  { message = "^perf", group = "<!-- 4 -->⚡ Performance" },
  { message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
  { message = "^style", group = "<!-- 5 -->🎨 Styling" },
  { message = "^test", group = "<!-- 6 -->🧪 Testing" },
  { message = "^chore\\(release\\): prepare for", skip = true },
  { message = "^chore\\(deps.*\\)", skip = true },
  { message = "^chore\\(pr\\)", skip = true },
  { message = "^chore\\(pull\\)", skip = true },
  { message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
  { body = ".*security", group = "<!-- 8 -->🛡️ Security" },
  { message = "^revert", group = "<!-- 9 -->◀️ Revert" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"

This configuration does exactly what we need:

  • Groups commits into logical sections (Features, Bug Fixes, etc.).
  • Includes the scope (like a ticket ID) for context.
  • Filters out noise, focusing only on what matters for a release.
  • Provides a link to the full diff between releases. The template uses a GitLab-specific URL; adjust it in the body section for other platforms.

It's a strong foundation. You can always explore the official docs later if you feel the need to tweak it further.