Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions internal/guard/changelogshape_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package guard
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
Expand Down Expand Up @@ -99,3 +100,115 @@ func TestTheChangelogHasOneHeadingPerKindOfChange(t *testing.T) {
t.Errorf("the first release heading is %q and docs/GIT.md puts [Unreleased] at the top", order[0])
}
}

// Every version in the changelog is linkable, and Unreleased compares from the
// newest one.
//
// What this defends against happened on 2026-09-03, closing the release of
// 0.3.0-rc1. A "## [0.3.0-rc1]" heading went in and the link definitions at the
// bottom were left as they were: the new version had none, so the heading
// rendered as literal square brackets, and [Unreleased] still compared from
// v0.2.0 - which means it showed the release's own changes as if they were
// still coming. Nothing went red. It was found by reading, and the reading
// happened to be looking for something else.
//
// Keep a Changelog is declared at the top of the file and in docs/GIT.md §3, so
// this is a convention the project has written down and nothing was holding it.
// The same class as O178, and closed on the same day.
//
// The oldest version points at its release tag rather than at a comparison,
// because there is nothing before it to compare from. That is why only the
// presence of a definition is required here, and only Unreleased has its target
// checked.
func TestEveryChangelogVersionHasItsLinkDefinition(t *testing.T) {
path := filepath.Join(repoRoot(t), "CHANGELOG.md")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading the changelog: %v", err)
}

var headings []string
defined := map[string]string{}
inFence := false
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "```") {
inFence = !inFence
continue
}
if inFence {
continue
}
if strings.HasPrefix(line, "## [") {
if name, ok := bracketed(line[len("## "):]); ok {
headings = append(headings, name)
}
continue
}
// A link definition is a bracketed name, a colon and a target, at the
// left margin. Anywhere else on a line it is a link being used.
if strings.HasPrefix(line, "[") {
if name, ok := bracketed(line); ok {
rest := strings.TrimPrefix(line[len(name)+2:], ":")
if strings.HasPrefix(line[len(name)+2:], ":") {
defined[name] = strings.TrimSpace(rest)
}
}
}
}

if len(headings) == 0 {
t.Fatal("the changelog has no version headings at all, so this guard is reading the wrong file")
}

for _, name := range headings {
if _, ok := defined[name]; !ok {
t.Errorf("the %q section has no link definition at the bottom of the changelog.\n"+
"Reason: Keep a Changelog makes every version linkable, and this file declares that\n"+
"format in its own header. Without the definition the heading renders as literal\n"+
"square brackets and the reader has no way to the diff.\n"+
"What to do: add a line like [%s]: <compare URL> beside the others at the bottom.",
name, name)
}
}
for name := range defined {
if !slices.Contains(headings, name) {
t.Errorf("the bottom of the changelog defines a link for %q and there is no such section.\n"+
"Reason: a definition left behind after a section is renamed or removed points at a\n"+
"comparison nobody can reach from the document.", name)
}
}

// Unreleased compares from the newest released version. Getting this wrong
// is worse than leaving it out, because the link works: it just shows a
// released version's own changes as if they were still coming.
if len(headings) < 2 {
return
}
newest := headings[1]
target, ok := defined["Unreleased"]
if !ok {
return // already reported above
}
want := "compare/v" + newest + "...HEAD"
if !strings.HasSuffix(target, want) {
t.Errorf("[Unreleased] compares from something other than the newest version.\n"+
"Reason: the newest section is %q, so the link should end in %q, and it is %q.\n"+
"This went wrong on 0.3.0-rc1: it still compared from v0.2.0, so everything the\n"+
"release shipped showed up under Unreleased as if it were still coming.\n"+
"What to do: point it at the tag for %s.",
newest, want, target, newest)
}
}

// bracketed reads the name out of a line that opens with a square bracket, and
// says whether there was one to read.
func bracketed(line string) (string, bool) {
if !strings.HasPrefix(line, "[") {
return "", false
}
end := strings.Index(line, "]")
if end < 2 {
return "", false
}
return line[1:end], true
}
Loading
Loading