feat: use reference-style links in changelog instead of inline links

Moves changelog link generation from inline heading format
  ## [1.1.0](https://...) - date
to reference-style definitions at the bottom of the file
  ## [1.1.0] - date
  ...
  [Unreleased]: https://.../src/branch/main
  [1.1.0]: https://.../releases/tag/v1.1.0

This keeps headings plain, which simplifies changelog parsing (the
awk pattern in publish/action.yml now matches without special-casing
the inline URL), and follows the canonical Keep a Changelog style.
This commit is contained in:
Micheal Wilkinson
2026-03-20 23:07:10 +00:00
parent 0234df7aa1
commit 3f7edea46e
2 changed files with 38 additions and 12 deletions

View File

@@ -25,6 +25,7 @@ var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
var linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `) var linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
var unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`) var unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`)
var releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `) var releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
var refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`)
type Options struct { type Options struct {
// VersionFile is the path to the file that stores the current version, // VersionFile is the path to the file that stores the current version,
@@ -412,19 +413,38 @@ func addChangelogLinks(text, repoURL string) string {
return text return text
} }
mainURL := repoURL + "/src/branch/main" // Normalize headings to plain format, stripping any existing inline links.
text = unreleasedHeadingRe.ReplaceAllString(text, fmt.Sprintf("## [Unreleased](%s)\n", mainURL)) text = unreleasedHeadingRe.ReplaceAllString(text, "## [Unreleased]\n")
text = releaseHeadingRe.ReplaceAllStringFunc(text, func(match string) string { text = releaseHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
parts := releaseHeadingRe.FindStringSubmatch(match) parts := releaseHeadingRe.FindStringSubmatch(match)
if len(parts) < 2 { if len(parts) < 2 {
return match return match
} }
version := parts[1] version := parts[1]
return fmt.Sprintf("## [%s](%s/releases/tag/v%s) - ", version, repoURL, version) return fmt.Sprintf("## [%s] - ", version)
}) })
return text // Strip any trailing reference link block (blank lines followed by ref link lines).
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
cutAt := len(lines)
for i := len(lines) - 1; i >= 0; i-- {
if strings.TrimSpace(lines[i]) == "" || refLinkLineRe.MatchString(lines[i]) {
cutAt = i
} else {
break
}
}
text = strings.Join(lines[:cutAt], "\n") + "\n"
// Build and append reference link definitions.
linkDefs := []string{fmt.Sprintf("[Unreleased]: %s/src/branch/main", repoURL)}
for _, match := range releasedSectionRe.FindAllStringSubmatch(text, -1) {
if len(match) >= 2 {
linkDefs = append(linkDefs, fmt.Sprintf("[%s]: %s/releases/tag/v%s", match[1], repoURL, match[1]))
}
}
return strings.TrimRight(text, "\n") + "\n\n" + strings.Join(linkDefs, "\n") + "\n"
} }
func parseSemver(version string) (semver, error) { func parseSemver(version string) (semver, error) {

View File

@@ -54,7 +54,7 @@ func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md")) changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
require.NoError(s.T(), err) require.NoError(s.T(), err)
require.Equal(s.T(), "# Changelog\n\n## [Unreleased](https://git.hrafn.xyz/aether/vociferate/src/branch/main)\n\n## [1.1.7](https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.7) - 2026-03-20\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6](https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.6) - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", string(changelogBytes)) require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n## [1.1.7] - 2026-03-20\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n\n[Unreleased]: https://git.hrafn.xyz/aether/vociferate/src/branch/main\n[1.1.7]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.7\n[1.1.6]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.6\n", string(changelogBytes))
} }
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() { func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
@@ -244,9 +244,12 @@ func (s *PrepareSuite) TestPrepare_UsesGitHrafnXYZEnvironmentForChangelogLinks()
require.NoError(s.T(), readErr) require.NoError(s.T(), readErr)
changelog := string(changelogBytes) changelog := string(changelogBytes)
require.Contains(s.T(), changelog, "## [Unreleased](https://git.hrafn.xyz/aether/vociferate/src/branch/main)") require.Contains(s.T(), changelog, "## [Unreleased]\n")
require.Contains(s.T(), changelog, "## [1.1.7](https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.7) - 2026-03-20") require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
require.Contains(s.T(), changelog, "## [1.1.6](https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.6) - 2017-12-20") require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20")
require.Contains(s.T(), changelog, "[Unreleased]: https://git.hrafn.xyz/aether/vociferate/src/branch/main")
require.Contains(s.T(), changelog, "[1.1.7]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.7")
require.Contains(s.T(), changelog, "[1.1.6]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.6")
} }
func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() { func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
@@ -260,7 +263,10 @@ func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
require.NoError(s.T(), readErr) require.NoError(s.T(), readErr)
changelog := string(changelogBytes) changelog := string(changelogBytes)
require.Contains(s.T(), changelog, "## [Unreleased](https://github.com/aether/vociferate/src/branch/main)") require.Contains(s.T(), changelog, "## [Unreleased]\n")
require.Contains(s.T(), changelog, "## [1.1.7](https://github.com/aether/vociferate/releases/tag/v1.1.7) - 2026-03-20") require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
require.Contains(s.T(), changelog, "## [1.1.6](https://github.com/aether/vociferate/releases/tag/v1.1.6) - 2017-12-20") require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20")
require.Contains(s.T(), changelog, "[Unreleased]: https://github.com/aether/vociferate/src/branch/main")
require.Contains(s.T(), changelog, "[1.1.7]: https://github.com/aether/vociferate/releases/tag/v1.1.7")
require.Contains(s.T(), changelog, "[1.1.6]: https://github.com/aether/vociferate/releases/tag/v1.1.6")
} }