diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ab27287..47564c63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -122,6 +122,21 @@ follow semantic versioning; release dates are ISO 8601.
as "latest stable", behind a release link that 404s. `cut-release.ps1` now
promotes the in-development half to latest stable and opens the next patch line
as part of the release commit, and verifies the result after the mutation.
+- **An install snippet in the documentation moves with the release.** The version
+ guard covered the README, the module READMEs and the showcase site, and stopped
+ there. The troubleshooting page carries the two snippets a reader copies at the
+ worst possible moment — when a session already refuses to start — and both had
+ sat on the previous minor since 2.0, handing out a render backend one minor
+ behind the engine that reader was running. The guard now walks `docs/`, skipping
+ the trees that pin an old version on purpose — migration guides, archived pages,
+ shipped roadmaps and the like — by path prefix rather than by a list someone has
+ to remember to extend. A second check closes the same gap one step earlier: a page
+ that carries such a snippet must be named in both of the release script's lists —
+ the one that rewrites the version and the one that stages the file — so a new page
+ cannot quietly keep the old version through every future cut. The bumper itself no
+ longer relies on a file naming a single coordinate: it now skips
+ `graph-compose-fonts` and `graph-compose-emoji` wherever they appear, since those
+ ship on their own release lines.
### Fixed
diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
index 52a24242..7a055d36 100644
--- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
+++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
@@ -11,11 +11,15 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -344,6 +348,178 @@ void companionReadmeInstallSnippetsMatchTheirPomVersions() throws Exception {
}
}
+ /**
+ * Install snippets published under {@code docs/} advertise a version a reader
+ * can resolve.
+ *
+ *
The root README, the module READMEs and the showcase site were already
+ * pinned; the docs tree was not, and that is where a backend coordinate sat a
+ * minor behind everything else — a reader who opened the troubleshooting page
+ * because a session would not start was handed a {@code graph-compose-render-pdf}
+ * one minor older than the engine they were running.
+ *
+ * Historical trees are excluded by path prefix rather than by a
+ * file allowlist: migration guides, archived pages and shipped roadmaps pin an
+ * old version on purpose, and a new one of those is covered the day it is
+ * written instead of the day someone remembers to extend a list.
+ */
+ @Test
+ void documentationInstallSnippetsMatchTheProjectVersion() throws Exception {
+ Set trainTargets = acceptableTargets();
+ List coordinates = documentationCoordinates();
+ List drift = new ArrayList<>();
+
+ for (DocCoordinate coordinate : coordinates) {
+ Set expected = expectedVersionsFor(coordinate.artifact(), trainTargets);
+ if (!expected.contains(coordinate.version())) {
+ drift.add("%s:%d %s advertises %s, expected one of %s".formatted(
+ coordinate.page(), coordinate.line(), coordinate.artifact(), coordinate.version(), expected));
+ }
+ }
+
+ assertThat(coordinates)
+ .describedAs("no versioned install coordinate found under docs/ — this guard would cover nothing")
+ .isNotEmpty();
+ assertThat(drift)
+ .describedAs("every versioned install snippet under docs/ must advertise the version a reader can resolve today")
+ .isEmpty();
+ }
+
+ /**
+ * Every documentation page carrying a train install snippet is both bumped and
+ * staged by {@code cut-release.ps1}.
+ *
+ * {@link #documentationInstallSnippetsMatchTheProjectVersion()} catches a stale
+ * snippet, but only once it is already stale — one release after the page was
+ * added. This closes the gap at the source: the script rewrites a hand-maintained
+ * list of pages, and a page that carries a snippet without being on that list
+ * silently keeps the previous version through every future cut. Both halves are
+ * required, because bumping a file the commit never stages leaves the release tag
+ * carrying the old text and the working tree carrying the new.
+ */
+ @Test
+ void documentationPagesWithInstallSnippetsAreBumpedByTheReleaseScript() throws Exception {
+ Set pagesWithSnippets = new LinkedHashSet<>();
+ for (DocCoordinate coordinate : documentationCoordinates()) {
+ // A companion snippet rides its own release line, so an engine cut must
+ // never touch the page on its account.
+ if (!isCompanionArtifact(coordinate.artifact())) {
+ pagesWithSnippets.add(coordinate.page());
+ }
+ }
+ String script = Files.readString(PROJECT_ROOT.resolve("scripts/cut-release.ps1"));
+
+ List> lists = docPageLists(script);
+ assertThat(lists)
+ .describedAs("cut-release.ps1 must declare a `foreach ($docPage in @(...))` list where it "
+ + "bumps the pages and another where it stages them")
+ .hasSize(2);
+
+ for (String page : pagesWithSnippets) {
+ assertThat(lists.get(0))
+ .describedAs("%s carries a train install snippet, so cut-release.ps1 must bump it", page)
+ .contains(page);
+ assertThat(lists.get(1))
+ .describedAs("%s is bumped by cut-release.ps1, so the release commit must stage it", page)
+ .contains(page);
+ }
+ }
+
+ /** One versioned GraphCompose coordinate found in a documentation page. */
+ private record DocCoordinate(String page, int line, String artifact, String version) {
+ }
+
+ /**
+ * Every versioned GraphCompose coordinate under {@code docs/}, skipping the trees
+ * that pin an old version on purpose. Both documentation guards read this.
+ */
+ private static List documentationCoordinates() throws IOException {
+ Path docs = PROJECT_ROOT.resolve("docs");
+ List found = new ArrayList<>();
+ List pages;
+ try (Stream tree = Files.walk(docs)) {
+ pages = tree.filter(Files::isRegularFile)
+ .filter(page -> page.getFileName().toString().endsWith(".md"))
+ .filter(page -> !pinsAnOldVersionOnPurpose(docs.relativize(page)))
+ .sorted()
+ .toList();
+ }
+ for (Path page : pages) {
+ String text = Files.readString(page);
+ String name = PROJECT_ROOT.relativize(page).toString().replace('\\', '/');
+ for (Pattern pattern : List.of(DOCS_MAVEN_COORDINATE, DOCS_GRADLE_COORDINATE)) {
+ Matcher coordinate = pattern.matcher(text);
+ while (coordinate.find()) {
+ found.add(new DocCoordinate(name, lineOf(text, coordinate.start()),
+ coordinate.group(1), coordinate.group(2).trim()));
+ }
+ }
+ }
+ return found;
+ }
+
+ /** The {@code $docPage} lists in {@code cut-release.ps1}, in source order: bump first, staging second. */
+ private static List> docPageLists(String script) {
+ List> lists = new ArrayList<>();
+ Matcher loop = Pattern.compile("foreach \\(\\$docPage in @\\(([^)]*)\\)\\)").matcher(script);
+ while (loop.find()) {
+ Set paths = new LinkedHashSet<>();
+ Matcher quoted = Pattern.compile("'([^']+)'").matcher(loop.group(1));
+ while (quoted.find()) {
+ paths.add(quoted.group(1));
+ }
+ lists.add(paths);
+ }
+ return lists;
+ }
+
+ /** Historical doc trees, which pin an old version deliberately. Relative to {@code docs/}. */
+ private static final String[] DOCS_PINNED_ON_PURPOSE = {
+ "archive/", "roadmaps/", "migration/", "private/", "templates/v1-classic/"
+ };
+
+ private static final Pattern DOCS_MAVEN_COORDINATE = Pattern.compile(
+ "(graph-compose[\\w-]*)\\s*v?([0-9][^<]*)");
+ private static final Pattern DOCS_GRADLE_COORDINATE = Pattern.compile(
+ "io\\.github\\.demchaav:(graph-compose[\\w-]*):v?([0-9][\\w.\\-]*)");
+
+ private static boolean pinsAnOldVersionOnPurpose(Path relativeToDocs) {
+ String path = relativeToDocs.toString().replace('\\', '/');
+ for (String prefix : DOCS_PINNED_ON_PURPOSE) {
+ if (path.startsWith(prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * fonts and emoji carry independent version lines (their own {@code fonts-v*} /
+ * {@code emoji-v*} tags), so a docs snippet naming one of them is checked
+ * against that module's own pom — never the engine train's target set.
+ */
+ private Set expectedVersionsFor(String artifact, Set trainTargets) throws Exception {
+ if (isCompanionArtifact(artifact)) {
+ String module = artifact.substring("graph-compose-".length());
+ return Set.of(effectiveVersion(PROJECT_ROOT.resolve(module + "/pom.xml")));
+ }
+ return trainTargets;
+ }
+
+ private static boolean isCompanionArtifact(String artifact) {
+ return artifact.equals("graph-compose-fonts") || artifact.equals("graph-compose-emoji");
+ }
+
+ private static int lineOf(String text, int offset) {
+ int line = 1;
+ for (int i = 0; i < offset; i++) {
+ if (text.charAt(i) == '\n') {
+ line++;
+ }
+ }
+ return line;
+ }
+
// ── Install-snippet patterns ────────────────────────────────────
// Each install snippet check tries the Maven Central format first
// (canonical from v1.6.6 onwards) and falls back to the legacy
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 6382f1c2..a6a23534 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -51,7 +51,7 @@ discoverable.
io.github.demchaav
graph-compose-render-pdf
- 2.0.0
+ 2.1.0
```
@@ -72,7 +72,7 @@ dependency to **your** project.
io.github.demchaav
graph-compose-render-docx
- 2.0.0
+ 2.1.0
```
diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1
index ecde5d2a..25fc6174 100644
--- a/scripts/cut-release.ps1
+++ b/scripts/cut-release.ps1
@@ -452,10 +452,13 @@ function Update-ReadmeInstallVersion($readmePath, $newVersion) {
function Update-ModuleReadmeInstallVersion($readmePath, $newVersion) {
# Per-module READMEs carry copy-paste install snippets for THEIR OWN train
# artifact (Maven + Gradle). Bump every occurrence — unlike the root README
- # there is no legacy-format fallback, and each file only ever references its
- # own coordinate, so a blanket replace within the file is safe. Called for
- # the train modules only; fonts/emoji READMEs pin their own independent
- # versions and must never be touched by an engine cut.
+ # there is no legacy-format fallback. Also used for documentation pages that
+ # carry a train install snippet; a page names more than one coordinate, so the
+ # match cannot lean on "one file, one coordinate" the way a module README can.
+ # graph-compose-fonts and graph-compose-emoji ship on their own fonts-v* /
+ # emoji-v* tags, so they are excluded explicitly here rather than by the
+ # caller's choice of file — an engine cut must never rewrite their version,
+ # wherever their snippet turns up.
if (-not (Test-Path $readmePath)) {
Note "skip (no file): $readmePath"
return
@@ -463,7 +466,7 @@ function Update-ModuleReadmeInstallVersion($readmePath, $newVersion) {
$content = Get-Content $readmePath -Raw
$changed = $false
- $mavenRegex = [regex]'(?<=graph-compose[\w\-]*\s*)v?[\w\.\-]+(?=)'
+ $mavenRegex = [regex]'(?<=graph-compose(?!-fonts|-emoji)[\w\-]*\s*)v?[\w\.\-]+(?=)'
$afterMaven = $mavenRegex.Replace($content, $newVersion)
if ($content -ne $afterMaven) {
$content = $afterMaven
@@ -471,7 +474,7 @@ function Update-ModuleReadmeInstallVersion($readmePath, $newVersion) {
Note "bumped module README Maven snippet: $readmePath -> $newVersion"
}
- $gradleRegex = [regex]'(?<=io\.github\.demchaav:graph-compose[\w\-]*:)v?[\w\.\-]+(?=")'
+ $gradleRegex = [regex]'(?<=io\.github\.demchaav:graph-compose(?!-fonts|-emoji)[\w\-]*:)v?[\w\.\-]+(?=")'
$afterGradle = $gradleRegex.Replace($content, $newVersion)
if ($content -ne $afterGradle) {
$content = $afterGradle
@@ -1047,6 +1050,16 @@ try {
'wrapper/README.md', 'bundle/README.md')) {
Update-ModuleReadmeInstallVersion (Join-Path $repoRoot $moduleReadme) $Version
}
+ # Documentation pages that carry a copy-paste install snippet for a train
+ # artifact. Same bumper as the module READMEs — the snippet names a train
+ # coordinate, so it moves with the release or a reader who opened the page
+ # because something already broke is handed the previous minor. Historical
+ # pages (docs/migration, docs/roadmaps, docs/archive) pin an old version on
+ # purpose and are never listed here; VersionConsistencyGuardTest excludes
+ # them by the same rule.
+ foreach ($docPage in @('docs/troubleshooting.md')) {
+ Update-ModuleReadmeInstallVersion (Join-Path $repoRoot $docPage) $Version
+ }
Update-IndexHtmlVersion (Join-Path $repoRoot 'web/index.html') $Version
# The smoke harness's default version must follow the release, or the
# post-release run silently re-verifies the previous one.
@@ -1227,6 +1240,13 @@ try {
$commitFiles += $moduleReadme
}
}
+ # Documentation pages carrying a train install snippet, bumped alongside the
+ # module READMEs in Step 1.
+ foreach ($docPage in @('docs/troubleshooting.md')) {
+ if (Test-Path (Join-Path $repoRoot $docPage)) {
+ $commitFiles += $docPage
+ }
+ }
# The README assets ride along whenever they were re-rendered — every final cut,
# -SkipShowcase or not, since that flag is about the published site and these ship
# in the repository. A pre-release leaves them alone, so it stages nothing here.