IkeReleaseCascadeMojo.java
package network.ike.plugin;
import network.ike.plugin.support.AbstractGoalMojo;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.support.GoalReportSpec;
import network.ike.workspace.cascade.CascadeAssembler;
import network.ike.workspace.cascade.CascadeEdge;
import network.ike.workspace.cascade.CascadeRepo;
import network.ike.workspace.cascade.EdgeKind;
import network.ike.workspace.cascade.ProjectCascade;
import network.ike.workspace.cascade.ProjectCascadeIo;
import network.ike.workspace.cascade.ReleaseCascade;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* Releases the whole IKE foundation cascade in topological order
* (IKE-Network/ike-issues#419).
*
* <p>The cascade is decentralized (#420): each foundation repo
* version-controls its own {@code src/main/cascade/release-cascade.yaml}
* declaring only its own edges. This goal reads the local repo's
* manifest, walks the edges into the sibling checkouts to assemble
* the full ordered graph, and runs {@code ike:release-publish} on
* every release-pending member in topological order. Each member's
* Nexus deploy completes before the next (which
* {@code ike:release-publish} aligns to its upstreams via
* {@code alignUpstreamProperties}, #419-B) begins.
*
* <p>A member is release-pending when either:
* <ul>
* <li>it has at least one non-release-cadence commit since its
* latest {@code v*} tag (substantive change), OR</li>
* <li>at least one of its upstream's {@code ${X.version}} property
* pin in the local POM is older than that upstream's latest
* released tag (stale upstream pin, #468).</li>
* </ul>
*
* <p>The walk is a single full-graph topological sweep, not a
* single-source downstream walk: from any starting node the assembler
* reaches every connected member, and every release-pending node
* releases exactly once even when the graph has multiple heads that
* converge on a shared terminal (#468). The walker is idempotent —
* re-running with no release-pending nodes is a no-op — and
* crash-safe — re-running after a partial cascade re-evaluates the
* release-pending set and picks up from the first unfinished member.
*
* <p>This is the {@code ike:}-tier cascade executor. The foundation
* repos cannot form a workspace — {@code ike-workspace-maven-plugin}
* lives <em>in</em> ike-platform — so the foundation must release with
* {@code ike:}-tier tooling only.
*
* <p>Repo resolution is local: every cascade member is expected to be
* checked out as a sibling directory alongside the repo this goal runs
* in (override the containing directory with
* {@code -Dike.release.cascade.basedir}). A member with no checkout is
* a hard error — the cascade cannot release what it cannot see.
*
* <p>Usage:
* <pre>
* mvn ike:release-cascade # release the cascade
* mvn ike:release-cascade -DpushRelease=false # local-only dry of each
* mvn ike:release-cascade -Dike.release.cascade.basedir=/path/to/checkouts
* </pre>
*/
@Mojo(name = IkeGoal.NAME_RELEASE_CASCADE, projectRequired = false, aggregator = true)
public class IkeReleaseCascadeMojo extends AbstractGoalMojo {
/**
* Release-cadence commit subjects — commits that are bookkeeping
* from a prior release, not a reason to release again. Matches the
* patterns the release flow itself produces so a re-run of a
* partially-completed cascade does not re-release an up-to-date
* repo.
*/
private static final Pattern RELEASE_CADENCE = Pattern.compile(
"^(release: .+"
+ "|merge: release .+"
+ "|post-release: .+"
+ "|site: publish .+)$");
/**
* Directory containing every cascade member as a sibling checkout.
* Defaults to the parent of the repo this goal runs in.
*/
@Parameter(property = "ike.release.cascade.basedir")
String cascadeBaseDir;
/**
* Forwarded to {@code ike:release-publish} on each repo. When
* {@code false}, each release stays local (no tag/main push, no
* Nexus deploy from a pushed tag).
*/
@Parameter(property = "pushRelease", defaultValue = "true")
boolean pushRelease;
/**
* Skip the per-repo {@code mvn install -DskipTests} that seeds
* {@code ~/.m2} with the current SNAPSHOT before its release. The
* seed exists for the self-hosting reactor bootstrap (#379); skip
* it when {@code ~/.m2} is already warm.
*/
@Parameter(property = "skipPreInstall", defaultValue = "false")
boolean skipPreInstall;
/** Override working directory for tests. If null, uses current directory. */
File baseDir;
/** Creates this goal instance. */
public IkeReleaseCascadeMojo() {}
/** What happened to one cascade member. */
private enum Kind { RELEASED, UP_TO_DATE, SKIPPED, FAILED }
/** The outcome of processing one cascade member. */
private record Outcome(String name, Kind kind, String detail) {}
@Override
protected GoalReportSpec runGoal() throws MojoException {
File startDir = baseDir != null ? baseDir : new File(".");
File gitRoot = ReleaseSupport.gitRoot(startDir);
ReleaseCascade cascade = assembleCascade(gitRoot);
File siblings = cascadeBaseDir != null && !cascadeBaseDir.isBlank()
? new File(cascadeBaseDir)
: gitRoot.getParentFile();
getLog().info("Foundation release cascade — "
+ cascade.repos().size() + " repo(s) in order: "
+ String.join(" → ", cascade.repos().stream()
.map(CascadeRepo::repo).toList()));
getLog().info("");
List<Outcome> outcomes = new ArrayList<>();
for (CascadeRepo repo : cascade.repos()) {
File dir = new File(siblings, repo.repo());
Outcome outcome = walkOne(dir, repo, siblings);
outcomes.add(outcome);
if (outcome.kind() == Kind.FAILED) {
reportSummary(outcomes);
throw new MojoException("Release cascade failed at "
+ repo.repo() + " — " + outcome.detail()
+ ". Fix it, then re-run "
+ IkeGoal.RELEASE_CASCADE.qualified()
+ " to"
+ " continue with the remaining repos.");
}
}
reportSummary(outcomes);
return new GoalReportSpec(IkeGoal.RELEASE_CASCADE,
startDir.toPath(), buildReport(outcomes));
}
/**
* Reads the local repo's {@code release-cascade.yaml} and assembles
* the full cascade graph by walking its edges into the siblings.
*/
private ReleaseCascade assembleCascade(File gitRoot) {
Path localManifest = gitRoot.toPath().resolve(
ProjectCascadeIo.MANIFEST_RELATIVE_PATH);
ProjectCascade local = ProjectCascadeIo.load(localManifest)
.orElseThrow(() -> new MojoException(
"No " + ProjectCascadeIo.MANIFEST_RELATIVE_PATH
+ " in " + gitRoot + " — run "
+ IkeGoal.RELEASE_CASCADE.qualified()
+ " from a foundation cascade repo."));
File rootPom = new File(gitRoot, "pom.xml");
CascadeEdge start = new CascadeEdge(
ReleaseSupport.readPomGroupId(rootPom),
ReleaseSupport.readPomArtifactId(rootPom),
gitRoot.getName(), null);
File siblings = cascadeBaseDir != null && !cascadeBaseDir.isBlank()
? new File(cascadeBaseDir)
: gitRoot.getParentFile();
try {
return CascadeAssembler.assemble(start, local, edge -> {
Path p = siblings.toPath().resolve(edge.repo())
.resolve(ProjectCascadeIo.MANIFEST_RELATIVE_PATH);
return ProjectCascadeIo.read(p);
});
} catch (RuntimeException e) {
throw new MojoException(
"Cannot assemble the release cascade: "
+ e.getMessage() + " — every cascade member must be"
+ " checked out as a sibling directory under "
+ siblings, e);
}
}
/**
* Processes one cascade member: detect release-pending state,
* release the member when its substantive commits or stale
* upstream pins make it release-pending.
*
* <p>The release-pending decision combines two independent signals
* — substantive commits since the last {@code v*} tag, and stale
* upstream version-property pins relative to each upstream's
* latest tag. Either signal is sufficient. Both are reported in
* the per-member log so the operator can see WHY each member
* released (or didn't).
*
* @param dir the member's checkout directory
* @param repo the member's assembled cascade node (carries
* its upstream edges)
* @param siblings the parent directory containing every
* cascade-member checkout
* @return the outcome
*/
private Outcome walkOne(File dir, CascadeRepo repo, File siblings) {
String name = repo.repo();
getLog().info("─── " + name + " ───");
if (!dir.isDirectory() || !new File(dir, ".git").exists()
|| !new File(dir, "pom.xml").isFile()) {
getLog().error(" Not a usable checkout at " + dir);
return new Outcome(name, Kind.FAILED,
"no checkout at " + dir);
}
String tag = latestReleaseTag(dir);
int meaningful = tag == null
? 0 // first release reported via tag==null branch below
: meaningfulCommitsSinceTag(dir, tag);
List<String> stalePins = stalePinsFor(repo, dir, siblings);
boolean firstRelease = tag == null;
if (!firstRelease && meaningful == 0 && stalePins.isEmpty()) {
getLog().info(" At " + tag + "; no meaningful commits"
+ " since and no stale upstream pins —"
+ " skipping (already released).");
getLog().info("");
return new Outcome(name, Kind.UP_TO_DATE, tag);
}
if (firstRelease) {
getLog().info(" Never released — releasing for the"
+ " first time.");
} else if (meaningful > 0) {
getLog().info(" At " + tag + "; " + meaningful
+ " meaningful commit(s) since.");
} else {
getLog().info(" At " + tag
+ "; no meaningful commits since.");
}
if (!stalePins.isEmpty()) {
getLog().info(" Stale upstream pin(s) (release-publish"
+ " will align before tagging):");
for (String pin : stalePins) {
getLog().info(" • " + pin);
}
}
File mvnw = ReleaseSupport.resolveMavenWrapper(dir, getLog());
String mvn = mvnw.getAbsolutePath();
if (!skipPreInstall) {
getLog().info(" Seeding ~/.m2 with the current SNAPSHOT...");
try {
ReleaseSupport.exec(dir, getLog(),
mvn, "install", "-DskipTests", "-T", "4", "-B");
} catch (RuntimeException e) {
getLog().warn(" Pre-install failed (continuing —"
+ " release-publish will surface a real"
+ " error): " + e.getMessage());
}
}
getLog().info(" Running mvn "
+ IkeGoal.RELEASE_PUBLISH.qualified() + "...");
try {
ReleaseSupport.exec(dir, getLog(),
mvn, IkeGoal.RELEASE_PUBLISH.qualified(),
"-DpushRelease=" + pushRelease, "-B");
getLog().info(" ✓ Released " + name);
getLog().info("");
return new Outcome(name, Kind.RELEASED, null);
} catch (RuntimeException e) {
getLog().error(" ✗ Failed to release " + name + ": "
+ e.getMessage());
getLog().info("");
return new Outcome(name, Kind.FAILED, e.getMessage());
}
}
/**
* Returns descriptions of stale upstream version-property pins
* for {@code node}. A pin is stale when the local POM's
* {@code <${X.version}>} value is older than the upstream's
* latest {@code v*} tag (the upstream's last known release).
*
* <p>Format is {@code "ike-tooling.version (191 → 192)"} so the
* cascade log can show the operator exactly which property
* release-publish's {@code alignUpstreamProperties} will bump.
*
* <p>Empty list when every upstream is at its latest tag, when
* {@code node} has no upstream edges (cascade head), or when an
* upstream's checkout is missing (the walker reports that
* separately when it tries to walk the missing member).
*
* <p>Visible for testing.
*
* @param node the cascade node being inspected
* @param nodeDir the node's checkout directory (its POM lives at
* {@code nodeDir/pom.xml})
* @param siblings the directory containing every cascade-member
* checkout
* @return per-pin stale descriptions, never null
*/
static List<String> stalePinsFor(CascadeRepo node, File nodeDir,
File siblings) {
List<String> stale = new ArrayList<>();
File pom = new File(nodeDir, "pom.xml");
if (!pom.isFile()) {
return stale;
}
String pomContent = null;
for (CascadeEdge up : node.upstream()) {
File upstreamDir = new File(siblings, up.repo());
if (!upstreamDir.isDirectory()) {
continue;
}
String upstreamTag = latestReleaseTag(upstreamDir);
if (upstreamTag == null) {
continue;
}
String upstreamLatest = upstreamTag.startsWith("v")
? upstreamTag.substring(1)
: upstreamTag;
// PARENT-kind edges pin via the <parent><version> block;
// every other kind pins via the ${G·A} property
// (IKE-Network/ike-issues#496 part E).
boolean parentEdge = up.kind() == EdgeKind.PARENT;
String pinned;
String displaySite;
if (parentEdge) {
if (pomContent == null) {
pomContent = readPomContent(pom);
if (pomContent == null) {
return stale;
}
}
pinned = PomRewriter.readParentVersion(pomContent,
up.groupId(), up.artifactId()).orElse(null);
displaySite = "<parent>" + up.ga() + "</parent>";
} else {
// Try typed-marker form first (post-#525), then fall
// back to legacy ·-form so stale-pin reporting works
// on POMs from both sides of the convention boundary.
String property = up.versionProperty();
pinned = (property == null || property.isBlank())
? null
: ReleaseSupport.readPomProperty(pom, property);
if (pinned == null) {
property = up.versionPropertyLegacy();
pinned = ReleaseSupport.readPomProperty(pom, property);
}
if (property == null || property.isBlank()) {
continue;
}
displaySite = property;
}
if (pinned == null || pinned.isBlank()
|| pinned.contains("${")) {
continue;
}
if (!pinned.equals(upstreamLatest)) {
stale.add(displaySite + " (" + pinned + " → "
+ upstreamLatest + ")");
}
}
return stale;
}
/**
* Reads {@code pom.xml} content into a string, returning
* {@code null} on I/O failure. Used by parent-edge inspection
* in {@link #stalePinsFor}, which needs the raw text for
* {@link PomRewriter#readParentVersion}.
*/
private static String readPomContent(File pom) {
try {
return java.nio.file.Files.readString(
pom.toPath(),
java.nio.charset.StandardCharsets.UTF_8);
} catch (java.io.IOException e) {
return null;
}
}
/** The newest {@code v*} release tag in a repo, or null. */
static String latestReleaseTag(File dir) {
try {
String tags = ReleaseSupport.execCapture(dir, "git", "tag",
"-l", "v*", "--sort=-version:refname");
return tags == null || tags.isBlank() ? null
: tags.lines().findFirst().orElse(null);
} catch (RuntimeException e) {
return null;
}
}
/**
* Counts the commits since {@code tag} that are not release-cadence
* bookkeeping — the commits that actually warrant a new release.
*/
private static int meaningfulCommitsSinceTag(File dir, String tag) {
try {
String log = ReleaseSupport.execCapture(dir, "git", "log",
tag + "..HEAD", "--pretty=format:%s", "--no-merges");
if (log == null || log.isBlank()) {
return 0;
}
int count = 0;
for (String line : log.strip().split("\n")) {
if (!RELEASE_CADENCE.matcher(line.strip()).matches()) {
count++;
}
}
return count;
} catch (RuntimeException e) {
// Cannot tell — assume there is work, so the cascade does
// not silently skip a repo.
return 1;
}
}
/** Logs the per-repo cascade summary table. */
private void reportSummary(List<Outcome> outcomes) {
getLog().info("");
getLog().info("Cascade summary:");
for (Outcome o : outcomes) {
getLog().info(" " + marker(o.kind()) + " " + o.name()
+ (o.detail() != null ? " (" + o.detail() + ")"
: ""));
}
}
private String buildReport(List<Outcome> outcomes) {
GoalReportBuilder report = new GoalReportBuilder()
.section("Release cascade");
for (Outcome o : outcomes) {
report.bullet(marker(o.kind()) + " **" + o.name() + "**"
+ (o.detail() != null ? " — " + o.detail() : ""));
}
return report.build();
}
private static String marker(Kind kind) {
return switch (kind) {
case RELEASED -> "✓ released";
case UP_TO_DATE -> "— up to date";
case SKIPPED -> "— skipped";
case FAILED -> "✗ FAILED";
};
}
}