changelog: parse and generate against real-world release history, not just our own output¶
- Authors
- Matt Cockayne, Claude Fable 5 (AI drafting assistant)
- Date
- 2026-07-23
- Status
- DRAFT — pending review
- Related
- architectural review (both HIGH changelog findings), leaf module extraction: workspace + changelog (the extraction that published this module)
1. Problem¶
Both halves of go/changelog fail against the release shapes the surrounding
toolchain actually produces. The test suites only round-trip the module's own
formatGroups output and linear history — a self-consistent blind spot.
1a. Parse() returns zero releases for releaser-pleaser changelogs¶
go/changelog/parse.go:16-19:
releaseHeaderRe = regexp.MustCompile(`^#\s+((?:v?\d+\.\d+\.\d+|Unreleased).*)`)
sectionHeaderRe = regexp.MustCompile(`^###?\s+(.+)`)
entryScopedRe = regexp.MustCompile(`^\*\s+\*\*([^*:]+):\*\*\s*(.+)`)
entryUnscopedRe = regexp.MustCompile(`^\*\s+(.+)`)
Release headings must be a single # with a bare version, and entries must be
* bullets. Every changelog releaser-pleaser produces — including this
module's own CHANGELOG.md — uses ## [v0.1.0](url) level-2, link-wrapped
headings and - bullets. Verified: Parse over go/controls/CHANGELOG.md
returns releases=0, from="", to="" — silently, with no error.
Consequence for self-update: GTB's update flow
(pkg/setup/update.go:1182,1201) calls ParseFromArchive/Parse on real
release notes, receives an empty Changelog, and HasBreakingChanges() is
therefore always false. The breaking-change warning — the parser's entire
reason to exist in the update path — silently never fires for the ecosystem's
own releases.
1b. A release tag on a merge commit is never detected as a boundary¶
go/changelog/generate.go:217-230 — in bucketCommits, merge commits are
skipped before the tag-boundary lookup:
// Skip merge commits (commits with more than one parent).
if c.NumParents() > 1 {
return nil
}
// If this commit is tagged, close the current group and start a new one.
if tagName, ok := tagByCommit[c.Hash]; ok { ... }
A tag pointing at a merge commit — the normal shape on any repo using non-FF
merges, e.g. GitLab merging a Release MR — never closes a group. Verified: a
repo with feat + fix commits, a tagged merge commit, and one post-tag
commit generates a changelog with no release heading at all; every
released commit is reported under # Unreleased. Relatedly, bucketing by
LogOrderCommitterTime linearises parallel branches by committer date, so a
commit merged after a tag but committed before it lands in the wrong release
even without merge-commit tags. generate_test.go:27-76 exercises only linear
history.
2. Proposed change¶
One fixture-driven rework across both halves.
Parser — accept the real-world grammar alongside the current one:
- Release headings:
#or##level; version optionally link-wrapped ([v1.2.3](url)); trailing date/annotation text still tolerated. Extract the version from the link text when present. - Entries: accept
-as well as*bullets in both the scoped and unscoped entry patterns. - Section mapping unchanged (
Features/Bug Fixes/Breaking Changesare already what releaser-pleaser emits). - Add real fixtures: verbatim copies of releaser-pleaser changelogs (e.g. this
module's and
go/controls'sCHANGELOG.md) checked in undertestdata/, plus the module's own emitted format to keep the round-trip property.
Generator — tag detection before merge-skip, then ancestry bucketing:
- Immediate correctness fix: perform the
tagByCommitlookup before the merge-commit skip so a tagged merge commit closes its group (its own message is still excluded from entries). - Structural fix: replace committer-time linearisation with ancestry-based
bucketing — order tags by semver, then walk
tagN-1..tagNcommit ranges (reachability difference), attributing each commit to the earliest release containing it.UnreleasedisHEAD..latest-tag's complement. This makes bucketing correct for parallel branches regardless of committer timestamps and removes the order-sensitivity entirely.
The parser change is backward compatible. The generator's output format is unchanged; only bucketing correctness improves.
3. Scope & release plan¶
- Module:
gitlab.com/phpboyscout/go/changelog(currently v0.1.0). Both fixes ship together asfix(parse):+fix(generate):commits; releaser-pleaser cuts one patch/minor release. - Downstream pickup: GTB consumes the module directly
(
go.mod: go/changelog v0.1.0) inpkg/setup/update.go(self-update breaking-change warning) andpkg/cmd/changelog(thechangelogcommand). A dep bump in GTB delivers both fixes; no GTB code change expected. The self-update warning path should get a GTB-side regression test asserting a releaser-pleaser-format changelog with aBreaking Changessection yieldsHasBreakingChanges() == true. - Docs: module docs gain a "supported changelog formats" note; GTB
docs/components/changelog page cross-checked.
4. Acceptance criteria¶
- Real releaser-pleaser changelog fixture:
Parseover a verbatim releaser-pleaserCHANGELOG.mdreturns the correct release count, versions (from/to), sections, and entries; a fixture containing a### Breaking Changessection makesHasBreakingChanges()true. - Legacy-format fixtures (single-
#,*bullets) still parse identically — no regression in the round-trip tests. - Merge-commit repo fixture: a generated test repo where the release tag
points at a merge commit (branch → non-FF merge → tag → further commits)
produces a changelog with the tagged release heading present, pre-tag
commits under it, and only post-tag commits under
Unreleased. - Branch-interleave fixture: a commit authored/committed before a tag but merged after it is bucketed into the later release (ancestry, not committer time).
- Two-tags-same-commit and tagged-root-commit edge cases produce deterministic output (documenting the chosen behaviour for the former).
- Existing linear-history generator tests remain green unchanged.
5. Open questions¶
- Ancestry bucketing vs. the minimal lookup-reorder: ship both in one release (recommended — the fixtures needed to prove the reorder already exercise ancestry), or land the one-line reorder as an immediate patch first?
- When a tagged merge commit's own message is a conventional commit (squash workflows), should it contribute an entry? Recommendation: no — keep merge-commit messages excluded from entries for consistency.
- Should
Parsereturn a sentinel error (rather than an empty changelog) when the input is non-empty but yields zero releases, so callers like self-update can distinguish "no changelog" from "unparseable format"?