From 6f73d25fdd936af0ae0948448af067492d60905b Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 7 Aug 2026 19:39:31 +0100
Subject: [PATCH 1/3] test(guards): hold the open changelog entry to the
version in the poms
The post-release step opens the next development line by incrementing the
patch unconditionally, so a minor release leaves the poms naming a different
version than the changelog for the whole cycle. While the two disagree, an
@since written in between has two answers available, and that is a public API
contract: across the 31 commits where the poms said 2.0.1-SNAPSHOT and the
changelog said v2.1.0, nine wrote @since 2.1.0 and four wrote @since 2.0.0.
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", which a check
keyed on "Planned" would have watched go past. The wording is then asserted
separately, since cut-release.ps1 dates an entry by replacing that literal and
leaves anything else undated.
The comparison is a pure function over (changelog, pomVersion), so its failing
branches are driven from strings instead of only ever being observed on the
repository's own files.
---
CHANGELOG.md | 26 +++
.../ChangelogVersionParsingTest.java | 152 ++++++++++++++++++
.../VersionConsistencyGuardTest.java | 124 ++++++++++++++
3 files changed, 302 insertions(+)
create mode 100644 core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42650851..4a25150f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,32 @@ 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. The wording is then held
+ to `Planned` separately, because `cut-release.ps1` dates an entry by matching that
+ literal and leaves anything else undated. Two open entries fail as well, being an
+ ambiguous answer rather than a wrong 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..79f6bc94
--- /dev/null
+++ b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java
@@ -0,0 +1,152 @@
+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 spelling 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 spellings here are not invented. {@code — in progress}, {@code — Unreleased} and
+ * {@code - unreleased} have all been used to open a line in this repository's history,
+ * and the 2.1.0 line was opened as {@code — in progress} while the poms named 2.0.1 —
+ * the drift the guard is for, in the wording that would have hidden it.
+ */
+class ChangelogVersionParsingTest {
+
+ private static final String POM = "2.1.2-SNAPSHOT";
+
+ // ── 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();
+ }
+
+ // ── What the check accepts ──────────────────────────────────────
+
+ @Test
+ void anOpenEntryAgreeingWithThePomIsAccepted() {
+ assertThat(VersionConsistencyGuardTest.versionDriftProblem(
+ "## v2.1.2 — Planned\n\n## v2.1.1 — 2026-08-05\n", POM))
+ .isNull();
+ }
+
+ @Test
+ void noOpenEntryIsAcceptedBecauseThePostReleaseBumpWritesNone() {
+ assertThat(VersionConsistencyGuardTest.versionDriftProblem(
+ "## v2.1.1 — 2026-08-05\n\n## v2.1.0 — 2026-07-26\n", POM))
+ .isNull();
+ }
+
+ @Test
+ void aReleaseCandidateAgreesWithTheLineItTargets() {
+ assertThat(VersionConsistencyGuardTest.versionDriftProblem(
+ "## v2.1.2 — Planned\n", "2.1.2-rc.1"))
+ .isNull();
+ }
+
+ // ── What the check reports ──────────────────────────────────────
+
+ @Test
+ void anOpenEntryNamingAnotherReleaseThanThePomIsReported() {
+ assertThat(VersionConsistencyGuardTest.versionDriftProblem(
+ "## v2.2.0 — Planned\n\n## v2.1.1 — 2026-08-05\n", POM))
+ .contains("2.2.0")
+ .contains(POM);
+ }
+
+ @Test
+ void aSecondOpenEntryIsReported() {
+ assertThat(VersionConsistencyGuardTest.versionDriftProblem(
+ "## v2.1.2 — Planned\n\n## v2.3.0 — Planned\n\n## v2.1.1 — 2026-08-05\n", POM))
+ .contains("ambiguous");
+ }
+
+ @Test
+ void anOpenEntryTheReleaseScriptCannotDateIsReported() {
+ // cut-release.ps1 replaces the literal "## vX.Y.Z — Planned" and notes-and-skips
+ // anything else, so a differently worded entry ships undated.
+ assertThat(VersionConsistencyGuardTest.versionDriftProblem(
+ "## v2.1.2 — in progress\n\n## v2.1.1 — 2026-08-05\n", POM))
+ .contains("in progress");
+ }
+
+ @Test
+ void anUndatedEntryBelowAShippedReleaseIsReported() {
+ assertThat(VersionConsistencyGuardTest.versionDriftProblem(
+ "## v2.1.1 — 2026-08-05\n\n## v1.9.0 — Planned\n", POM))
+ .contains("leftover");
+ }
+
+ // ── 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 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..86e555f1 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,94 @@ 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 marker) {
+ boolean isDated() {
+ return marker.matches("\\d{4}-\\d{2}-\\d{2}\\b.*");
+ }
+ }
+
+ /**
+ * 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 = Pattern.compile(
+ "^## v(\\d+\\.\\d+\\.\\d+)[ \\t]*(?:[\\u2014\\-][ \\t]*)?(.*)$", Pattern.MULTILINE)
+ .matcher(changelog);
+ List entries = new ArrayList<>();
+ while (heading.find()) {
+ entries.add(new ChangelogEntry(heading.group(1), heading.group(2).trim()));
+ }
+ return entries;
+ }
+
+ /**
+ * 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. The spelling is then held separately, because
+ * {@code cut-release.ps1} dates a heading by matching the literal
+ * {@code — Planned} and silently leaves any other wording undated.
+ */
+ static String versionDriftProblem(String changelog, String pomVersion) {
+ 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 (!"Planned".equals(entry.marker())) {
+ return ("the open CHANGELOG entry reads '## v%s — %s'; cut-release.ps1 dates a heading by matching "
+ + "the literal '— Planned', so this one would go out undated")
+ .formatted(entry.version(), entry.marker());
+ }
+
+ String pomLine = releaseLineOf(pomVersion);
+ if (!entry.version().equals(pomLine)) {
+ 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);
+ }
+ 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
From 916a0f2e1eef0853319e17c1909b18c3daf93919 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 8 Aug 2026 08:48:06 +0100
Subject: [PATCH 2/3] fix(guards): read a pre-release entry as one, and the
separator the cut matches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three shapes got past the check or tripped it wrongly.
A version stopped at X.Y.Z, so "## v2.2.0-rc.1 - 2026-09-01" parsed as version
2.2.0 on a line beginning "rc.1" — neither dated nor open in any useful sense.
A shipped pre-release therefore counted as a second open entry and would have
held the build red against a consistent changelog. The qualifier is part of the
version now, and both sides are compared by release line.
The wording check normalised the separator away, so "## v2.1.3 - Planned" with
an ASCII hyphen passed — the likeliest thing to type by hand, and the one shape
the cut cannot see, since it replaces the em-dash literal. It is now held to
what the cut actually matches, and "Planned (target)" is accepted rather than
rejected, because the cut does date it.
A "##" heading naming no release left the check with nothing to compare and
therefore green: "## Unreleased" and "## v2.2 - Planned" both passed. The
topmost one must now be a readable entry.
The drift is reported ahead of the wording, so a commit that gets both wrong
names the release the two sources disagree about rather than only the marker.
Getting the wording wrong does not ship an entry undated, as the note claimed:
Step 2b stops the cut on the missing date. It stops the release instead of the
commit that caused it, which is the smaller thing this actually buys.
---
CHANGELOG.md | 15 ++-
.../ChangelogVersionParsingTest.java | 106 +++++++++++++-----
.../VersionConsistencyGuardTest.java | 83 +++++++++++---
3 files changed, 158 insertions(+), 46 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a25150f..c6dd112c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,10 +49,17 @@ follow semantic versioning; release dates are ISO 8601.
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. The wording is then held
- to `Planned` separately, because `cut-release.ps1` dates an entry by matching that
- literal and leaves anything else undated. Two open entries fail as well, being an
- ambiguous answer rather than a wrong one.
+ 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
diff --git a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java
index 79f6bc94..8338f922 100644
--- a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java
+++ b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java
@@ -13,19 +13,21 @@
* {@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 spelling 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 spellings here are not invented. {@code — in progress}, {@code — Unreleased} and
- * {@code - unreleased} have all been used to open a line in this repository's history,
- * and the 2.1.0 line was opened as {@code — in progress} while the poms named 2.0.1 —
- * the drift the guard is for, in the wording that would have hidden it.
+ * 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 ──────────────────────────────────────
@@ -78,62 +80,112 @@ 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(VersionConsistencyGuardTest.versionDriftProblem(
- "## v2.1.2 — Planned\n\n## v2.1.1 — 2026-08-05\n", POM))
- .isNull();
+ assertThat(problem("## v2.1.2 — Planned" + SHIPPED, POM)).isNull();
}
@Test
void noOpenEntryIsAcceptedBecauseThePostReleaseBumpWritesNone() {
- assertThat(VersionConsistencyGuardTest.versionDriftProblem(
- "## v2.1.1 — 2026-08-05\n\n## v2.1.0 — 2026-07-26\n", POM))
- .isNull();
+ assertThat(problem("## v2.1.1 — 2026-08-05\n\n## v2.1.0 — 2026-07-26\n", POM)).isNull();
}
@Test
void aReleaseCandidateAgreesWithTheLineItTargets() {
- assertThat(VersionConsistencyGuardTest.versionDriftProblem(
- "## v2.1.2 — Planned\n", "2.1.2-rc.1"))
- .isNull();
+ 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(VersionConsistencyGuardTest.versionDriftProblem(
- "## v2.2.0 — Planned\n\n## v2.1.1 — 2026-08-05\n", POM))
+ assertThat(problem("## v2.2.0 — Planned" + SHIPPED, POM))
.contains("2.2.0")
.contains(POM);
}
@Test
void aSecondOpenEntryIsReported() {
- assertThat(VersionConsistencyGuardTest.versionDriftProblem(
- "## v2.1.2 — Planned\n\n## v2.3.0 — Planned\n\n## v2.1.1 — 2026-08-05\n", POM))
+ assertThat(problem("## v2.1.2 — Planned\n\n## v2.3.0 — Planned" + SHIPPED, POM))
.contains("ambiguous");
}
@Test
- void anOpenEntryTheReleaseScriptCannotDateIsReported() {
- // cut-release.ps1 replaces the literal "## vX.Y.Z — Planned" and notes-and-skips
- // anything else, so a differently worded entry ships undated.
- assertThat(VersionConsistencyGuardTest.versionDriftProblem(
- "## v2.1.2 — in progress\n\n## v2.1.1 — 2026-08-05\n", POM))
+ 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(VersionConsistencyGuardTest.versionDriftProblem(
- "## v2.1.1 — 2026-08-05\n\n## v1.9.0 — Planned\n", POM))
+ 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
@@ -143,6 +195,10 @@ void aSnapshotAndAReleaseCandidateShareTheirReleaseLine() {
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())
diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
index 86e555f1..b351cbe8 100644
--- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
+++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
@@ -645,9 +645,28 @@ private String latestPublishedRelease() throws Exception {
* 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 marker) {
+ 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 marker.matches("\\d{4}-\\d{2}-\\d{2}\\b.*");
+ 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;
}
}
@@ -660,16 +679,34 @@ boolean isDated() {
* {@code ChangelogVersionParsingTest} holds the shapes it must keep seeing.
*/
static List changelogEntriesIn(String changelog) {
- Matcher heading = Pattern.compile(
- "^## v(\\d+\\.\\d+\\.\\d+)[ \\t]*(?:[\\u2014\\-][ \\t]*)?(.*)$", Pattern.MULTILINE)
- .matcher(changelog);
+ Matcher heading = CHANGELOG_ENTRY.matcher(changelog);
List entries = new ArrayList<>();
while (heading.find()) {
- entries.add(new ChangelogEntry(heading.group(1), heading.group(2).trim()));
+ // 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
@@ -680,11 +717,24 @@ static List changelogEntriesIn(String changelog) {
* 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. The spelling is then held separately, because
- * {@code cut-release.ps1} dates a heading by matching the literal
- * {@code — Planned} and silently leaves any other wording undated.
+ * 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.
*/
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();
@@ -701,18 +751,17 @@ static String versionDriftProblem(String changelog, String pomVersion) {
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 (!"Planned".equals(entry.marker())) {
- return ("the open CHANGELOG entry reads '## v%s — %s'; cut-release.ps1 dates a heading by matching "
- + "the literal '— Planned', so this one would go out undated")
- .formatted(entry.version(), entry.marker());
- }
-
- String pomLine = releaseLineOf(pomVersion);
- if (!entry.version().equals(pomLine)) {
+ 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;
}
From 448151d1277148777290b02b973b230483ab8bdf Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 8 Aug 2026 08:59:23 +0100
Subject: [PATCH 3/3] docs(guards): record why the two gaps in the version
check are left open
Both read as oversights from the code alone: a mistyped heading level slips
through, and an entry and a pom that agree on the wrong release are accepted.
Closing the first by widening the entry pattern would start reading subsection
headings as releases, and the second is not closeable from these two sources at
all. The note says so where whoever edits the method will see it, rather than in
a pull request nobody reads twice.
---
.../VersionConsistencyGuardTest.java | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
index b351cbe8..2796ad58 100644
--- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
+++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
@@ -722,6 +722,23 @@ static List changelogEntriesIn(String changelog) {
*
* 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);