WsPostReleaseMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.workspace.Subproject;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.WorkingSet;
import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.ws.preflight.Preflight;
import network.ike.plugin.ws.preflight.PreflightCondition;
import network.ike.plugin.ws.preflight.PreflightContext;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.plugin.ws.vcs.VcsState;
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.io.IOException;
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;
/**
* Post-release version bump across workspace subprojects.
*
* <p>After a release, this goal bumps every checked-out subproject's
* POM version to the specified {@code nextVersion}, commits the
* change, pushes if a remote exists, then updates workspace.yaml
* to reflect the new development versions.
*
* <p>Components are processed in topological order so that upstream
* dependencies are bumped before downstream consumers.
*
* <pre>{@code
* mvn ike:post-release -DnextVersion=4-SNAPSHOT
* }</pre>
*/
@Mojo(name = "post-release", projectRequired = false, aggregator = true)
public class WsPostReleaseMojo extends AbstractWorkspaceMojo {
/**
* The next development version to set across all subprojects,
* e.g., {@code "4-SNAPSHOT"}.
*/
@Parameter(property = "nextVersion")
String nextVersion;
/** Creates this goal instance. */
public WsPostReleaseMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
nextVersion = requireParam(nextVersion, "nextVersion",
"Next development version (e.g., 4-SNAPSHOT)");
validateMavenVersion(nextVersion);
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
Path manifestPath = resolveManifest();
// VCS bridge: catch-up before modifying
VcsOperations.catchUp(root, getLog());
List<String> sorted = graph.topologicalSort(
new LinkedHashSet<>(graph.manifest().subprojects().keySet()));
// COORDINATING preflight (#780): post-release bumps + commits every
// subproject pom, so the whole working set must be unmodified first
// (after the VCS-bridge catch-up above). -Dallow-uncommitted escapes
// (e.g. an interrupted release). post-release is not cascade-invoked, so
// -Ddefer-commit never fires here, but the guard mirrors the family.
if (!allowUncommitted() && !deferCommit()) {
Preflight.of(List.of(PreflightCondition.WORKING_TREE_CLEAN),
PreflightContext.of(root, graph, sorted))
.requirePassed(WsGoal.POST_RELEASE);
}
getLog().info("");
getLog().info("IKE Workspace \u2014 Post-Release");
getLog().info("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
getLog().info(" Next version: " + nextVersion);
getLog().info(" Components: " + sorted.size());
getLog().info("");
Map<String, String> versionUpdates = new LinkedHashMap<>();
// Per-subproject Effect for the working-set report (#763/#767): keyed
// by subproject name, valued by what post-release did to that member
// (bumped X \u2192 Y, skipped (already at Y), skipped (not cloned), \u2026). The
// aggregator's effect is derived separately, after the workspace.yaml
// write, so the report can carry one row per working-set member.
Map<String, String> effects = new LinkedHashMap<>();
int bumped = 0;
int skipped = 0;
for (String name : sorted) {
Subproject subproject = graph.manifest().subprojects().get(name);
File dir = new File(root, name);
File gitDir = new File(dir, ".git");
File pomFile = new File(dir, "pom.xml");
if (!gitDir.exists()) {
getLog().info(" \u26A0 " + name + " \u2014 not cloned, skipping");
effects.put(name, "skipped (not cloned)");
skipped++;
continue;
}
if (!pomFile.exists()) {
getLog().info(" \u26A0 " + name + " \u2014 no pom.xml, skipping");
effects.put(name, "skipped (no pom.xml)");
skipped++;
continue;
}
// Read current version from root POM
String currentVersion;
try {
currentVersion = ReleaseSupport.readPomVersion(pomFile);
} catch (MojoException e) {
getLog().warn(" \u26A0 " + name + " \u2014 could not read version: "
+ e.getMessage());
effects.put(name, "skipped (could not read version)");
skipped++;
continue;
}
// Idempotency guard (#294): re-running with the same
// -DnextVersion on a subproject already at that version is a
// no-op \u2014 log and skip rather than running the rewrite +
// commit machinery only to discover there's nothing to do.
if (nextVersion.equals(currentVersion)) {
getLog().info(" \u2713 " + name + " \u2014 already at "
+ nextVersion);
effects.put(name, "skipped (already at " + nextVersion + ")");
skipped++;
continue;
}
getLog().info(" \u2192 " + name + " \u2014 " + currentVersion
+ " \u2192 " + nextVersion);
// Set version to nextVersion in POM
ReleaseSupport.setPomVersion(pomFile, currentVersion, nextVersion);
// Also update submodule POMs that reference the old version
try {
List<File> allPoms = ReleaseSupport.findPomFiles(dir);
for (File subPom : allPoms) {
if (subPom.equals(pomFile)) continue;
try {
String content = java.nio.file.Files.readString(
subPom.toPath(), java.nio.charset.StandardCharsets.UTF_8);
if (content.contains("<version>" + currentVersion + "</version>")) {
String updated = content.replace(
"<version>" + currentVersion + "</version>",
"<version>" + nextVersion + "</version>");
java.nio.file.Files.writeString(
subPom.toPath(), updated,
java.nio.charset.StandardCharsets.UTF_8);
String rel = dir.toPath().relativize(subPom.toPath()).toString();
getLog().info(" updated: " + rel);
}
} catch (java.io.IOException e) {
getLog().warn(" Could not update " + subPom + ": "
+ e.getMessage());
}
}
} catch (MojoException e) {
getLog().warn(" Could not scan submodule POMs: " + e.getMessage());
}
// Commit: git add pom.xml && git commit
ReleaseSupport.exec(dir, getLog(), "git", "add", "pom.xml");
// Stage any submodule POMs that were updated
try {
List<File> allPoms = ReleaseSupport.findPomFiles(dir);
for (File subPom : allPoms) {
if (!subPom.equals(pomFile)) {
String rel = dir.toPath().relativize(subPom.toPath()).toString();
ReleaseSupport.exec(dir, getLog(), "git", "add", rel);
}
}
} catch (MojoException e) {
getLog().debug("Could not stage submodule POMs: " + e.getMessage());
}
VcsOperations.commitStaged(dir, getLog(),
"post-release: bump to " + nextVersion);
// Push if remote exists (safe — ignores failure)
VcsOperations.pushIfRemoteExists(dir, getLog(), "origin",
gitBranch(dir));
versionUpdates.put(name, nextVersion);
effects.put(name, "bumped " + currentVersion + " → " + nextVersion);
bumped++;
}
// The aggregator (workspace root) is a first-class member, so
// post-release advances its own pom version like every subproject
// (#768). Root-pom only — the subproject repos beneath it were bumped
// in the loop above. Idempotent: a no-op when already at nextVersion.
File wsRoot = manifestPath.getParent().toFile();
File rootPom = new File(wsRoot, "pom.xml");
boolean rootBumped = false;
if (rootPom.exists()) {
try {
String rootCurrent = ReleaseSupport.readPomVersion(rootPom);
if (!nextVersion.equals(rootCurrent)) {
getLog().info(" → aggregator pom — " + rootCurrent
+ " → " + nextVersion);
ReleaseSupport.setPomVersion(rootPom, rootCurrent, nextVersion);
rootBumped = true;
}
} catch (MojoException e) {
getLog().warn(" ⚠ aggregator — could not bump root pom: "
+ e.getMessage());
}
}
// Update workspace.yaml subproject versions, then commit the
// aggregator (root pom + manifest) together.
String aggregatorEffect = rootBumped
? "pom bumped → " + nextVersion : "no-op (unchanged)";
if (!versionUpdates.isEmpty() || rootBumped) {
if (!versionUpdates.isEmpty()) {
try {
ManifestWriter.updateMavenVersions(manifestPath, versionUpdates);
getLog().info("");
getLog().info(" Updated workspace.yaml versions for "
+ versionUpdates.size() + " components");
} catch (IOException e) {
throw new MojoException(
"Failed to update workspace.yaml: " + e.getMessage(), e);
}
}
File wsGit = new File(wsRoot, ".git");
if (wsGit.exists()) {
if (rootBumped) {
ReleaseSupport.exec(wsRoot, getLog(), "git", "add", "pom.xml");
}
ReleaseSupport.exec(wsRoot, getLog(), "git", "add", "workspace.yaml");
VcsOperations.commitStaged(wsRoot, getLog(),
"post-release: bump aggregator + workspace versions to "
+ nextVersion);
VcsOperations.pushIfRemoteExists(wsRoot, getLog(), "origin",
gitBranch(wsRoot));
aggregatorEffect = (rootBumped ? "pom + " : "")
+ "workspace.yaml bumped → " + nextVersion
+ " + committed";
} else {
aggregatorEffect = (rootBumped ? "pom + " : "")
+ "workspace.yaml bumped → " + nextVersion
+ " (not committed — no .git)";
}
}
getLog().info("");
getLog().info(" Bumped: " + bumped + " | Skipped: " + skipped);
getLog().info("");
return new WorkspaceReportSpec(WsGoal.POST_RELEASE,
buildReport(bumped, skipped, effects, aggregatorEffect));
}
/**
* Build the post-release markdown report — the shared working-set table
* (#766/#767), one row per {@link WorkingSet.Member} including the
* aggregator. The aggregator row is now present (the #763 fix): its
* version, branch, and short SHA are gathered the same way as a
* subproject's, so a workspace root left stale is visible rather than
* silently absent from a subprojects-only table. The {@code Effect}
* column states what post-release applied to each member.
*
* @param bumped count of subprojects whose version was bumped
* @param skipped count of subprojects skipped (no-op / not cloned)
* @param effects per-subproject Effect text, keyed by name
* @param aggregatorEffect what post-release applied to the workspace root
* @return the rendered markdown report body
*/
private String buildReport(int bumped, int skipped,
Map<String, String> effects,
String aggregatorEffect) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**" + bumped + "** bumped, **" + skipped
+ "** skipped.");
List<WorkingSetReportTable.Row> rows = new ArrayList<>();
for (WorkingSet.Member member : resolveWorkingSet().members()) {
File dir = member.directory().toFile();
String version = readMemberVersion(dir);
String branch = new File(dir, ".git").exists()
? gitBranch(dir) : null;
String sha = new File(dir, ".git").exists()
? gitShortSha(dir) : null;
String effect = member.isAggregator()
? aggregatorEffect
: effects.getOrDefault(member.name(), "—");
rows.add(new WorkingSetReportTable.Row(
member, version, branch, sha, effect));
}
WorkingSetReportTable.render(report, "Working set", rows);
return report.build();
}
/**
* Read a working-set member's POM version, gathered uniformly for
* subprojects and the aggregator (the workspace root) alike — gathering
* the root's version is the #763 fix, surfacing the staleness a
* subprojects-only report hid. A missing or unreadable POM yields
* {@code null}, rendered as the report's blank-cell placeholder.
*
* @param dir the member's directory
* @return the POM version, or {@code null} when no readable POM exists
*/
private String readMemberVersion(File dir) {
File pomFile = new File(dir, "pom.xml");
if (!pomFile.exists()) {
return null;
}
try {
return ReleaseSupport.readPomVersion(pomFile);
} catch (MojoException e) {
getLog().debug("Could not read POM version for " + dir + ": "
+ e.getMessage());
return null;
}
}
}