Accepted changelog formats¶
Parse is a line scanner, not a markdown parser. It recognises three kinds of line —
release headings, section headings and bullets — and ignores everything else. This is the
exact set it recognises.
Release headings¶
Recognised at heading level 1 or 2 only. Two shapes:
| Shape | Example | Version recorded |
|---|---|---|
| Link-wrapped | ## [v1.2.3](https://…/v1.2.3) (2026-01-02) |
v1.2.3 — trailing text is dropped |
| Bare | # v1.2.3 |
v1.2.3 |
| Bare with trailing text | # v1.2.3 (2026-01-02) |
v1.2.3 (2026-01-02) — the whole line |
The version must look like 1.2.3 or v1.2.3 — three dot-separated numbers, with an
optional v — or be the literal word Unreleased. A two-part version (# v1.2) is not
recognised as a release heading, and a version at heading level 3 (### v1.2.3) is read
as a section heading instead, which silently resets the category to Other.
A document title such as # Changelog matches nothing and is ignored.
Section headings and the categories they map to¶
Recognised at heading level 2 or 3. Matching is case-insensitive, and leading
non-alphanumeric characters are stripped first, so release-please's ### ⚠ BREAKING
CHANGES maps correctly.
| Heading text | Category |
|---|---|
Breaking Changes |
CategoryBreaking |
Features or feat |
CategoryFeature |
Bug Fixes or fix |
CategoryFix |
Performance Improvements or perf |
CategoryPerformance |
| anything else | CategoryOther |
There is no partial matching. Performance on its own is not Performance Improvements,
and the keep-a-changelog headings — Added, Changed, Deprecated, Removed, Fixed,
Security — are all unrecognised and land in Other. Note that Fixed is not fix.
Entries appearing before any section heading in a release take CategoryOther.
Entry lines¶
A bullet is a line beginning with * or - followed by whitespace. + bullets and
numbered lists are not entries. Lines are trimmed before matching, so an indented
sub-bullet is read as a top-level entry of the current release.
| Written | Scope |
Description |
Category |
|---|---|---|---|
* plain text |
empty | plain text |
current section's |
* **cli:** add a flag |
cli |
add a flag |
current section's |
- **cli**: add a flag |
cli |
add a flag |
current section's |
- **BREAKING**: **api**: drop it |
api |
drop it |
CategoryBreaking |
- BREAKING CHANGE: drop it |
empty | drop it |
CategoryBreaking |
- BREAKING-CHANGE: drop it |
empty | drop it |
CategoryBreaking |
The inline **BREAKING**: marker is matched literally — capitals, colon, one space — and
overrides whatever the surrounding section said. A scope may not contain : or *, so
**a:b:** is left in the description rather than becoming a scope.
Entry.Raw holds the whole original line, bullet character included, before any of this
was stripped.
What is not interpreted¶
Everything else is skipped: prose, tables, HTML, link-reference definitions, and the
[Compare to previous version](…) lines releaser-pleaser writes between releases.
Fenced code blocks are not treated specially. A line inside a fence that starts with
* becomes an entry, and a # v1.0.0 inside a fence starts a release. A changelog whose
entries quote shell output will parse into entries you did not intend.
Trailing commit links are not stripped. Parsing this repository's own changelog leaves the link in the text:
Description: "detect release tags on merge commits via ancestry-based bucketing
([b064cee](https://gitlab.com/…/commit/b064cee…))"
Ordering, FromVersion and ToVersion¶
Parse reverses the releases it finds, so Changelog.Releases runs oldest to newest on
the assumption that the document runs newest first. FromVersion is then the first
release of the reversed slice and ToVersion the last.
Feed it an oldest-first document and both come out backwards — the parser has no way to
know, because it does not sort by version. Unreleased is treated as an ordinary version
string, so a newest-first document with an unreleased section yields
ToVersion == "Unreleased".
Reading from a release archive¶
ParseFromArchive(r io.Reader) expects a gzip-compressed tar stream. Any other
container — a plain tar, a zip, a bare markdown file — fails at the gzip header.
- The first regular file whose base name is
CHANGELOG.mdis used, matched case-insensitively (changelog.mdcounts) at any depth inside the archive. - Directories, symlinks and other non-regular entries are skipped.
- The file is read up to 10 MB and then truncated silently. A larger changelog parses as though the first 10 MB were the whole file — no error, no warning, and the releases past the cut are simply absent.
- If no changelog is found, the return is
(nil, nil)— see errors and return values.