From 7904af4a7f2e6022f4c40b22d828b4c4e422944a Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 3 Aug 2026 10:48:58 +0100 Subject: [PATCH] build(release-script): documentation install snippets move with the release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 2.0.0 since the module install matrix landed in #306, 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 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 carrying 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. The bumper no longer leans on a file naming a single coordinate either — it skips graph-compose-fonts and graph-compose-emoji wherever they appear, since those ship on their own release lines. ./mvnw -B -ntp clean verify — BUILD SUCCESS. VersionConsistencyGuardTest 13 -> 15; both new tests confirmed red against a deliberately stale version and a deliberately missing staging entry. --- CHANGELOG.md | 15 ++ .../VersionConsistencyGuardTest.java | 176 ++++++++++++++++++ docs/troubleshooting.md | 4 +- scripts/cut-release.ps1 | 32 +++- 4 files changed, 219 insertions(+), 8 deletions(-) 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.