Skip to content

Generate your first changelog

By the end of this you'll have a CHANGELOG.md generated from a repository's commit history, and a second program that reads it back and fails a build when a release contains a breaking change. Allow about fifteen minutes.

Everything here runs locally. Nothing is published, nothing is pushed, and the only file written is a CHANGELOG.md inside a throwaway repository you create in step one.

Before you start

You'll need:

  • Go 1.26.5 or newer. That is the go directive in this module's go.mod. An older toolchain will either download a newer one for you (the default GOTOOLCHAIN=auto behaviour) or refuse to build, depending on how yours is configured.
  • git, to create the practice repository. changelog itself never shells out to git — it reads the repository with go-git — but you need the binary to make the commits.

No API keys, no network service, no configuration file. This is a library, not a CLI: there is no changelog binary to install.

Create a repository with something to say

changelog reads commit messages that follow Conventional Commits, and semver tags to divide them into releases. Rather than point it at real work first time, build a small repository whose history you already know:

mkdir changelog-demo && cd changelog-demo
git init -b main

touch app.go
git add -A && git commit -m "feat: add the greeting command"

echo 1 >> app.go
git add -A && git commit -m "fix(cli): stop trimming the final newline"
git tag v0.1.0

echo 2 >> app.go
git add -A && git commit -m "feat(cli): add a --json flag"

echo 3 >> app.go
git add -A && git commit -m "test: cover the json output"
git tag v0.2.0

echo 4 >> app.go
git add -A && git commit -m "perf: cache the parsed template"

echo 5 >> app.go
git add -A && git commit -m "feat(api)!: rename Render to RenderTo"

Six commits, two tags, and one commit — the last — marked breaking with the ! after its type. The two commits after v0.2.0 are not tagged at all, which is the normal state of a repository mid-cycle.

Create a second directory alongside the demo repository for the program:

cd ..
mkdir changelog-tool && cd changelog-tool
go mod init changelog-tutorial
go get gitlab.com/phpboyscout/go/changelog

Then main.go:

package main

import (
    "fmt"
    "os"

    "gitlab.com/phpboyscout/go/changelog"
)

func main() {
    md, err := changelog.GenerateFromRepo("../changelog-demo")
    if err != nil {
        fmt.Fprintln(os.Stderr, "generating changelog:", err)
        os.Exit(1)
    }

    fmt.Print(md)
}

go run . prints:

# Unreleased

### Breaking Changes

* **api:** rename Render to RenderTo

### Performance Improvements

* cache the parsed template

# v0.2.0

### Features

* **cli:** add a --json flag

# v0.1.0

### Features

* add the greeting command

### Bug Fixes

* **cli:** stop trimming the final newline

Three things to notice, because all three surprise people:

  • The whole history is included. GenerateFromRepo with no options emits every release it can find, not a recent window.
  • The untagged commits became Unreleased, at the top, so a changelog generated mid-cycle shows what has landed since the last tag.
  • test: cover the json output is missing. test and ci commits are dropped on purpose — see commit classification for the full mapping.

Write it to CHANGELOG.md

GenerateFromRepo returns a string; writing it out is your program's job. Change main to write the file, and cap the output at the two most recent releases while you are there:

func main() {
    md, err := changelog.GenerateFromRepo("../changelog-demo",
        changelog.WithMaxReleases(2),
    )
    if err != nil {
        fmt.Fprintln(os.Stderr, "generating changelog:", err)
        os.Exit(1)
    }

    if err := os.WriteFile("../changelog-demo/CHANGELOG.md", []byte(md), 0o644); err != nil {
        fmt.Fprintln(os.Stderr, "writing CHANGELOG.md:", err)
        os.Exit(1)
    }

    fmt.Println("wrote ../changelog-demo/CHANGELOG.md")
}

Run it, then look at the file:

# Unreleased

### Breaking Changes

* **api:** rename Render to RenderTo

### Performance Improvements

* cache the parsed template

# v0.2.0

### Features

* **cli:** add a --json flag

v0.1.0 is gone, and so is one release you might have expected to keep: Unreleased counts towards the limit. WithMaxReleases(2) on a repository with unreleased commits gives you Unreleased plus one tagged release, not two tagged releases. There is no option to exclude the unreleased section — if you need exactly two tagged releases from a repository mid-cycle, ask for three.

os.WriteFile overwrites whatever was there. The generator has no notion of an existing changelog and never merges into one; it renders the history and hands you the string.

Read the changelog back and fail on a breaking change

The other half of the module reads a changelog into a typed model, which is what you want in CI: parse the file, and stop the build if a release contains a breaking change.

Replace main.go with:

package main

import (
    "fmt"
    "os"

    "gitlab.com/phpboyscout/go/changelog"
)

func main() {
    raw, err := os.ReadFile("../changelog-demo/CHANGELOG.md")
    if err != nil {
        fmt.Fprintln(os.Stderr, "reading CHANGELOG.md:", err)
        os.Exit(1)
    }

    cl, err := changelog.Parse(string(raw))
    if err != nil {
        fmt.Fprintln(os.Stderr, "parsing CHANGELOG.md:", err)
        os.Exit(1)
    }

    fmt.Println("releases:", len(cl.Releases), "from", cl.FromVersion, "to", cl.ToVersion)
    fmt.Print(changelog.FormatSummary(cl))

    if cl.HasBreakingChanges() {
        os.Exit(1)
    }
}

go run . prints the summary and exits non-zero:

releases: 2 from v0.2.0 to Unreleased
WARNING: Breaking changes detected!

  BREAKING: api: rename Render to RenderTo

Features:
  - cli: add a --json flag

Performance:
  - cache the parsed template

FormatSummary is plain text for a terminal, not markdown, and it flattens every release into one list — it answers "what changed across this range", not "what changed in v0.2.0". For per-release output, walk cl.Releases yourself.

Note from v0.2.0 to Unreleased. FromVersion and ToVersion are the first and last entries of a document assumed to run newest-first; they are not sorted by semver, and Unreleased is a version string like any other as far as the parser is concerned.

Point it at your own repository

Swap ../changelog-demo for a path inside a real project. The path may be the repository root or any subdirectory of it — changelog walks up to find the .git directory.

Two things will decide whether the output is any good, and neither is a setting:

  • Commit messages that follow the convention. Anything the parser cannot read is dropped, silently, unless you pass WithIncludeAll — which keeps non-conforming messages under Other, using the whole subject line as the description.
  • Semver tags. Tags that are not valid semver — release-4, 2026.07, nightly — are ignored entirely, and their commits fall into whichever release does claim them.