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
godirective in this module'sgo.mod. An older toolchain will either download a newer one for you (the defaultGOTOOLCHAIN=autobehaviour) or refuse to build, depending on how yours is configured. - git, to create the practice repository.
changelogitself never shells out togit— 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.
Print the changelog to your terminal¶
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.
GenerateFromRepowith 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 outputis missing.testandcicommits 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.
What to read next¶
- Generate from git history — bounding the range, and what each option does to the output.
- Generation options — every option, its default, and what happens when the value is wrong.
- Parse existing release notes — reading a changelog you did not generate, including one inside a release archive.
- What changelog does not do — the limits, before you design around something that is not there.