diff --git a/CHANGELOG.md b/CHANGELOG.md index 42650851..c6dd112c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,39 @@ follow semantic versioning; release dates are ISO 8601. cannot express. The remaining CV presets still slot by keyword and still discard what does not match; they are unchanged here. +### Build + +- **The open changelog entry and the development version cannot name different + releases.** The post-release step opens the next line by incrementing the patch, so a + GA of `X.Y.Z` always leaves the poms on `X.Y.(Z+1)-SNAPSHOT` — right when the next + release is a patch, wrong from the first commit when it is a minor. What that costs is + not tidiness. While the poms and the changelog name different releases, an `@since` + tag written in between has two answers available, and `@since` is a contract with the + consumer that outlives the cycle: the last time the two disagreed, tags went out + against both, and the ones that followed the previous release had to be corrected when + the line was. `VersionConsistencyGuardTest` now holds the poms to the open entry, which + is where the next version gets recorded first, so there is one answer to take. + + An entry counts as open because it carries no date, not because of the word after the + version — the 2.1.0 line was opened as `— in progress`, and a check that recognised + only one spelling would have watched that whole line go by. For the same reason a `##` + heading that names no release is reported rather than skipped: leaving the check with + nothing to compare must not look like agreement. Two open entries fail as well, being + an ambiguous answer rather than a wrong one. + + The wording is then held to the exact `— Planned` the cut replaces, em dash included, + since an ASCII hyphen is as invisible to that replacement as another word would be. + Getting it wrong does not ship an undated entry — the cut stops on the missing date — + but it stops the release rather than the commit that introduced it, and by then the + cause is a step away. A version keeps its pre-release qualifier throughout, so a dated + `-rc.N` entry reads as shipped instead of as a second open one. + + Having no open entry passes: the post-release bump writes none and runs this guard as + its own gate, so demanding one would fail the commit that opens the window. The check + begins with the cycle's first entry. It compares the two recorded answers against each + other, so it catches one being corrected without the other — not a pair that was wrong + together from the start. + ## v2.1.1 — 2026-08-05 ### Build diff --git a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java new file mode 100644 index 00000000..8338f922 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java @@ -0,0 +1,208 @@ +package com.demcha.documentation; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Drives {@link VersionConsistencyGuardTest}'s CHANGELOG check with entries and pom + * versions the repository's own files do not currently hold. + * + *

