Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -344,6 +348,178 @@ void companionReadmeInstallSnippetsMatchTheirPomVersions() throws Exception {
}
}

/**
* Install snippets published under {@code docs/} advertise a version a reader
* can resolve.
*
* <p>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.</p>
*
* <p>Historical trees are excluded by <em>path prefix</em> 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.</p>
*/
@Test
void documentationInstallSnippetsMatchTheProjectVersion() throws Exception {
Set<String> trainTargets = acceptableTargets();
List<DocCoordinate> coordinates = documentationCoordinates();
List<String> drift = new ArrayList<>();

for (DocCoordinate coordinate : coordinates) {
Set<String> 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}.
*
* <p>{@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.</p>
*/
@Test
void documentationPagesWithInstallSnippetsAreBumpedByTheReleaseScript() throws Exception {
Set<String> 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<Set<String>> 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<DocCoordinate> documentationCoordinates() throws IOException {
Path docs = PROJECT_ROOT.resolve("docs");
List<DocCoordinate> found = new ArrayList<>();
List<Path> pages;
try (Stream<Path> 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<Set<String>> docPageLists(String script) {
List<Set<String>> lists = new ArrayList<>();
Matcher loop = Pattern.compile("foreach \\(\\$docPage in @\\(([^)]*)\\)\\)").matcher(script);
while (loop.find()) {
Set<String> 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(
"<artifactId>(graph-compose[\\w-]*)</artifactId>\\s*<version>v?([0-9][^<]*)</version>");
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<String> expectedVersionsFor(String artifact, Set<String> 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
Expand Down
4 changes: 2 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ discoverable.
<dependency>
<groupId>io.github.demchaav</groupId>
<artifactId>graph-compose-render-pdf</artifactId>
<version>2.0.0</version>
<version>2.1.0</version>
</dependency>
```

Expand All @@ -72,7 +72,7 @@ dependency to **your** project.
<dependency>
<groupId>io.github.demchaav</groupId>
<artifactId>graph-compose-render-docx</artifactId>
<version>2.0.0</version>
<version>2.1.0</version>
</dependency>
```

Expand Down
32 changes: 26 additions & 6 deletions scripts/cut-release.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -452,26 +452,29 @@ 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
}
$content = Get-Content $readmePath -Raw
$changed = $false

$mavenRegex = [regex]'(?<=<artifactId>graph-compose[\w\-]*</artifactId>\s*<version>)v?[\w\.\-]+(?=</version>)'
$mavenRegex = [regex]'(?<=<artifactId>graph-compose(?!-fonts|-emoji)[\w\-]*</artifactId>\s*<version>)v?[\w\.\-]+(?=</version>)'
$afterMaven = $mavenRegex.Replace($content, $newVersion)
if ($content -ne $afterMaven) {
$content = $afterMaven
$changed = $true
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading