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 ListThe 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 { SetDeliberately 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 ListThe 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:
+ * + *