diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ddce40e..fe866300 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ follow semantic versioning; release dates are ISO 8601.
### Build
+- **The cut installs a module after the things it needs.** Step 4 installs each train
+ sibling on its own, so everything it depends on has to be in the local repository at
+ the version the bump just wrote — a version that exists in no reactor and not yet on
+ Central. `render-pptx` was listed before `testing` while depending on it, and had been
+ since PPTX gained its text-fidelity suite. That stayed invisible: the cut only fails on
+ it when the local repository does not already hold `graph-compose-testing` at the new
+ version, which is the normal state of a clean machine and not of one that has been
+ building all week. The 2.1.1 cut hit it and stopped at Step 4 — after the version bump
+ had rewritten thirty files, before any commit, tag or push. `testing` now installs
+ second, and `ReleaseScriptInstallListGuardTest` derives the required order from the
+ poms rather than restating it, so a new edge cannot be added without failing the build.
+
- **CI opens the Javadoc jar it is about to publish.** The existing step lints the
engine's sources, which says nothing about whether the artefact Maven Central serves
has anything in it — and that was the failure: `graph-compose` carries no sources of
diff --git a/core/src/test/java/com/demcha/documentation/ReleaseScriptInstallListGuardTest.java b/core/src/test/java/com/demcha/documentation/ReleaseScriptInstallListGuardTest.java
index a7255fca..af735b86 100644
--- a/core/src/test/java/com/demcha/documentation/ReleaseScriptInstallListGuardTest.java
+++ b/core/src/test/java/com/demcha/documentation/ReleaseScriptInstallListGuardTest.java
@@ -5,7 +5,10 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
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;
@@ -47,6 +50,20 @@ class ReleaseScriptInstallListGuardTest {
"(graph-compose[a-z-]*)\\s*"
+ "\\$\\{graphcompose\\.version}");
+ /**
+ * A {@code graph-compose-*} dependency inside a train module.
+ *
+ *
Separate from {@link #TRAIN_DEPENDENCY} because the two spell the version
+ * differently: {@code examples/pom.xml} pins {@code ${graphcompose.version}},
+ * while a sibling inside the train carries {@code ${project.version}}. Reusing
+ * the examples pattern here matched nothing and the order check passed over the
+ * very edge that broke the 2.1.1 cut — a guard reading the wrong spelling
+ * reports on an empty set and calls it clean.
+ */
+ private static final Pattern MODULE_TRAIN_DEPENDENCY = Pattern.compile(
+ "(graph-compose[a-z-]*)\\s*"
+ + "\\$\\{(?:project|graphcompose)\\.version}");
+
/** The literal PowerShell array the script installs from. */
private static final Pattern INSTALL_LIST = Pattern.compile(
"\\$exampleSnapshotSiblings\\s*=\\s*@\\(([^)]*)\\)", Pattern.DOTALL);
@@ -66,6 +83,91 @@ void releaseScriptInstallsEveryTrainSiblingTheExamplesDependOn() throws IOExcept
.containsAll(required);
}
+ /**
+ * A module is installed after everything it depends on.
+ *
+ * Step 4 installs each sibling on its own — {@code install -f /pom.xml},
+ * not a reactor build — so every dependency has to be in the local repository
+ * already, at the version the bump just wrote. That version exists nowhere else:
+ * not in a reactor, not on Central. Install a module before its dependency and
+ * Maven stops with "Could not find artifact …:<new version>", after the tree
+ * has been rewritten.
+ *
+ * Membership was guarded; order was not. {@code render-pptx} has depended on
+ * {@code testing} since the PPTX text-fidelity work while being installed before
+ * it, and the cut went green anyway whenever the local repository happened to
+ * hold that artifact from an earlier build. On a clean machine it does not, and
+ * the 2.1.1 cut stopped there. The order is derived from the poms rather than
+ * restated here, so a new edge cannot be added without this noticing.
+ */
+ @Test
+ void everyInstalledModuleFollowsTheSiblingsItDependsOn() throws IOException {
+ List order = scriptInstallOrder();
+ List violations = new ArrayList<>();
+
+ for (int i = 0; i < order.size(); i++) {
+ String module = order.get(i);
+ for (String dependency : trainSiblingsOf(module)) {
+ int at = order.indexOf(dependency);
+ if (at > i) {
+ violations.add("%s (position %d) needs %s, installed at %d"
+ .formatted(module, i + 1, dependency, at + 1));
+ }
+ }
+ }
+
+ assertThat(violations)
+ .describedAs("cut-release.ps1 installs these one at a time, so a module listed "
+ + "before something it depends on cannot resolve it: the bumped version is "
+ + "in no reactor and not yet on Central. This fails the cut at Step 4, with "
+ + "the version bump already written across the tree")
+ .isEmpty();
+ }
+
+ /** The module directories the script installs, in the order it installs them. */
+ private static List scriptInstallOrder() throws IOException {
+ String script = Files.readString(PROJECT_ROOT.resolve("scripts/cut-release.ps1"));
+ Matcher list = INSTALL_LIST.matcher(script);
+ assertThat(list.find())
+ .describedAs("cut-release.ps1 no longer declares $exampleSnapshotSiblings")
+ .isTrue();
+
+ List modules = new ArrayList<>();
+ // The engine is installed by its own command immediately before the loop, so
+ // it precedes every entry and belongs at the head of the order.
+ modules.add("core");
+ Matcher path = Pattern.compile("'([^']+)/pom\\.xml'").matcher(list.group(1));
+ while (path.find()) {
+ modules.add(path.group(1));
+ }
+ return modules;
+ }
+
+ /** Module directories of the train-versioned siblings {@code module} declares. */
+ private static Set trainSiblingsOf(String module) throws IOException {
+ String pom = Files.readString(PROJECT_ROOT.resolve(module + "/pom.xml"))
+ .replaceAll("(?s).*?", "");
+ Set modules = new LinkedHashSet<>();
+ Matcher matcher = MODULE_TRAIN_DEPENDENCY.matcher(pom);
+ while (matcher.find()) {
+ String directory = DIRECTORY_BY_ARTIFACT.get(matcher.group(1));
+ if (directory != null) {
+ modules.add(directory);
+ }
+ }
+ return modules;
+ }
+
+ /** Artifact id to the directory holding its pom, for the train-versioned modules. */
+ private static final Map DIRECTORY_BY_ARTIFACT = Map.of(
+ "graph-compose-core", "core",
+ "graph-compose-render-pdf", "render-pdf",
+ "graph-compose-render-docx", "render-docx",
+ "graph-compose-render-pptx", "render-pptx",
+ "graph-compose-templates", "templates",
+ "graph-compose-testing", "testing",
+ "graph-compose", "wrapper");
+
/**
* Artifact ids of the train-versioned {@code graph-compose-*} siblings the
* examples depend on, ignoring the {@code } coordinate.
diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1
index 25fc6174..30585e67 100644
--- a/scripts/cut-release.ps1
+++ b/scripts/cut-release.ps1
@@ -613,9 +613,20 @@ function Build-ExampleCatalogue {
# on but that is missing here fails Step 4 with "Could not find artifact
# …:", because the just-bumped version exists nowhere yet.
# ReleaseScriptInstallListGuardTest fails the build if the two drift apart.
- # render-pptx must follow render-pdf: it depends on it at compile scope.
- $exampleSnapshotSiblings = @('render-pdf/pom.xml', 'wrapper/pom.xml', 'render-docx/pom.xml',
- 'render-pptx/pom.xml', 'templates/pom.xml', 'testing/pom.xml')
+ #
+ # ORDER IS PART OF THE CONTRACT, not presentation. Each module is installed on
+ # its own, so anything it needs must already be in the local repository at the
+ # just-bumped version — and that version exists nowhere else, not in the
+ # reactor and not on Central. Two edges matter:
+ # render-pdf before render-pptx and wrapper (compile scope)
+ # testing before render-pptx (test scope, since #407)
+ # The second was wrong from the moment render-pptx took that dependency, and
+ # stayed invisible: a cut only fails on it when the local repository does not
+ # already hold graph-compose-testing at the new version, which is the normal
+ # state of a clean machine. The 2.1.1 cut hit it and stopped at Step 4 —
+ # before any commit, tag or push, which is the one thing that went right.
+ $exampleSnapshotSiblings = @('render-pdf/pom.xml', 'testing/pom.xml', 'wrapper/pom.xml',
+ 'render-docx/pom.xml', 'render-pptx/pom.xml', 'templates/pom.xml')
if ($DryRun) {
Write-Host " [DRY RUN] $mvnw -B -ntp -DskipTests install -pl :graph-compose-core" -ForegroundColor Yellow
foreach ($modulePom in $exampleSnapshotSiblings) {