Skip to content

Parse existing release notes

The parsing half turns a changelog document back into a typed model, so a tool can read what was released rather than only render it. Useful for showing "what changed" from a downloaded release, or inspecting a CHANGELOG.md in CI.

Parse a markdown string

func Parse(rawNotes string) (*Changelog, error)
cl, err := changelog.Parse(rawNotes)

fmt.Println(cl.FromVersion, "→", cl.ToVersion)
for _, rel := range cl.Releases {
    fmt.Println("##", rel.Version, "-", len(rel.Entries), "entries")
}

Releases comes back oldest-first, on the assumption that the document was written newest-first. FromVersion is the first release of that reversed slice and ToVersion the last — they are positions in the document, not a semver comparison, so a document written oldest-first yields both the wrong way round.

Tell "no changelog" apart from "unreadable changelog"

Empty input is "no changelog": a Changelog with no releases and a nil error. Non-empty input in which nothing was recognised returns ErrNoReleases, so a caller such as a self-update breaking-change check can tell the two apart:

cl, err := changelog.Parse(rawNotes)
if errors.Is(err, changelog.ErrNoReleases) {
    // present the raw notes instead of a parsed summary
}

One case sits between them: a document with bullets but no headings parses into a single release whose Version is empty, and returns no error. If an unversioned release matters to you, check for it.

Formats it accepts

Parse reads both changelog families in the wild:

  • This module's own output# vX.Y.Z headings, * bullets, **scope:** entries. Round-trips exactly.
  • The releaser-pleaser / keep-a-changelog family## [vX.Y.Z](url) link-wrapped headings with trailing dates, - bullets, **scope**: entries with the colon outside the bold, and the inline **BREAKING**: marker. A release-please ### ⚠ BREAKING CHANGES heading maps correctly too.

Section headings are matched against a fixed list, and the keep-a-changelog vocabulary is not on it: Added, Changed, Deprecated, Removed, Fixed and Security all fall through to Other. Every recognised heading, and what each maps to, is in accepted changelog formats.

Parse from a release archive

Releases often ship the changelog inside a gzip/tar bundle. ParseFromArchive extracts and parses it in one step:

func ParseFromArchive(r io.Reader) (*Changelog, error)
cl, err := changelog.ParseFromArchive(resp.Body)
if err != nil {
    return err
}

if cl == nil {
    // no CHANGELOG.md in the archive — fall back to the release API
    return fetchNotesFromAPI(ctx, version)
}

Check for nil. An archive with no changelog in it is not an error — the design lets you fall back to another source — but the returned pointer is nil, and calling cl.HasBreakingChanges() on it panics. Parse never does this; only ParseFromArchive.

The reader must be a gzip-compressed tar. A changelog larger than 10 MB is truncated silently at that point, and whatever follows is lost without an error.

Query the model

if cl.HasBreakingChanges() {
    for _, e := range cl.BreakingChanges() {
        fmt.Printf("BREAKING %s: %s\n", e.Scope, e.Description)
    }
}

features := cl.EntriesByCategory(changelog.CategoryFeature)
fixes    := cl.EntriesByCategory(changelog.CategoryFix)

BreakingChanges and EntriesByCategory gather matching entries across every release in the changelog, flattened — they answer "what changed across this range", not "what changed in v1.2.0". For per-release answers, walk cl.Releases and filter rel.Entries yourself.

Render a summary

FormatSummary produces a concise, terminal-friendly summary — breaking changes first under a warning banner, then Features / Bug Fixes / Performance / Other:

fmt.Print(changelog.FormatSummary(cl))
WARNING: Breaking changes detected!

  BREAKING: config: renamed the timeout key

Features:
  - cli: add a --json flag

Bug Fixes:
  - http: retry on 503

It is plain text, not markdown, and it is flattened across releases like the query helpers. A nil or empty changelog formats to the empty string, so it is safe to call unconditionally.

Limitations

Parse is a line scanner, not a markdown parser: bullets inside fenced code blocks become entries, trailing commit links stay in the description text, and a version heading below level 2 is read as a section heading. It never sorts, never fetches, and never validates. The full list is in what changelog does not do.