{@link VersionConsistencyGuardTest#theOpenChangelogEntryNamesTheVersionUnderDevelopment} + * reads the real {@code CHANGELOG.md} and the real pom, which agree — so on their own + * they exercise the passing branch and nothing else. Neither the mismatch nor the + * ambiguity the check exists to report is ever reached there, and an entry shape the + * parser stops recognising does not turn the check red either: it leaves it with nothing + * to compare, which reads exactly like success. That is the failure this class exists to + * make impossible, so each entry shape and each rejection gets a case of its own.

+ * + *

The shapes here are not invented. {@code — in progress}, {@code — Unreleased} and + * {@code - unreleased} have all opened a line in this repository's history; the 2.1.0 + * line was opened as {@code — in progress} while the poms named 2.0.1. Pre-release + * headings are equally real — twenty-nine {@code ## v1.5.0-beta.N} entries were written + * during the 1.5.0 cycle.

+ */ +class ChangelogVersionParsingTest { + + private static final String POM = "2.1.2-SNAPSHOT"; + private static final String SHIPPED = "\n\n## v2.1.1 — 2026-08-05\n"; + + // ── Which entries are open ────────────────────────────────────── + + @Test + void anEntryWithoutADateIsOpen() { + assertThat(open(""" + # Changelog + + ## v2.1.2 — Planned + + ### Build + """)).containsExactly("2.1.2"); + } + + @Test + void aDatedEntryIsNotOpen() { + assertThat(open("## v2.1.1 — 2026-08-05\n\n### Build\n")).isEmpty(); + } + + @Test + void anEntrySpelledInProgressIsStillOpen() { + // The 2.1.0 line was opened this way and ran 31 commits against poms that named + // 2.0.1. Matching only "Planned" would have seen no open entry and stayed green. + assertThat(open("## v2.1.0 — in progress\n")).containsExactly("2.1.0"); + } + + @Test + void anEntrySpelledUnreleasedIsStillOpen() { + assertThat(open("## v2.1.0 — Unreleased\n")).containsExactly("2.1.0"); + assertThat(open("## v2.1.0 - unreleased\n")).containsExactly("2.1.0"); + } + + @Test + void anEntryWithNoMarkerAtAllIsOpen() { + assertThat(open("## v2.1.0\n")).containsExactly("2.1.0"); + } + + @Test + void aDecoratedMarkerDoesNotHideTheEntry() { + assertThat(open("## v2.2.0 — Planned (target)\n")).containsExactly("2.2.0"); + } + + @Test + void carriageReturnsDoNotHideTheEntry() { + assertThat(open("# Changelog\r\n\r\n## v2.2.0 — Planned\r\n")).containsExactly("2.2.0"); + } + + @Test + void aHeadingThatNamesNoVersionIsNotAnEntry() { + assertThat(open("## Unreleased — Planned\n\n### Build\n")).isEmpty(); + } + + // ── Pre-release entries keep their qualifier ──────────────────── + + @Test + void aDatedPreReleaseEntryIsShippedRatherThanOpen() { + // Truncating the version at the hyphen would read this as version 2.2.0 with a + // line starting "rc.1", hence undated, hence a second open entry holding the + // build red against a changelog that is perfectly consistent. + assertThat(open("## v2.2.0-rc.1 — 2026-09-01\n" + SHIPPED)).isEmpty(); + assertThat(open("## v1.5.0-beta.3 — 2026-02-01\n")).isEmpty(); + } + + @Test + void anOpenPreReleaseEntryKeepsItsQualifier() { + assertThat(open("## v2.2.0-rc.1 — Planned\n")).containsExactly("2.2.0-rc.1"); + } + + @Test + void aPreReleaseEntryAgreesWithThePomCuttingIt() { + assertThat(problem("## v2.2.0-rc.1 — Planned\n" + SHIPPED, "2.2.0-rc.1")).isNull(); + } + + @Test + void aDateFurtherAlongTheLineDoesNotMakeAnEntryShipped() { + assertThat(open("## v2.1.2 — Planned, superseding 2026-01-01\n")).containsExactly("2.1.2"); + } + + // ── What the check accepts ────────────────────────────────────── + + @Test + void anOpenEntryAgreeingWithThePomIsAccepted() { + assertThat(problem("## v2.1.2 — Planned" + SHIPPED, POM)).isNull(); + } + + @Test + void noOpenEntryIsAcceptedBecauseThePostReleaseBumpWritesNone() { + assertThat(problem("## v2.1.1 — 2026-08-05\n\n## v2.1.0 — 2026-07-26\n", POM)).isNull(); + } + + @Test + void aReleaseCandidateAgreesWithTheLineItTargets() { + assertThat(problem("## v2.1.2 — Planned" + SHIPPED, "2.1.2-rc.1")).isNull(); + } + + @Test + void aDecoratedPlannedMarkerIsAcceptedBecauseTheCutStillDatesIt() { + // Step 2 replaces the matched "## v2.1.2 — Planned" and leaves the tail in place, + // so this entry does get dated — rejecting it would be a false alarm. + assertThat(problem("## v2.1.2 — Planned (target)" + SHIPPED, POM)).isNull(); + } + + // ── What the check reports ────────────────────────────────────── + + @Test + void anOpenEntryNamingAnotherReleaseThanThePomIsReported() { + assertThat(problem("## v2.2.0 — Planned" + SHIPPED, POM)) + .contains("2.2.0") + .contains(POM); + } + + @Test + void aSecondOpenEntryIsReported() { + assertThat(problem("## v2.1.2 — Planned\n\n## v2.3.0 — Planned" + SHIPPED, POM)) + .contains("ambiguous"); + } + + @Test + void anAsciiHyphenIsNotTheSeparatorTheCutMatches() { + // The likeliest thing to type, and the one the release script cannot see: it + // replaces the literal em-dash form. + assertThat(problem("## v2.1.2 - Planned" + SHIPPED, POM)) + .contains("Planned"); + } + + @Test + void anOpenEntryTheCutCannotDateIsReported() { + assertThat(problem("## v2.1.2 — in progress" + SHIPPED, POM)) + .contains("in progress"); + } + + @Test + void theDriftIsReportedAheadOfTheWording() { + // Both are wrong here. The release the two sources disagree about is the finding; + // being told only about the marker would bury it. + assertThat(problem("## v2.2.0 - in progress" + SHIPPED, POM)) + .contains("2.2.0") + .contains(POM); + } + + @Test + void anUndatedEntryBelowAShippedReleaseIsReported() { + assertThat(problem("## v2.1.1 — 2026-08-05\n\n## v1.9.0 — Planned\n", POM)) + .contains("leftover"); + } + + // ── Headings the check cannot read are reported, not skipped ──── + + @Test + void aTopmostHeadingNamingNoReleaseIsReported() { + assertThat(problem("## Unreleased" + SHIPPED, POM)).contains("names no release"); + } + + @Test + void aTwoComponentVersionIsReported() { + assertThat(problem("## v2.2 — Planned" + SHIPPED, POM)).contains("names no release"); + } + + // ── Release lines ─────────────────────────────────────────────── + + @Test + void aSnapshotAndAReleaseCandidateShareTheirReleaseLine() { + assertThat(VersionConsistencyGuardTest.releaseLineOf("2.2.0-SNAPSHOT")).isEqualTo("2.2.0"); + assertThat(VersionConsistencyGuardTest.releaseLineOf("2.2.0-rc.1")).isEqualTo("2.2.0"); + assertThat(VersionConsistencyGuardTest.releaseLineOf("2.2.0")).isEqualTo("2.2.0"); + } + + private static String problem(String changelog, String pomVersion) { + return VersionConsistencyGuardTest.versionDriftProblem(changelog, pomVersion); + } + + private static List open(String changelog) { + return VersionConsistencyGuardTest.changelogEntriesIn(changelog).stream() + .filter(entry -> !entry.isDated()) + .map(VersionConsistencyGuardTest.ChangelogEntry::version) + .toList(); + } +} diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java index 19e3bf65..2796ad58 100644 --- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -269,6 +269,42 @@ void readmeReleaseStatusNamesAPublishedVersion() throws Exception { .endsWith("/releases/tag/v" + stable.group(1)); } + /** + * The open {@code CHANGELOG.md} entry and the in-development pom version name the + * same release line. + * + *

The post-release step opens the next line by incrementing the patch + * unconditionally, so a GA of {@code X.Y.Z} always leaves the train on + * {@code X.Y.(Z+1)-SNAPSHOT}. When the next release turns out to be a minor, the + * poms and the CHANGELOG name different releases for the rest of the cycle, and an + * {@code @since} tag written meanwhile has two answers to choose between. It is a + * public API contract, so whichever the author picks outlives the cycle: the last + * time these two disagreed, tags were written against both — most against the + * CHANGELOG heading, the rest against the previous release, and the latter had to + * be retagged when the line was corrected.

+ * + *

What this pins is that correction. The real next version is recorded in the + * CHANGELOG heading first, because that is where the cycle's opening entry goes; + * from that commit on, the build stays red until the poms name the same line. What + * it cannot catch is a heading and a pom that are wrong together — both + * states are internally consistent, and nothing in the tree distinguishes them.

+ * + *

Having no open entry at all passes. The post-release bump writes no heading and + * runs this test as its own gate before committing, so requiring one here would fail + * the very commit that opens the window. The entry arrives with the cycle's first + * CHANGELOG addition and is held from then on.

+ */ + @Test + void theOpenChangelogEntryNamesTheVersionUnderDevelopment() throws Exception { + String changelog = Files.readString(PROJECT_ROOT.resolve("CHANGELOG.md")); + String pomVersion = effectiveVersion(PROJECT_ROOT.resolve("core/pom.xml")); + + assertThat(versionDriftProblem(changelog, pomVersion)) + .describedAs("the CHANGELOG entry left open and the working pom version must name the " + + "same release, or an @since written this cycle has two answers to pick from") + .isNull(); + } + @Test void readmeInstallSnippetsMatchTheProjectVersion() throws Exception { Set targets = acceptableTargets(); @@ -605,6 +641,160 @@ private String latestPublishedRelease() throws Exception { return released.group(1); } + /** + * One {@code ## vX.Y.Z — } entry: the release line it names, and whatever + * follows the version on that line — a date once shipped, anything else while open. + */ + record ChangelogEntry(String version, String remainder) { + + /** + * Shipped. The shape the release script itself accepts as dated, separator + * included — a version is not read as released on the strength of a stray date + * further along the line. + */ + boolean isDated() { + return remainder.matches("[ \\t]*[\\u2014\\-][ \\t]*\\d{4}-\\d{2}-\\d{2}\\b.*"); + } + + /** + * Open and in the one form the cut can date: {@code cut-release.ps1} + * replaces the literal {@code "## vX.Y.Z — Planned"}, em dash and all, so an + * ASCII hyphen is as invisible to it as another word would be. + */ + boolean isDatableByTheCut() { + return remainder.matches("[ \\t]*\\u2014[ \\t]*Planned\\b.*"); + } + + String heading() { + return "## v" + version + remainder; + } + } + + /** + * Every {@code ## vX.Y.Z} entry in {@code changelog}, in file order. + * + *

Deliberately drivable from a string rather than reading the file: an entry this + * stops recognising is one {@link #versionDriftProblem} cannot compare, and a guard + * that compares nothing is green forever without having failed once. + * {@code ChangelogVersionParsingTest} holds the shapes it must keep seeing.

+ */ + static List changelogEntriesIn(String changelog) { + Matcher heading = CHANGELOG_ENTRY.matcher(changelog); + List entries = new ArrayList<>(); + while (heading.find()) { + // stripTrailing, not trim: the leading run carries the separator, which is + // the part the cut matches on — and it also drops the CR of a CRLF file, + // which would otherwise defeat every `.*` in the shape checks below. + entries.add(new ChangelogEntry(heading.group(1), heading.group(2).stripTrailing())); + } + return entries; + } + + /** + * A released-or-open entry: {@code ## v} then a version, then the rest of the line. + * + *

The version carries its pre-release qualifier. Stopping at {@code X.Y.Z} would + * read {@code ## v2.2.0-rc.1 — 2026-09-01} as version {@code 2.2.0} whose line + * begins {@code rc.1}, which is neither dated nor open in any useful sense — a + * shipped pre-release would be counted as a second open entry and hold the build + * red. Twenty-nine {@code ## v1.5.0-beta.N} headings exist in this repository's + * history.

+ */ + private static final Pattern CHANGELOG_ENTRY = Pattern.compile( + "^## v(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.]+)?)([^\\n]*)$", Pattern.MULTILINE); + + /** Any {@code ##}-level heading — exactly two hashes, at the start of a line. */ + private static final Pattern SECTION_HEADING = + Pattern.compile("^##(?!#)[^\\n]*$", Pattern.MULTILINE); + + /** + * What is wrong between the open {@code CHANGELOG.md} entry and {@code pomVersion}, + * or {@code null} when the two agree — pure, so the red paths can be driven from + * strings instead of only ever being observed on the repository's own files. + * + *

An entry counts as open because it carries no date, never because of + * the word after the version. Recognising one spelling and skipping the others is + * how this check would go quiet for a whole development line rather than fail: the + * 2.1.0 line was opened as {@code — in progress} and stayed that way for 31 commits + * while the poms named a different release, which is precisely the drift this + * exists to catch. For the same reason a {@code ##} heading it cannot read at all is + * reported rather than skipped — silence and success must not look alike.

+ * + *

The drift is reported before the wording, because the release the two sources + * disagree about is the finding; the wording is a smaller, separate problem.

+ * + *

Two limits are deliberate, and both look like weaknesses worth "fixing" until + * you know why they are there:

+ * + *
    + *
  • A heading whose level is mistyped ({@code ### v2.2.0 — Planned}) or which is + * indented stays invisible. Neither has occurred here, and matching them means + * recognising headings the format does not produce. If one ever does appear the + * symptom is a silently green check, so the repair is to widen the + * topmost-heading test above — not the entry pattern, which would start reading + * subsection headings as releases.
  • + *
  • An entry and a pom that were wrong together from the start cannot be + * caught. That is the v2.0.1 shape: both internally consistent, nothing in the + * tree to tell them apart, and no amount of cross-checking these two sources + * recovers a third opinion neither of them holds. What is catchable is one of + * them being corrected without the other, which is how the correction arrives.
  • + *
+ */ + static String versionDriftProblem(String changelog, String pomVersion) { + Matcher firstSection = SECTION_HEADING.matcher(changelog); + if (firstSection.find()) { + String heading = firstSection.group().stripTrailing(); + if (!CHANGELOG_ENTRY.matcher(heading).matches()) { + return ("the topmost '##' heading in CHANGELOG.md reads '%s', which names no release. " + + "Entries are read as '## vX.Y.Z — …'; anything else leaves this check with " + + "nothing to compare, which would pass for the wrong reason") + .formatted(heading); + } + } + + List entries = changelogEntriesIn(changelog); + List open = entries.stream().filter(entry -> !entry.isDated()).toList(); + + if (open.isEmpty()) { + return null; + } + if (open.size() > 1) { + return "CHANGELOG.md leaves %d entries undated (%s) — two open entries leave the next release ambiguous" + .formatted(open.size(), open.stream().map(ChangelogEntry::version).toList()); + } + + ChangelogEntry entry = open.get(0); + if (!entries.get(0).equals(entry)) { + return "the open CHANGELOG entry (v%s) sits below a shipped release — an undated entry under a dated one is a leftover, not the next line" + .formatted(entry.version()); + } + if (!releaseLineOf(entry.version()).equals(releaseLineOf(pomVersion))) { + return ("the open CHANGELOG entry (v%s) and the working pom version (%s) name different releases — " + + "whichever is right, the other was left behind") + .formatted(entry.version(), pomVersion); + } + if (!entry.isDatableByTheCut()) { + return ("the open CHANGELOG entry reads '%s'. The cut dates an entry by replacing the literal " + + "'— Planned', em dash included, so this one matches nothing and the cut then stops on " + + "the missing date — failing here names the cause, failing there does not") + .formatted(entry.heading()); + } + return null; + } + + /** + * The {@code X.Y.Z} release line a working version belongs to. {@code 2.2.0-SNAPSHOT} + * and {@code 2.2.0-rc.1} both target {@code 2.2.0}, which is the version the open + * CHANGELOG heading names while either sits in the poms. + */ + static String releaseLineOf(String version) { + Matcher line = Pattern.compile("^(\\d+\\.\\d+\\.\\d+)").matcher(version); + assertThat(line.find()) + .describedAs("the working pom version (%s) must start with an X.Y.Z release line", version) + .isTrue(); + return line.group(1); + } + /** * Returns the captured group of the first regex in {@code patterns} that * matches. Fails with a descriptive message listing every pattern tried if