WsReleaseDraftMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.PomRewriter;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.ReleasePlan.ArtifactReleasePlan;
import network.ike.plugin.ws.ReleasePlan.GA;
import network.ike.plugin.ws.ReleasePlan.PropertyReleasePlan;
import network.ike.plugin.ws.ReleasePlan.ReferenceKind;
import network.ike.plugin.ws.ReleasePlan.ReferenceSite;
import network.ike.plugin.ws.ReleasePlanCompute.ArtifactReleaseIntent;
import network.ike.plugin.ws.ReleasePlanCompute.SubprojectRoot;
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.preflight.PreflightResult;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.plugin.Log;
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.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
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.stream.Collectors;
import java.util.stream.Stream;
/**
* Workspace-level release — release all release-pending checked-out
* components (those with unreleased commits since their last tag, or
* cascaded as transitive downstream of one) in topological order.
*
* <p>Scans checked-out components for commits since their last release
* tag. The release set is the union of:
* <ul>
* <li><b>source-changed</b> — subprojects with unreleased commits
* since their last tag (or never released);</li>
* <li><b>transitive downstream</b> — every checked-out subproject
* that depends, directly or transitively, on a source-changed
* subproject. Catches workspaces where a mid-graph change forces
* downstream re-publish even though those downstream subprojects
* have no source changes of their own.</li>
* </ul>
* The release set is topologically sorted and released in dependency
* order. Before each subproject's release, a single
* <em>catch-up alignment commit</em> bumps every workspace-internal
* upstream version reference (parent and {@code <X.version>} property)
* to the upstream's current target version — this-mission's new version
* if the upstream is releasing this mission, otherwise the upstream's
* current published version on disk. All upstream bumps for a single
* subproject batch into one commit (never two).</p>
*
* <p>Catch-up never expands the release set: a subproject with stale
* upstream properties but no source changes <em>and</em> no upstream
* releasing in this mission is not pulled in.</p>
*
* <p>If catch-up alignment fails for any subproject (POM rewrite or
* commit error), the release halts at that subproject with a
* {@link MojoException} naming the failing subproject and property —
* never silently continues.</p>
*
* <p><strong>What it does, per subproject:</strong></p>
* <ol>
* <li>Detect latest release tag ({@code v*})</li>
* <li>Check for commits since that tag</li>
* <li>If source-changed or cascade-induced: catch-up upstream version
* references in this subproject's POM (single commit), then run
* {@code mvn ike:release-publish} in that subproject's directory</li>
* </ol>
*
* <p><strong>Workspace-level preflight</strong> (applied before any
* subproject is released):</p>
* <ul>
* <li>{@link PreflightCondition#WORKING_TREE_CLEAN} — every
* checked-out subproject (and the workspace root) must have no
* uncommitted changes.</li>
* <li>{@link PreflightCondition#NO_SNAPSHOT_PROPERTIES} — no root
* POM may carry a {@code <properties>} value ending in
* {@code -SNAPSHOT}. Catches the {@code ike-parent-105.pom}
* leakage class of bug at its source (see issues #175, #177).</li>
* <li>{@link PreflightCondition#NO_EXTERNAL_SNAPSHOT_DEPENDENCIES} —
* no releasing member's POMs may carry a literal
* {@code -SNAPSHOT} version the mission neither releases nor
* retargets. The literal complement of the property gate:
* komet-desktop shipped two releases pinning a never-released
* external snapshot inside OS profiles (ike-issues#1021), which
* only a cold repository could expose (ike-issues#1022).</li>
* </ul>
*
* <p>Per-subproject preflight (javadoc warnings, git push auth, SSH
* proxy, gh CLI auth, Maven wrapper, post-mutation SNAPSHOT
* <code><version></code> scan) runs inside each
* {@code ike:release-publish} invocation — see {@code ReleaseDraftMojo}
* in the {@code ike-maven-plugin} module. This ensures the same
* gates apply whether a release is invoked workspace-level or
* directly inside a single subproject.
*
* <p>The cascade is self-limiting: only checked-out components with
* changes since their last release are candidates. Components not
* present in the aggregator are not considered.</p>
*
* <pre>{@code
* mvn ws:release-draft # preview what would be released
* mvn ws:release-publish # release all release-pending components
* }</pre>
*/
@Mojo(name = "release-draft", projectRequired = false, aggregator = true)
public class WsReleaseDraftMojo extends AbstractWorkspaceMojo {
private static final DateTimeFormatter ISO_UTC =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC);
/** Preview what would be released without executing. */
@Parameter(property = "publish", defaultValue = "false")
boolean publish;
/** Skip the pre-release checkpoint. */
@Parameter(property = "skipCheckpoint", defaultValue = "false")
boolean skipCheckpoint;
/** Push releases to remote. Passed through to ike:release. */
@Parameter(property = "push", defaultValue = "true")
boolean push;
/**
* Release even when a subproject's release preflight reports
* warnings (e.g. commits without an issue trailer). Forwarded to
* each subproject's {@code ike:release-publish} invocation as
* {@code -Dike.release.ignoreWarnings}. Errors remain fatal.
*/
@Parameter(property = "ike.release.ignoreWarnings", defaultValue = "false")
boolean ignoreWarnings;
/**
* GitHub repository for release creation (e.g., "IKE-Network/komet").
* If set, creates a GitHub Release for each released subproject and
* attaches any platform installers found in the subproject's
* {@code target/installers/} directory.
*/
@Parameter(property = "githubRepo")
String githubRepo;
/**
* Glob pattern for installer artifacts to attach to the GitHub Release.
* Matched relative to each subproject's {@code target/} directory.
*/
@Parameter(property = "installerGlob", defaultValue = "installers/*.{pkg,dmg,msi,deb,rpm}")
String installerGlob;
/**
* Message for both goal-authored alignment commits (pre-release
* upstream alignment and the loop's per-member catch-up). Carries
* the standing {@code Refs:} trailer so the member release engine's
* trailer-compliance preflight passes on the orchestrator's own
* bookkeeping (IKE-Network/ike-issues#983; #377 is the alignment
* feature the commits implement).
*/
static final String ALIGNMENT_COMMIT_MESSAGE =
"chore: align upstream versions before release"
+ "\n\nRefs: IKE-Network/ike-issues#377";
/**
* The release-mission label naming the record file
* ({@code releases/release-<mission>.yaml}) and the tag messages.
* Defaults to {@code <root-artifactId>-<root-release-version>}.
* An explicit label that diverges from that canonical form warns at
* draft and refuses at publish unless {@link #acceptMissionLabel}
* confirms it — the delivery chain resolves the record by the
* canonical name, so a drifted label silently breaks downstream
* publishing (IKE-Network/ike-issues#1035).
*/
@Parameter(property = "mission")
String missionLabel;
/**
* Deprecated spelling of {@link #missionLabel} from before the
* cycle-to-mission rename (IKE-Network/ike-issues#1038). Honoured
* with a deprecation warning when {@code -Dmission} is absent;
* removed after one platform generation.
*/
@Parameter(property = "cycle")
String cycleLabelDeprecated;
/**
* Confirms a deliberately non-canonical {@link #missionLabel}.
* Without it, an explicit label that diverges from
* {@code <root-artifactId>-<root-release-version>} refuses at
* publish (IKE-Network/ike-issues#1035).
*/
@Parameter(property = "acceptMissionLabel", defaultValue = "false")
boolean acceptMissionLabel;
/**
* Skip tests in the mission's reactor verify. The verify is the one
* full build of the working set at release versions; tests run
* there by default because nothing else in the mission runs them.
*/
@Parameter(property = "skipCycleTests", defaultValue = "false")
boolean skipCycleTests;
/**
* How this working set names release tags, in both directions —
* the tags the mission writes and the tags detection reads back as
* "the last release". Workspaces whose members are repositories
* IKE does not own must follow those repositories' own convention:
* the komet working set's ikmdev members carry bare version tags,
* so its root declares
* {@code <ike.release.tagStyle>BARE</ike.release.tagStyle>}.
* Defaults to IKE's own {@code v}-prefixed form.
*/
@Parameter(property = "ike.release.tagStyle", defaultValue = "V_PREFIXED")
ReleaseTagStyle tagStyle = ReleaseTagStyle.V_PREFIXED;
/** Creates this goal instance. */
public WsReleaseDraftMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
if (!isWorkspaceMode()) {
return executeBareMode();
}
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
// ── 1. Determine candidate components ─────────────────────────
List<String> candidates = graph.topologicalSort();
boolean draft = !publish;
// Refresh local main from origin/main before any release work —
// the cascade picks up parent versions and property bumps off
// local main, and Syncthing-paired workflows can leave local
// main stale (ike-issues#284). Same invariant the feature
// flows establish; releases need it for the same reason.
if (publish) {
RefreshMainSupport.refreshOrThrow(root, candidates, "main", getLog());
}
// ── 1.5. Pre-release upstream alignment ──────────────────────────
// An ike-parent bump (or any foundation property bump) is a
// release-worthy change even when no source has been edited.
// Without this pass, ws:release-publish only saw per-subproject
// source changes and skipped workspaces whose only diff vs.
// last release was "absorb the new foundations".
//
// For each subproject (and the workspace root), scan its pom
// for <parent> blocks and <X.version> properties referencing
// an IKE foundation. If a reference is older than the
// foundation's latest released version (resolved from the
// sibling repo's tip v* tag in the canonical ~/ike-dev/
// layout), bump via OpenRewrite and commit. The bump commit
// becomes a meaningful commit, so the existing
// meaningfulCommitsSinceTag detector includes the subproject
// in the release set automatically.
//
// Also clean any on-disk gh-pages leak directories
// (<pomDir>/<artifactId>/<artifactId>/index.html — the #358
// signature). Those are produced by a stale ike-parent's
// broken <site><url> inheritance; once alignment bumps the
// parent to a fixed version (v45+), they stop being produced,
// but the existing dirs remain on disk until somebody
// explicitly removes them. Doing it here avoids the
// chicken-and-egg where the NO_ON_DISK_GHPAGES_LEAK preflight
// would block on a leak that alignment is about to fix at
// the source — a stale workspace on its first cascade after
// the fix shipped.
//
// Runs BEFORE preflight (rather than after) for the same
// reason: alignment and the leak cleanup it triggers are
// exactly what makes the preflight checks pass. WORKING_TREE_CLEAN
// is unaffected — alignment commits its own changes, leaving
// worktree clean from git's POV; gitignored leak dirs were
// never counted by `git status --porcelain` anyway.
//
// Both modes compute the alignment plan; only publish applies
// it (drafts preview, never mutate). The draft reports the
// plan and folds planned members into the release set below,
// so draft and publish describe the same mission
// (ike-issues#1000 finding 1).
Map<String, List<String>> alignmentPlan =
computeUpstreamAlignmentPlan(graph, root);
if (publish) {
preReleaseUpstreamAlignment(graph, root);
} else if (!alignmentPlan.isEmpty()) {
getLog().info("Pre-release alignment plan (#377) — applied on publish:");
for (Map.Entry<String, List<String>> entry : alignmentPlan.entrySet()) {
getLog().info(" " + entry.getKey() + ":");
for (String bump : entry.getValue()) {
getLog().info(" " + bump);
}
}
}
// (The release preflight runs AFTER release-set detection — see
// below — so NO_SNAPSHOT_PROPERTIES can exempt properties this
// mission's own catch-up alignment resolves, ike-issues#981.
// Detection is read-only, so the reorder mutates nothing
// earlier than before.)
// ── 2a. Detect source-changed checked-out subprojects ────────────
// First pass: gather the set of subprojects whose own commits
// require a release. Cascade-only downstream is added in 2b.
Map<String, ReleaseCandidate> releasable = new LinkedHashMap<>();
Set<String> sourceChanged = new LinkedHashSet<>();
for (String name : graph.topologicalSort()) {
if (!candidates.contains(name)) continue;
Subproject sub = graph.manifest().subprojects().get(name);
if (sub == null) continue;
// Tag-aligned members left the release cascade (#973): they
// are pinned at a released tag; commits on their checkout do
// not make them release-pending here.
if (!sub.isSnapshotAligned()) {
getLog().info(" Skipping " + name + " — tag-aligned (pinned "
+ sub.tag() + "; not in the release cascade)");
continue;
}
File subDir = new File(root, name);
if (!subDir.isDirectory() || !new File(subDir, "pom.xml").exists()) {
getLog().debug("Skipping " + name + " — not checked out");
continue;
}
String latestTag = latestReleaseTag(subDir);
if (latestTag == null) {
// No release tag exists — subproject has never been released
releasable.put(name, new ReleaseCandidate(name, sub, subDir,
null, "never released"));
sourceChanged.add(name);
continue;
}
// #347: count only commits whose subjects don't match
// the release-cadence pattern, so retries of a partial
// cascade don't re-release subprojects whose only
// post-tag commits are release/merge/post-release/site
// bookkeeping from a previous successful attempt.
int meaningfulCommits =
meaningfulCommitsSinceTag(subDir, latestTag);
if (meaningfulCommits > 0) {
releasable.put(name, new ReleaseCandidate(name, sub, subDir,
latestTag, meaningfulCommits
+ (meaningfulCommits == 1 ? " commit since "
: " commits since ") + latestTag));
sourceChanged.add(name);
continue;
}
// A planned-but-unapplied upstream alignment is a change
// this mission WILL make (publish commits it before this
// detection runs). Folding it in here keeps the draft's
// release set identical to the publish's
// (ike-issues#1000 finding 1).
List<String> pendingAlignment = alignmentPlan.get(name);
if (pendingAlignment != null) {
releasable.put(name, new ReleaseCandidate(name, sub, subDir,
latestTag, "upstream alignment pending ("
+ String.join(", ", pendingAlignment) + ")"));
sourceChanged.add(name);
continue;
}
getLog().debug("Skipping " + name + " — clean (at "
+ latestTag + "; only cadence commits since)");
}
// ── 2b. Cascade — add transitive downstream of source-changed ───
// Every checked-out subproject that depends (directly or
// transitively) on a source-changed subproject must also release
// so its parent/property references can pick up the new upstream
// version. Catch-up never expands the release set: subprojects
// with stale properties but no source change and no upstream in
// this mission stay out.
Set<String> releaseSet = computeReleaseSet(graph, sourceChanged);
for (String name : releaseSet) {
if (releasable.containsKey(name)) continue;
Subproject sub = graph.manifest().subprojects().get(name);
if (sub == null) continue;
File subDir = new File(root, name);
if (!subDir.isDirectory() || !new File(subDir, "pom.xml").exists()) {
getLog().info(" Skipping cascaded " + name
+ " — not checked out (downstream version stays stale)");
continue;
}
String latestTag = latestReleaseTag(subDir);
String reason = "downstream of " + describeUpstreamCause(
name, graph, sourceChanged);
releasable.put(name, new ReleaseCandidate(name, sub, subDir,
latestTag, reason));
}
if (releasable.isEmpty()) {
getLog().info("No components need releasing. All are clean.");
return new WorkspaceReportSpec(
publish ? WsGoal.RELEASE_PUBLISH : WsGoal.RELEASE_DRAFT,
"No components need releasing — all are clean.\n");
}
// ── Preflight: all working trees clean, no POM-shape gotchas ──
// (Javadoc cleanliness is checked per-module by ike:release
// preflight — see ReleaseDraftMojo — so every entry point
// enforces it, not only workspace-level releases.)
//
// #346 expanded the preflight set so the dry-run is
// authoritative: every cascade-time gotcha that has bitten a
// release is checked at draft time, not discovered mid-flight
// after some subprojects have already tagged:
// WORKING_TREE_CLEAN — #132 #154
// NO_SNAPSHOT_PROPERTIES — Maven 4 consumer
// flattener leak;
// release-set-aware
// since #981
// NO_EXTERNAL_SNAPSHOT_DEPENDENCIES — literal snapshot
// references the mission
// neither releases nor
// retargets (#1022)
// SUBPROJECT_HAS_DISTRIBUTION_MANAGEMENT — site:stage gate
// (#343 surfaced)
// NO_FOUNDATION_PROPERTY_SHADOWING — ike-tooling.version
// shadowing pinned the
// plugin to a stale
// version that lacked
// newer goals
// PARENT_COHERENCE — #324 release gate
// form
// Runs after detection so the context can carry this mission's
// release set (#981): a SNAPSHOT property tracking a member
// that releases in this mission is resolved by the loop's
// catch-up alignment before its consumer releases, and must
// not refuse the publish.
// ── 2b. Topological order + release plan ─────────────────────
// The plan is pure computation over the checked-out POMs, and
// the preflight needs it: only the plan knows which version
// properties this mission de-qualifies (ike-issues#1004). It is
// logged and written further down, where the report reads.
List<String> releaseOrder = graph.topologicalSort().stream()
.filter(releasable::containsKey)
.toList();
ReleasePlan plan;
try {
plan = buildReleasePlan(releaseOrder, releasable);
} catch (IOException e) {
throw new MojoException(
"Release plan compute failed: " + e.getMessage(), e);
}
Set<String> missionResolvedProperties = new LinkedHashSet<>();
for (ReleasePlan.PropertyReleasePlan property : plan.properties()) {
missionResolvedProperties.add(property.declaringSubproject()
+ "::" + property.propertyName());
}
// The artifacts the plan de-qualifies: references to them are
// retargeted by the version pass, so the external-snapshot gate
// (ike-issues#1022) must not refuse them.
Set<String> missionReleasedArtifacts = new LinkedHashSet<>();
for (ReleasePlan.GA ga : plan.artifacts().keySet()) {
missionReleasedArtifacts.add(ga.toString());
}
PreflightResult releasePreflight = Preflight.of(
List.of(PreflightCondition.WORKING_TREE_CLEAN,
PreflightCondition.NO_ON_DISK_GHPAGES_LEAK,
PreflightCondition.NO_SCPEXE_SITE_URLS,
PreflightCondition.NO_SNAPSHOT_PROPERTIES,
PreflightCondition.NO_EXTERNAL_SNAPSHOT_DEPENDENCIES,
PreflightCondition.SUBPROJECT_HAS_DISTRIBUTION_MANAGEMENT,
PreflightCondition.NO_FOUNDATION_PROPERTY_SHADOWING,
PreflightCondition.PARENT_COHERENCE),
PreflightContext.of(root, graph, candidates,
releasable.keySet(), missionResolvedProperties,
missionReleasedArtifacts));
if (draft) {
releasePreflight.warnIfFailed(getLog(), WsGoal.RELEASE_PUBLISH);
} else {
releasePreflight.requirePassed(WsGoal.RELEASE_PUBLISH);
}
// ── 4. Report plan ────────────────────────────────────────────
getLog().info("════════════════════════════════════════════════════");
getLog().info(draft ? " WORKSPACE RELEASE — DRAFT" : " WORKSPACE RELEASE");
getLog().info("════════════════════════════════════════════════════");
getLog().info("");
getLog().info("Components to release (" + releaseOrder.size() + "):");
for (int i = 0; i < releaseOrder.size(); i++) {
ReleaseCandidate rc = releasable.get(releaseOrder.get(i));
String version = currentVersion(rc.dir);
getLog().info(" " + (i + 1) + ". " + rc.name
+ " (" + version + ") — " + rc.reason);
}
getLog().info("");
// ── 4a. Compute release plan (single source of truth) ────────
// One plan for the entire cascade, computed once up front. Every
// pre-release alignment is a blind lookup in this plan — no
// mid-flight heuristics. See dev-release-plan design topic.
logReleasePlan(plan);
writeReleasePlan(root, plan);
// ── 4b. Reactor-pass mission (ike-issues#997) ───────────────────
// One version pass, one reactor verify, deploy scoped to the
// releasing set, tags per repository, published at the
// working-set level. The draft describes the mission leniently —
// an assembly problem (missing root POM, underivable version)
// becomes a report line, because a draft is a preview; the
// publish assembles strictly and fails fast.
String mvnLauncher = findMvn(root);
if (draft) {
getLog().info("");
try {
WorkspaceReleaseMission preview = buildMission(releaseOrder,
releasable, plan, root);
for (String line : preview.describe(mvnLauncher)) {
getLog().info(line);
}
} catch (Exception assembly) {
getLog().warn(" ⚠ Mission assembly would fail on publish: "
+ assembly.getMessage());
}
getLog().info("");
getLog().info("[DRAFT] No releases executed (draft mode).");
return new WorkspaceReportSpec(WsGoal.RELEASE_DRAFT,
buildReleasePlanMarkdownReport(releaseOrder, releasable));
}
WorkspaceReleaseMission mission = buildMission(releaseOrder, releasable,
plan, root);
// ── 5. Pre-release checkpoint ─────────────────────────────────
if (!skipCheckpoint) {
String checkpointName = "pre-release-"
+ Instant.now().atZone(ZoneOffset.UTC)
.format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
getLog().info("Creating pre-release checkpoint: " + checkpointName);
writeCheckpoint(root, graph, checkpointName);
}
// ── 6. Execute the reactor-pass mission (ike-issues#997) ────────
// One version pass across every releasing repository, one
// reactor verify at release versions, deploy scoped to the
// releasing set, tags per repository, the mission record in the
// root's release commit, post-bump, push. The commit subjects
// are release-cadence forms, so an interrupted mission's re-run
// never counts them as source changes.
Map<String, String> releasedVersions;
try {
releasedVersions = mission.execute(mvnLauncher, skipCycleTests);
} catch (MojoException e) {
getLog().error(Ansi.red(" ✗ ") + "Release mission failed: "
+ e.getMessage());
getLog().error(" Roll forward: fix the cause and re-run —"
+ " completed phases resume, they do not repeat.");
throw e;
}
// ── 6a. Sync workspace.yaml version: fields (#371) ────────────
// The post-bump moved every releasing member to its next
// development SNAPSHOT; fold those into the manifest in one
// commit so it never drifts.
Map<String, String> manifestVersionUpdates = new LinkedHashMap<>();
for (String name : releaseOrder) {
ReleaseCandidate rc = releasable.get(name);
try {
String postVersion = currentVersion(rc.dir);
if (postVersion != null && !postVersion.isBlank()) {
manifestVersionUpdates.put(name, postVersion);
}
} catch (Exception readFail) {
getLog().warn(" ⚠ Could not read post-release version for "
+ name + " — workspace.yaml version: stays stale"
+ " until the next "
+ WsGoal.SCAFFOLD_PUBLISH.qualified());
}
}
if (!manifestVersionUpdates.isEmpty()) {
syncWorkspaceVersions(root, manifestVersionUpdates);
}
// ── 7. Summary ───────────────────────────────────────────────
getLog().info("");
getLog().info("════════════════════════════════════════════════════");
getLog().info(" WORKSPACE RELEASE COMPLETE");
getLog().info("════════════════════════════════════════════════════");
for (Map.Entry<String, String> entry : releasedVersions.entrySet()) {
getLog().info(" " + entry.getKey() + " → " + entry.getValue());
}
getLog().info("");
// ── 8. GitHub Release (optional) ──────────────────────────────
if (githubRepo != null && !githubRepo.isBlank()) {
createGitHubReleases(root, releasedVersions);
}
// Structured markdown report
return new WorkspaceReportSpec(
publish ? WsGoal.RELEASE_PUBLISH : WsGoal.RELEASE_DRAFT,
buildReleaseMarkdownReport(releasedVersions));
}
/**
* Assemble the reactor-pass mission (ike-issues#997) from the detected
* release set: every releasing member plus the workspace root, which
* releases each mission as the record's anchor (one mission, one root
* increment).
*
* @param releaseOrder releasing member names in dependency order
* @param releasable the detected release candidates
* @param plan the computed release plan
* @param root the workspace root directory
* @return the executable mission
*/
private WorkspaceReleaseMission buildMission(List<String> releaseOrder,
Map<String, ReleaseCandidate> releasable, ReleasePlan plan,
File root) {
List<WorkspaceReleaseMission.ReleasingRepo> repos = new ArrayList<>();
for (String name : releaseOrder) {
repos.add(releasingRepo(name, releasable.get(name).dir));
}
WorkspaceReleaseMission.ReleasingRepo rootRepo =
releasingRepo("(workspace root)", root);
String canonical =
rootRepo.artifactId() + "-" + rootRepo.releaseVersion();
String explicit = missionLabel;
if ((explicit == null || explicit.isBlank())
&& cycleLabelDeprecated != null
&& !cycleLabelDeprecated.isBlank()) {
getLog().warn("-Dcycle is deprecated — the release iteration"
+ " is a mission; use -Dmission"
+ " (ike-issues#1038).");
explicit = cycleLabelDeprecated;
}
String label = (explicit != null && !explicit.isBlank())
? explicit
: canonical;
guardMissionLabel(label, canonical, publish, acceptMissionLabel,
getLog());
return new WorkspaceReleaseMission(root, repos, rootRepo, plan,
label, java.time.LocalDate.now().toString(), tagStyle,
getLog(),
(dir, command) ->
ReleaseSupport.exec(dir, getLog(), command));
}
/**
* Refuse (publish) or warn about (draft) a mission label that
* diverges from the canonical
* {@code <root artifactId>-<root release version>}. The delivery
* chain resolves the mission record by the canonical name, so a
* drifted label publishes the release against a stale record
* (IKE-Network/ike-issues#1035 — mission 6 shipped with mission 5's
* release body this way). Static and pure for direct unit testing.
*
* @param label the resolved mission label
* @param canonical the canonical label for this mission
* @param publish whether this run publishes (refuse) or drafts
* (warn)
* @param accepted whether the operator confirmed the divergence
* ({@code -DacceptMissionLabel=true})
* @param log Maven logger for the draft warning
* @throws MojoException when publishing with an unconfirmed
* divergent label
*/
static void guardMissionLabel(String label, String canonical,
boolean publish, boolean accepted, Log log)
throws MojoException {
if (label.equals(canonical) || accepted) {
return;
}
String message = "Mission label `" + label + "` diverges from the"
+ " canonical `" + canonical + "` — the delivery chain"
+ " resolves the record releases/release-" + canonical
+ ".yaml by that name, so a drifted label publishes"
+ " against a stale record (ike-issues#1035). Omit"
+ " -Dmission to use the canonical label, or confirm the"
+ " custom label with -DacceptMissionLabel=true.";
if (publish) {
throw new MojoException(message);
}
log.warn(message);
}
/**
* A repository's release identity for the mission: versions derived
* from its root POM (pre = the current development SNAPSHOT,
* release = the de-qualified form, post = the next SNAPSHOT), the
* artifactId for the scoped-deploy selector.
*
* @param name the display name
* @param dir the repository directory
* @return the mission repo record
*/
private WorkspaceReleaseMission.ReleasingRepo releasingRepo(String name,
File dir) {
String pre = currentVersion(dir);
String release = pre.replace("-SNAPSHOT", "");
String post = ReleaseSupport.deriveNextSnapshot(release);
String artifactId;
try {
artifactId = PomModel.parse(new File(dir, "pom.xml").toPath())
.model().getArtifactId();
} catch (IOException e) {
throw new MojoException("Cannot read artifactId for " + name
+ ": " + e.getMessage(), e);
}
return new WorkspaceReleaseMission.ReleasingRepo(name, dir,
artifactId, pre, release, post);
}
/**
* Compute the release set: source-changed subprojects union the
* transitive downstream cascade of each.
*
* <p>This is a pure function over the workspace graph and the set
* of source-changed subprojects. Cascade is computed via
* {@link WorkspaceGraph#cascade(String)} (BFS on reverse edges).
* Order in the returned set follows the graph's topological sort.
*
* <p>By construction, the release set contains every member of
* {@code sourceChanged} plus everything that depends on any of
* them (directly or transitively). It never contains a subproject
* that has neither a source change nor a release-set upstream —
* stale properties alone cannot expand the release set (see #192).
*
* @param graph the workspace dependency graph
* @param sourceChanged subproject names whose own commits warrant
* a release this mission
* @return release set in topological order (dependencies first)
*/
public static Set<String> computeReleaseSet(WorkspaceGraph graph,
Set<String> sourceChanged) {
Set<String> set = new LinkedHashSet<>();
for (String name : sourceChanged) {
Subproject seed = graph.manifest().subprojects().get(name);
// Tag-aligned members left the release cascade (#973): drop
// them as seeds and never pull them in as downstream. Names
// absent from the manifest are seeded as before — the topo
// reorder below governs their fate, exactly as it always has.
if (seed != null && !seed.isSnapshotAligned()) continue;
set.add(name);
if (seed == null) continue;
for (String downstream : graph.cascade(name)) {
Subproject cascaded =
graph.manifest().subprojects().get(downstream);
if (cascaded == null || cascaded.isSnapshotAligned()) {
set.add(downstream);
}
}
}
// Reorder by topo sort so the result is deterministic and matches
// the dependency order callers expect.
Set<String> ordered = new LinkedHashSet<>();
for (String name : graph.topologicalSort()) {
if (set.contains(name)) ordered.add(name);
}
return ordered;
}
/**
* Describe which source-changed subproject(s) caused a downstream
* subproject to be cascaded into the release set. Used purely for
* the human-readable "downstream of X" reason in the release plan.
*
* <p>If multiple source-changed subprojects are upstream, returns
* the topologically-nearest set joined by {@code ", "}.
*/
private static String describeUpstreamCause(String downstream,
WorkspaceGraph graph,
Set<String> sourceChanged) {
// Walk the forward edges to find which source-changed subprojects
// this one transitively depends on. Use BFS from downstream.
List<String> causes = new ArrayList<>();
Set<String> visited = new LinkedHashSet<>();
java.util.Deque<String> queue = new java.util.ArrayDeque<>();
queue.add(downstream);
visited.add(downstream);
while (!queue.isEmpty()) {
String current = queue.poll();
Subproject sub = graph.manifest().subprojects().get(current);
if (sub == null) continue;
for (network.ike.workspace.Dependency dep : sub.dependsOn()) {
String up = dep.subproject();
if (!visited.add(up)) continue;
if (sourceChanged.contains(up)) {
causes.add(up);
} else {
queue.add(up);
}
}
}
if (causes.isEmpty()) return "(unknown upstream)";
return String.join(", ", causes);
}
/**
* Create GitHub Releases for released components and attach
* platform installers. Uses {@code gh} CLI. Each subproject gets
* a release tagged {@code v<version>}. If the release already
* exists, uploads are appended with {@code --clobber}.
*/
private void createGitHubReleases(File root,
Map<String, String> releasedVersions)
throws MojoException {
for (Map.Entry<String, String> entry : releasedVersions.entrySet()) {
String name = entry.getKey();
String version = entry.getValue();
String tag = "v" + version;
File subDir = new File(root, name);
// Collect installer artifacts
java.nio.file.Path targetDir = subDir.toPath().resolve("target");
List<String> artifacts = new ArrayList<>();
if (java.nio.file.Files.exists(targetDir)) {
try {
java.nio.file.PathMatcher matcher =
targetDir.getFileSystem().getPathMatcher(
"glob:" + installerGlob);
try (Stream<Path> walk = java.nio.file.Files.walk(targetDir, 3)) {
walk.filter(java.nio.file.Files::isRegularFile)
.filter(p -> matcher.matches(
targetDir.relativize(p)))
.forEach(p -> artifacts.add(p.toString()));
}
} catch (java.io.IOException e) {
getLog().debug("Could not scan installers for " + name
+ ": " + e.getMessage());
}
}
getLog().info(" Creating GitHub Release: " + tag
+ (artifacts.isEmpty() ? ""
: " (" + artifacts.size() + " installer"
+ (artifacts.size() == 1 ? "" : "s") + ")"));
try {
// Try create first; fall back to upload if release exists
List<String> cmd = new ArrayList<>(List.of(
"gh", "release", "create", tag,
"--repo", githubRepo,
"--title", name + " " + version,
"--generate-notes"));
cmd.addAll(artifacts);
ReleaseSupport.exec(subDir, getLog(),
cmd.toArray(String[]::new));
} catch (MojoException e) {
// Release may already exist — append assets
if (!artifacts.isEmpty()) {
try {
List<String> uploadCmd = new ArrayList<>(List.of(
"gh", "release", "upload", tag,
"--repo", githubRepo, "--clobber"));
uploadCmd.addAll(artifacts);
ReleaseSupport.exec(subDir, getLog(),
uploadCmd.toArray(String[]::new));
} catch (MojoException uploadErr) {
getLog().warn(" Could not upload to release " + tag
+ ": " + uploadErr.getMessage());
}
} else {
getLog().warn(" GitHub Release creation failed for "
+ tag + ": " + e.getMessage());
}
}
}
}
/**
* Build the draft-mode report: the planned release order and the
* reason each subproject is included. No subproject is released in
* draft mode, so this records intent rather than outcomes.
*
* @param releaseOrder topologically sorted names of the release plan
* @param releasable release candidates keyed by subproject name
* @return the Markdown report body
*/
private String buildReleasePlanMarkdownReport(
List<String> releaseOrder,
Map<String, ReleaseCandidate> releasable) {
List<String[]> rows = new ArrayList<>();
for (String name : releaseOrder) {
ReleaseCandidate rc = releasable.get(name);
rows.add(new String[] {
rc.name, currentVersion(rc.dir), rc.reason });
}
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph(releaseOrder.size()
+ " subproject(s) would be released (draft).")
.table(List.of("Subproject", "Version", "Reason"), rows);
return report.build();
}
private String buildReleaseMarkdownReport(
Map<String, String> releasedVersions) {
List<String[]> rows = new ArrayList<>();
for (Map.Entry<String, String> entry : releasedVersions.entrySet()) {
rows.add(new String[] {
entry.getKey(), entry.getValue(), "✓" });
}
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph(releasedVersions.size()
+ " subproject(s) released.")
.table(List.of("Subproject", "Version", "Status"), rows);
return report.build();
}
// ── Helper: find latest release tag ──────────────────────────────
private String latestReleaseTag(File subDir) {
return latestReleaseTag(subDir, tagStyle);
}
/**
* The newest release tag in the given style, or {@code null} when
* the repository has none. Git's version sort orders the
* candidates; the style's own pattern then rejects the
* lookalikes that share the glob (dated tags, hand-cut
* development tags), so the first survivor is the real latest.
*
* @param subDir the repository directory
* @param style the working set's tag style
* @return the newest release tag, or {@code null}
*/
static String latestReleaseTag(File subDir, ReleaseTagStyle style) {
try {
String tags = ReleaseSupport.execCapture(subDir,
"git", "tag", "-l", style.tagGlob(),
"--sort=-version:refname");
if (tags == null || tags.isBlank()) return null;
return tags.lines()
.map(String::strip)
.filter(style::isReleaseTag)
.findFirst()
.orElse(null);
} catch (Exception e) {
return null;
}
}
// ── Helper: count commits since tag ──────────────────────────────
private int commitsSinceTag(File subDir, String tag) {
try {
String count = ReleaseSupport.execCapture(subDir,
"git", "rev-list", tag + "..HEAD", "--count");
return Integer.parseInt(count.strip());
} catch (Exception e) {
return -1;
}
}
// ── Helper: count meaningful (non-release-cadence) commits ────────
//
// ike-issues#347: when a partial cascade fails after some
// subprojects have already released, retries kept finding "new"
// commits since each subproject's tag — the post-release-bump
// commit, the merge commit, and the release-set-version commit
// produced by the prior attempt's ike:release-publish. Each retry
// saw commitsSinceTag > 0 and re-released, ratcheting subprojects
// forward by one version per retry.
//
// Filter out commits whose subject matches the well-known
// release-cadence patterns produced by ReleaseSupport:
// - "release: set version to N"
// - "release: restore ${project.version} references"
// - "merge: release N"
// - "post-release: bump to <next>-SNAPSHOT"
// - "site: publish <project> N"
//
// If every commit since the tag matches one of those patterns,
// the subproject has no real source changes — return 0 so the
// outer logic treats it as already-released.
private static final java.util.regex.Pattern RELEASE_CADENCE_PATTERN =
java.util.regex.Pattern.compile(
"^(release: set version to .+"
+ "|release: restore .+"
+ "|merge: release .+"
+ "|post-release: bump to .+"
+ "|site: publish .+)$");
/**
* Count commits between {@code tag} and HEAD whose subjects do
* NOT match a release-cadence pattern.
*
* <p>Used in place of {@link #commitsSinceTag(File, String)} for
* "do we need to release this again?" decisions, so that retries
* after a partial cascade failure don't re-release subprojects
* whose only post-tag commits are cadence-emitted ones.
*
* @param subDir the subproject directory
* @param tag the latest release tag
* @return number of non-cadence commits since {@code tag}, or
* {@code -1} on error
*/
int meaningfulCommitsSinceTag(File subDir, String tag) {
try {
String log = ReleaseSupport.execCapture(subDir,
"git", "log", tag + "..HEAD",
"--pretty=format:%s", "--no-merges");
if (log == null) return 0;
String trimmed = log.strip();
if (trimmed.isEmpty()) return 0;
int count = 0;
for (String subject : trimmed.split("\n")) {
if (!RELEASE_CADENCE_PATTERN.matcher(subject.strip()).matches()) {
count++;
}
}
return count;
} catch (Exception e) {
return -1;
}
}
/**
* Test whether a commit subject matches a release-cadence
* pattern (would be filtered out by
* {@link #meaningfulCommitsSinceTag}). Public for unit testing.
*
* @param subject the commit subject line
* @return {@code true} when the subject is cadence-emitted
*/
public static boolean isReleaseCadenceCommit(String subject) {
if (subject == null) return false;
return RELEASE_CADENCE_PATTERN.matcher(subject.strip()).matches();
}
// ── Pre-release upstream alignment (#377) ─────────────────────────
// Foundation-tracking map used by preReleaseUpstreamAlignment.
// groupId → foundation name in ~/ike-dev/<name>/. Each foundation
// releases as a single Maven reactor whose tip v* tag is the
// version we'll align downstream consumers to.
static final Map<String, String> FOUNDATION_GROUP_TO_DIR = Map.of(
"network.ike.tooling", "ike-tooling",
"network.ike.docs", "ike-docs",
"network.ike.platform", "ike-platform");
// Property-name → groupId. Properties shaped <X.version> conventionally
// pin coordinates whose groupId starts with "network.ike.X" (with
// ike-platform handling both "platform" and "parent" because
// ike-parent ships from the ike-platform reactor).
static final Map<String, String> PROPERTY_TO_GROUP = Map.of(
"ike-tooling.version", "network.ike.tooling",
"ike-docs.version", "network.ike.docs",
"ike-platform.version", "network.ike.platform");
/**
* Before release detection runs, walk each subproject (and the
* workspace root) and bump any stale upstream-foundation references
* to the latest released version. An upstream is "stale" when the
* pom declares a {@code <parent>} or {@code <X.version>} property
* pinned older than the foundation's tip {@code v*} tag in the
* canonical {@code ~/ike-dev/<foundation>/} layout. Bumps land as
* "chore: align upstream versions before release" commits per
* subproject — those commits register as meaningful, so the
* {@link #meaningfulCommitsSinceTag} detector includes the
* subproject in the release set automatically.
*
* <p>This is what makes "ike-parent was released, so absorb it"
* a release-worthy change. Without it, ws:release-publish only
* saw per-subproject source edits and missed transitive-dependency
* upgrades entirely (ike-issues#377).
*
* <p>Non-fatal: failures (unreadable poms, git commit errors)
* log a warning and continue. Worst case the alignment doesn't
* commit and the release subsequently treats the subproject as
* "no meaningful commits" — same outcome as before this method
* existed, no regression.
*
* @param graph the loaded workspace graph
* @param root the workspace root directory
*/
/**
* Compute the pre-release upstream-alignment plan without touching
* anything: which walked poms have stale foundation references,
* and the exact bumps each would receive. The walk covers the
* workspace root (keyed {@code "(workspace root)"}) and every
* snapshot-aligned subproject — tag-aligned members are pinned
* outside the release cascade (#973) and must not be aligned
* either (ike-issues#1000 finding 2).
*
* <p>Both modes call this: the draft reports the plan (and folds
* planned members into the printed release set); publish applies
* it via {@link #preReleaseUpstreamAlignment}.
*
* @param graph the loaded workspace graph
* @param root the workspace root directory
* @return insertion-ordered member → bump descriptions; empty when
* nothing needs alignment (or no foundation layout exists)
*/
Map<String, List<String>> computeUpstreamAlignmentPlan(
WorkspaceGraph graph, File root) {
Map<String, List<String>> plan = new LinkedHashMap<>();
Map<String, String> groupIdToLatest = foundationLatestVersions(root);
if (groupIdToLatest.isEmpty()) return plan;
for (Map.Entry<String, File> entry
: alignmentWalk(graph, root).entrySet()) {
AlignmentEdit edit =
plannedAlignmentEdit(entry.getValue(), groupIdToLatest);
if (!edit.bumps().isEmpty() && edit.newContent() != null) {
plan.put(entry.getKey(), edit.bumps());
}
}
return plan;
}
/**
* Resolve each IKE foundation's latest released version from its
* sibling repo's tip {@code v*} tag in the canonical
* {@code ~/ike-dev/<foundation>/} layout.
*
* @param root the workspace root directory
* @return foundation groupId → latest released version; empty when
* no sibling layout or no tags are found
*/
private Map<String, String> foundationLatestVersions(File root) {
Map<String, String> groupIdToLatest = new LinkedHashMap<>();
File foundationsDir = root.getParentFile();
if (foundationsDir == null || !foundationsDir.isDirectory()) {
getLog().debug(" No siblings directory available for foundation lookup; "
+ "skipping pre-release alignment.");
return groupIdToLatest;
}
for (Map.Entry<String, String> entry : FOUNDATION_GROUP_TO_DIR.entrySet()) {
File siblingDir = new File(foundationsDir, entry.getValue());
if (!siblingDir.isDirectory()) continue;
// The foundations are IKE's own repositories and always
// tag v-prefixed, whatever style this working set uses for
// its members. Reading them through the member style would
// find nothing in a BARE workspace and silently skip
// alignment altogether (ike-issues#1000).
String tag = latestReleaseTag(siblingDir,
ReleaseTagStyle.V_PREFIXED);
if (tag == null) continue;
groupIdToLatest.put(entry.getKey(),
ReleaseTagStyle.V_PREFIXED.versionOf(tag));
}
if (groupIdToLatest.isEmpty()) {
getLog().debug(" No foundation tags found near " + foundationsDir
+ "; skipping pre-release alignment.");
}
return groupIdToLatest;
}
/**
* The alignment walk: workspace root first (keyed
* {@code "(workspace root)"}), then every snapshot-aligned,
* checked-out subproject in manifest order. Tag-aligned members
* are excluded — they are pinned at a released tag and outside
* the cascade, so alignment must not mutate them.
*
* @param graph the loaded workspace graph
* @param root the workspace root directory
* @return insertion-ordered member name → pom file
*/
private Map<String, File> alignmentWalk(WorkspaceGraph graph, File root) {
Map<String, File> walk = new LinkedHashMap<>();
walk.put("(workspace root)", new File(root, "pom.xml"));
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
Subproject sub = entry.getValue();
if (sub != null && !sub.isSnapshotAligned()) {
getLog().debug(" Alignment skips " + entry.getKey()
+ " — tag-aligned (pinned " + sub.tag() + ")");
continue;
}
File pom = new File(new File(root, entry.getKey()), "pom.xml");
if (pom.isFile()) walk.put(entry.getKey(), pom);
}
return walk;
}
private void preReleaseUpstreamAlignment(WorkspaceGraph graph, File root) {
Map<String, String> groupIdToLatest = foundationLatestVersions(root);
if (groupIdToLatest.isEmpty()) return;
int aligned = 0;
int leaksCleaned = 0;
for (File pom : alignmentWalk(graph, root).values()) {
if (alignPom(pom, groupIdToLatest)) aligned++;
if (cleanGhPagesLeak(pom)) leaksCleaned++;
}
if (aligned > 0) {
getLog().info(" Pre-release alignment: bumped upstream references in "
+ aligned + " pom(s) (#377).");
}
if (leaksCleaned > 0) {
getLog().info(" Pre-release alignment: removed gh-pages leak from "
+ leaksCleaned + " pom dir(s) (#358).");
}
}
/**
* Auto-clean any on-disk gh-pages leak directory under the given
* pom's project directory. The leak signature is exactly the one
* {@code PreflightCondition.NO_ON_DISK_GHPAGES_LEAK} detects:
* {@code <pomDir>/<artifactId>/<artifactId>/index.html} — produced
* by maven-site-plugin's site:stage when {@code ike-parent}'s
* site URL inheritance was broken (ike-issues#358; root cause
* fixed in ike-parent v45+). The directory always escapes
* {@code target/} and is gitignored, so {@code git status} doesn't
* see it — operators discover it only when the preflight blocks
* their release.
*
* <p>This pre-release step cleans the leak BEFORE the preflight
* runs, so a workspace inheriting a still-stale ike-parent (and
* therefore still producing leaks) can bootstrap to a newer
* ike-parent in the same cascade without the operator having to
* {@code rm -rf} by hand. After that one bootstrap cascade,
* future builds don't leak.
*
* <p>Per {@code feedback_workspace_ops_completion}: recoverable
* side effects default on.
*
* @param pomFile the pom whose project directory to inspect
* @return {@code true} when a leak was cleaned, {@code false} otherwise
*/
private boolean cleanGhPagesLeak(File pomFile) {
File pomDir = pomFile.getParentFile();
if (pomDir == null) return false;
String artifactId;
try {
String content = Files.readString(pomFile.toPath(),
StandardCharsets.UTF_8);
artifactId = extractArtifactId(content);
} catch (IOException e) {
return false;
}
if (artifactId == null || artifactId.isBlank()) return false;
java.nio.file.Path leakDir = pomDir.toPath()
.resolve(artifactId).resolve(artifactId);
java.nio.file.Path leakIndex = leakDir.resolve("index.html");
if (!Files.isRegularFile(leakIndex)) return false;
// The OUTER doubled dir is the one to remove; .resolve(artifactId)
// once gives us <pomDir>/<artifactId>/ which is what
// NO_ON_DISK_GHPAGES_LEAK's report prints as the rm-rf path.
java.nio.file.Path outerLeak = pomDir.toPath().resolve(artifactId);
try {
deleteRecursively(outerLeak);
getLog().info(" Cleaned gh-pages leak: " + pomDir.getName()
+ "/" + artifactId + "/ (#358)");
return true;
} catch (IOException e) {
getLog().warn(" Could not clean leak dir " + outerLeak
+ ": " + e.getMessage());
return false;
}
}
/**
* Extract the project's own {@code <artifactId>}. Skips a
* preceding {@code <parent>} block so we don't return the parent's
* artifactId. Same shape as the helper in
* {@code RegisterSiteDraftMojo} — repeated here to keep the
* dependency direction (this mojo doesn't depend on it).
*/
private static String extractArtifactId(String pomContent) {
if (pomContent == null) return null;
int searchFrom = 0;
int parentOpen = pomContent.indexOf("<parent>");
if (parentOpen >= 0) {
int parentClose = pomContent.indexOf("</parent>", parentOpen);
if (parentClose > parentOpen) {
searchFrom = parentClose + "</parent>".length();
}
}
int open = pomContent.indexOf("<artifactId>", searchFrom);
if (open < 0) return null;
int valueStart = open + "<artifactId>".length();
int close = pomContent.indexOf("</artifactId>", valueStart);
if (close < 0) return null;
return pomContent.substring(valueStart, close).trim();
}
/**
* Delete a directory tree recursively. Mirrors common helpers in
* the codebase but inlined to avoid coupling to a specific util.
*/
private static void deleteRecursively(java.nio.file.Path path) throws IOException {
if (!Files.exists(path)) return;
try (Stream<Path> stream = Files.walk(path)) {
stream.sorted(java.util.Comparator.reverseOrder())
.forEach(p -> {
try { Files.delete(p); }
catch (IOException ignore) { /* best effort */ }
});
}
}
/**
* Apply alignment to one pom. Returns {@code true} when the pom
* was changed + committed; {@code false} when no change was
* needed (idempotent re-run).
*
* @param pomFile the pom to align
* @param groupIdToLatest groupId → latest released version
* @return whether the pom was bumped
*/
/**
* The computed (not yet applied) alignment of one pom: the
* human-readable bump descriptions and the post-bump pom text.
* Empty {@code bumps} means the pom is already aligned.
*
* @param bumps one line per reference bump, old → new
* @param newContent the pom text with every bump applied
*/
record AlignmentEdit(List<String> bumps, String newContent) {}
/**
* Pure computation of {@link #alignPom}'s edit: scan the pom for
* stale foundation references (parent block, {@code <X.version>}
* properties) and return the would-be bumps plus the rewritten
* text. Never writes or commits — the draft path reports this
* plan verbatim so draft and publish describe the same mission
* (ike-issues#1000 finding 1).
*
* @param pomFile the pom to scan
* @param groupIdToLatest foundation groupId → latest released version
* @return the computed edit; {@code bumps} empty when already aligned
*/
private AlignmentEdit plannedAlignmentEdit(File pomFile,
Map<String, String> groupIdToLatest) {
String content;
try {
content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
} catch (IOException e) {
getLog().warn(" Could not read " + pomFile + " for alignment: "
+ e.getMessage());
return new AlignmentEdit(List.of(), null);
}
String original = content;
List<String> bumps = new ArrayList<>();
// Align <parent> block — forward only. A stale local foundation
// clone resolves an OLDER "latest" than the pom already pins;
// proposing that as an alignment would downgrade the pom
// (ike-issues#1000, mission two).
try {
PomParentSupport.ParentInfo parent =
PomParentSupport.readParent(pomFile.toPath());
if (parent != null) {
String target = groupIdToLatest.get(parent.groupId());
if (target != null
&& isForwardBump(parent.version(), target)) {
content = PomParentSupport.updateParentVersion(content,
parent.groupId(), parent.artifactId(), target);
bumps.add("<parent>" + parent.groupId() + ":"
+ parent.artifactId() + ">: "
+ parent.version() + " → " + target);
}
}
} catch (IOException e) {
getLog().warn(" Could not read parent block of " + pomFile
+ ": " + e.getMessage());
}
// Align <X.version> properties — forward only, and never an
// expression value: `<ike-platform.version>${project.parent.version}`
// delegates elsewhere by design, and replacing it with a literal
// would freeze the delegation (ike-issues#1000, mission two).
for (Map.Entry<String, String> entry : PROPERTY_TO_GROUP.entrySet()) {
String propertyName = entry.getKey();
String groupId = entry.getValue();
String target = groupIdToLatest.get(groupId);
if (target == null) continue;
String current = extractPropertyValue(content, propertyName);
if (current == null) continue;
if (current.contains("${")) continue;
if (!isForwardBump(current, target)) continue;
content = PomRewriter.updateProperty(content, propertyName, target);
bumps.add("<" + propertyName + ">: " + current + " → " + target);
}
// The bump list must describe an actual textual change, or the
// draft would promise a release the publish never makes (the
// rewriter treating a bump as a no-op must not count).
if (content.equals(original)) {
return new AlignmentEdit(List.of(), original);
}
return new AlignmentEdit(bumps, content);
}
/**
* True when {@code from} → {@code to} moves strictly forward under
* whole-number comparison. Foundation versions are single-segment
* integers; anything unparseable is incomparable and never bumped
* (versions must not be assumed semver, and a stale local
* foundation clone must never cause a downgrade proposal).
*
* @param from the version currently in the pom
* @param to the candidate target version
* @return {@code true} only when both parse and {@code to} is newer
*/
static boolean isForwardBump(String from, String to) {
try {
return Long.parseLong(to.trim()) > Long.parseLong(from.trim());
} catch (NumberFormatException e) {
return false;
}
}
private boolean alignPom(File pomFile, Map<String, String> groupIdToLatest) {
AlignmentEdit edit = plannedAlignmentEdit(pomFile, groupIdToLatest);
if (edit.bumps().isEmpty() || edit.newContent() == null) {
return false;
}
String content = edit.newContent();
List<String> bumps = edit.bumps();
try {
Files.writeString(pomFile.toPath(), content,
StandardCharsets.UTF_8);
} catch (IOException e) {
getLog().warn(" Could not write aligned " + pomFile
+ ": " + e.getMessage());
return false;
}
File pomDir = pomFile.getParentFile();
if (!new File(pomDir, ".git").isDirectory()) {
// No git repo here — leave the worktree edit for ws:commit-publish
// (or a sibling tool) to pick up later. Same fail-soft
// pattern as #371 manifest sync.
getLog().info(" Pre-release alignment: " + pomDir.getName()
+ " (no .git — bumped on disk, not committed):");
for (String b : bumps) getLog().info(" " + b);
return false;
}
try {
ReleaseSupport.exec(pomDir, getLog(), "git", "add", "pom.xml");
ReleaseSupport.exec(pomDir, getLog(), "git", "commit", "-m",
ALIGNMENT_COMMIT_MESSAGE);
} catch (Exception e) {
getLog().warn(" Pre-release alignment commit failed for "
+ pomDir.getName() + ": " + e.getMessage());
return false;
}
getLog().info(" Pre-release alignment: " + pomDir.getName());
for (String b : bumps) getLog().info(" " + b);
return true;
}
/**
* Pure-string extract of a {@code <properties>}-block value by
* name. Returns {@code null} when absent.
*/
static String extractPropertyValue(String pomContent,
String propertyName) {
if (pomContent == null) return null;
String openTag = "<" + propertyName + ">";
int open = pomContent.indexOf(openTag);
if (open < 0) return null;
int valueStart = open + openTag.length();
int close = pomContent.indexOf("</" + propertyName + ">",
valueStart);
if (close < 0) return null;
return pomContent.substring(valueStart, close).trim();
}
// ── Helper: workspace root has unreleased changes? ───────────────
// ike-issues#328: the workspace itself participates in the
// release set when source-changed. Returns true when the
// workspace has never been tagged or has commits since its last
// release tag.
/**
* Write the post-cascade {@code version:} updates into
* {@code workspace.yaml} and commit. Filters out no-op entries
* (where the manifest already matches the new SNAPSHOT) so an
* idempotent re-run writes nothing — important because
* {@code WORKING_TREE_CLEAN} on the workspace root would otherwise
* fail on a re-run that "succeeded" but left a manifest dirty
* with no actual changes.
*
* <p>Failures are logged but do not abort the cascade: the
* subproject release tags + Nexus deploys have already shipped,
* so a manifest-sync hiccup is recoverable via
* {@code ws:scaffold-publish} or the next release mission.
* ike-issues#371.
*
* @param root workspace root
* @param versionUpdates subprojectName → new SNAPSHOT (full
* post-release pom version)
*/
private void syncWorkspaceVersions(File root,
Map<String, String> versionUpdates) {
Path manifestPath = root.toPath().resolve("workspace.yaml");
if (!Files.isRegularFile(manifestPath)) {
getLog().debug(" No workspace.yaml at " + manifestPath
+ " — skipping manifest sync (#371)");
return;
}
String before;
try {
before = Files.readString(manifestPath, StandardCharsets.UTF_8);
} catch (IOException e) {
getLog().warn(" ⚠ Could not read workspace.yaml for "
+ "manifest sync (#371): " + e.getMessage());
return;
}
String after = before;
for (Map.Entry<String, String> entry : versionUpdates.entrySet()) {
after = ManifestWriter.updateSubprojectField(
after, entry.getKey(), "version", entry.getValue());
}
if (after.equals(before)) {
getLog().debug(" workspace.yaml already in sync — "
+ "no manifest write needed (#371)");
return;
}
try {
Files.writeString(manifestPath, after, StandardCharsets.UTF_8);
} catch (IOException e) {
getLog().warn(" ⚠ Could not write workspace.yaml for "
+ "manifest sync (#371): " + e.getMessage());
return;
}
getLog().info("");
getLog().info(" Synced workspace.yaml version: fields for "
+ versionUpdates.size() + " subproject(s) (#371)");
// Stage and commit on the workspace root only if it's a git
// repo. If staging or commit fails, the file write already
// happened — leave it for ws:commit-publish to pick up rather
// than wedging the cascade.
if (!new File(root, ".git").exists()) {
return;
}
try {
ReleaseSupport.exec(root, getLog(),
"git", "add", "workspace.yaml");
ReleaseSupport.exec(root, getLog(),
"git", "commit", "-m",
"post-release: sync workspace.yaml versions (#371)");
} catch (Exception e) {
getLog().warn(" ⚠ workspace.yaml updated on disk but "
+ "could not commit (#371): " + e.getMessage()
+ ". Pick it up with " + WsGoal.COMMIT_PUBLISH.qualified()
+ ".");
}
}
// ── Helper: read current POM version ─────────────────────────────
private String currentVersion(File subDir) {
try {
Path pom = subDir.toPath().resolve("pom.xml");
String content = Files.readString(pom, StandardCharsets.UTF_8);
return extractVersionFromPom(content);
} catch (Exception e) {
return "unknown";
}
}
/**
* Extract the project's own {@code <version>} value from POM XML
* content.
*
* <p>Strips any {@code <parent>...</parent>} block before scanning
* so we don't accidentally return the inherited parent's version
* for projects that declare a parent (like the workspace root pom
* inheriting {@code ike-parent}). Then takes the first remaining
* {@code <version>}, which is the project's own.
*
* @param pomContent raw POM XML as a string
* @return the version string, or {@code "unknown"} if not found
*/
public static String extractVersionFromPom(String pomContent) {
if (pomContent == null || pomContent.isBlank()) return "unknown";
// Strip <parent>...</parent> so its <version> doesn't match.
String stripped = pomContent.replaceAll(
"(?s)<parent>.*?</parent>", "");
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile(
"<version>([^<]+)</version>").matcher(stripped);
if (matcher.find()) return matcher.group(1);
return "unknown";
}
// ── Helper: catch-up alignment for a single subproject ──────────
/**
* Read POM content as UTF-8. Used inside
* {@link Map#computeIfAbsent}; wraps {@link IOException} as
* {@link UncheckedIOException} which the caller re-throws as
* {@link MojoException}.
*/
private String readPomContent(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/**
* Build the full cascade's release plan from the release order and
* the set of releasable candidates. Each subproject becomes an
* {@link ArtifactReleaseIntent} whose pre/release/post values are
* derived from the subproject's current {@code <version>} (must end
* in {@code -SNAPSHOT}).
*
* <p>The reactor scan walks each released subproject's root POM to
* collect every property declaration and reference site across the
* cascade. Out-of-cascade subprojects do not participate; their
* POMs are not scanned and their properties do not appear in the
* plan.
*
* @param releaseOrder the topologically-sorted subproject names
* @param releasable the candidates indexed by name
* @return the immutable release plan
* @throws IOException if any POM cannot be read
*/
private ReleasePlan buildReleasePlan(
List<String> releaseOrder,
Map<String, ReleaseCandidate> releasable) throws IOException {
List<ArtifactReleaseIntent> intents = new ArrayList<>();
List<SubprojectRoot> subprojectRoots = new ArrayList<>();
List<Path> reactorRoots = new ArrayList<>();
for (String name : releaseOrder) {
ReleaseCandidate rc = releasable.get(name);
Path rootPom = rc.dir.toPath().resolve("pom.xml")
.toAbsolutePath().normalize();
PomModel pom = PomModel.parse(rootPom);
String groupId = pom.groupId();
String artifactId = pom.artifactId();
String preReleaseValue = pom.version();
if (preReleaseValue == null
|| !preReleaseValue.endsWith("-SNAPSHOT")) {
throw new IOException(
"Subproject " + name + " (" + rootPom + ") version "
+ "must end in -SNAPSHOT; got "
+ preReleaseValue);
}
String releaseValue = preReleaseValue.substring(
0, preReleaseValue.length() - "-SNAPSHOT".length());
String postReleaseValue = nextSnapshotVersion(releaseValue);
intents.add(new ArtifactReleaseIntent(
new GA(groupId, artifactId),
name,
rootPom,
preReleaseValue,
releaseValue,
postReleaseValue));
subprojectRoots.add(new SubprojectRoot(name, rootPom));
reactorRoots.add(rootPom);
}
ReactorWalker.ReactorScan scan = ReactorWalker.walkAll(reactorRoots);
return ReleasePlanCompute.compute(scan, subprojectRoots, intents);
}
/**
* Derive the post-release next-snapshot from a release value by
* incrementing the trailing numeric segment. Matches the IKE
* single-segment convention: {@code 110} → {@code 111-SNAPSHOT}.
* If the release value has no trailing digits, appends
* {@code .1-SNAPSHOT}.
*
* @param releaseValue release version (must not end in -SNAPSHOT)
* @return next-snapshot version
*/
static String nextSnapshotVersion(String releaseValue) {
int i = releaseValue.length();
while (i > 0 && Character.isDigit(releaseValue.charAt(i - 1))) i--;
if (i == releaseValue.length()) {
return releaseValue + ".1-SNAPSHOT";
}
String prefix = releaseValue.substring(0, i);
long n = Long.parseLong(releaseValue.substring(i));
return prefix + (n + 1) + "-SNAPSHOT";
}
/**
* Log the release plan at INFO: one line per artifact and per
* property. This is the pre-mutation audit view; the same data is
* also persisted to {@code plan.yaml} at the workspace root for
* later inspection (#212).
*/
private void logReleasePlan(ReleasePlan plan) {
getLog().info("Release plan:");
for (ArtifactReleasePlan ap : plan.artifacts().values()) {
getLog().info(" artifact " + ap.ga() + ": "
+ ap.preReleaseValue() + " → "
+ ap.releaseValue() + " → "
+ ap.postReleaseValue()
+ " (" + ap.referenceSites().size() + " reference"
+ (ap.referenceSites().size() == 1 ? "" : "s") + ")");
}
for (PropertyReleasePlan pp : plan.properties()) {
getLog().info(" property " + pp.propertyName()
+ " in " + pp.declaringSubproject() + ": "
+ pp.preReleaseValue() + " → " + pp.releaseValue()
+ " (" + pp.referenceSites().size() + " reference"
+ (pp.referenceSites().size() == 1 ? "" : "s") + ")");
}
}
/**
* Persist the release plan to {@code plan.yaml} at the workspace
* root. Written before any mutation so the audit artifact reflects
* the plan that will drive the cascade. In draft mode, the file is
* still emitted — it's the point of draft mode.
*
* <p>Write failures are logged as warnings and do not abort the
* release; the plan.yaml is an audit artifact, not a gate.
*
* @param root the workspace root directory
* @param plan the release plan to serialize
*/
private void writeReleasePlan(File root, ReleasePlan plan) {
Path file = root.toPath().resolve("plan.yaml");
String timestamp = ISO_UTC.format(Instant.now());
String yaml = buildReleasePlanYaml(timestamp, root.toPath(), plan);
try {
Files.writeString(file, yaml, StandardCharsets.UTF_8);
getLog().info("Release plan written: " + file);
} catch (IOException e) {
getLog().warn("Could not write plan.yaml: " + e.getMessage());
}
}
/**
* Build the {@code plan.yaml} audit content from a release plan.
*
* <p>Pure function: no I/O, no git — suitable for unit testing.
* Paths are emitted relative to {@code workspaceRoot} when possible;
* absolute otherwise.
*
* @param timestamp ISO-8601 UTC timestamp
* @param workspaceRoot workspace root, used to relativize POM paths
* @param plan the release plan
* @return YAML content
*/
static String buildReleasePlanYaml(
String timestamp, Path workspaceRoot, ReleasePlan plan) {
Path rootAbs = workspaceRoot.toAbsolutePath().normalize();
StringBuilder y = new StringBuilder();
y.append("# Workspace release plan (pre-mutation audit)\n");
y.append("# Generated: ").append(timestamp).append("\n");
y.append("timestamp: ").append(timestamp).append("\n");
y.append("artifacts:\n");
if (plan.artifacts().isEmpty()) {
y.append(" []\n");
}
for (ArtifactReleasePlan ap : plan.artifacts().values()) {
y.append(" - groupId: ").append(ap.ga().groupId()).append("\n");
y.append(" artifactId: ").append(ap.ga().artifactId()).append("\n");
y.append(" producingSubproject: ")
.append(ap.producingSubproject()).append("\n");
y.append(" rootPomPath: ")
.append(relPath(rootAbs, ap.rootPomPath())).append("\n");
y.append(" preReleaseValue: ").append(ap.preReleaseValue()).append("\n");
y.append(" releaseValue: ").append(ap.releaseValue()).append("\n");
y.append(" postReleaseValue: ").append(ap.postReleaseValue()).append("\n");
appendSites(y, " ", rootAbs, ap.referenceSites());
}
y.append("properties:\n");
if (plan.properties().isEmpty()) {
y.append(" []\n");
}
for (PropertyReleasePlan pp : plan.properties()) {
y.append(" - propertyName: ").append(pp.propertyName()).append("\n");
y.append(" declaringPomPath: ")
.append(relPath(rootAbs, pp.declaringPomPath())).append("\n");
y.append(" declaringSubproject: ")
.append(pp.declaringSubproject().isEmpty()
? "\"\"" : pp.declaringSubproject()).append("\n");
y.append(" preReleaseValue: ").append(pp.preReleaseValue()).append("\n");
y.append(" releaseValue: ").append(pp.releaseValue()).append("\n");
y.append(" postReleaseValue: ").append(pp.postReleaseValue()).append("\n");
appendSites(y, " ", rootAbs, pp.referenceSites());
}
return y.toString();
}
private static void appendSites(
StringBuilder y, String indent, Path rootAbs,
List<ReferenceSite> sites) {
if (sites.isEmpty()) {
y.append(indent).append("referenceSites: []\n");
return;
}
y.append(indent).append("referenceSites:\n");
for (ReferenceSite s : sites) {
y.append(indent).append(" - pomPath: ")
.append(relPath(rootAbs, s.pomPath())).append("\n");
y.append(indent).append(" kind: ").append(s.kind()).append("\n");
y.append(indent).append(" targetGa: ").append(s.targetGa()).append("\n");
y.append(indent).append(" textAtSite: ")
.append(s.textAtSite() == null
? "null"
: "\"" + s.textAtSite().replace("\"", "\\\"") + "\"")
.append("\n");
}
}
private static String relPath(Path rootAbs, Path p) {
Path abs = p.toAbsolutePath().normalize();
if (abs.startsWith(rootAbs)) {
Path rel = rootAbs.relativize(abs);
String s = rel.toString();
return s.isEmpty() ? "." : s;
}
return abs.toString();
}
// ── Helper: write checkpoint YAML ────────────────────────────────
private void writeCheckpoint(File root, WorkspaceGraph graph, String name)
throws MojoException {
Path checkpointsDir = root.toPath().resolve("checkpoints");
try {
Files.createDirectories(checkpointsDir);
Path file = checkpointsDir.resolve("checkpoint-" + name + ".yaml");
// Gather subproject data for the pure function
String timestamp = ISO_UTC.format(Instant.now());
List<String[]> componentData = new ArrayList<>();
for (String subName : graph.topologicalSort()) {
File subDir = new File(root, subName);
if (!subDir.isDirectory()) continue;
componentData.add(new String[]{
subName, gitBranch(subDir), gitShortSha(subDir),
currentVersion(subDir),
String.valueOf(!gitStatus(subDir).isEmpty())
});
}
String yaml = buildPreReleaseCheckpointYaml(name, timestamp, componentData);
Files.writeString(file, yaml, StandardCharsets.UTF_8);
getLog().info("Checkpoint written: " + file);
// Commit it: an untracked artifact the goal itself just
// wrote would fail the very next preflight's clean-tree
// check — the goal blocking on its own output
// (ike-issues#1000, mission two).
String relative = root.toPath().relativize(file).toString();
try {
ReleaseSupport.exec(root, getLog(), "git", "add", "--", relative);
ReleaseSupport.exec(root, getLog(), "git", "commit", "-m",
"checkpoint: pre-release safety checkpoint " + name
+ "\n\nRefs: IKE-Network/ike-issues#1000",
"--", relative);
} catch (Exception e) {
getLog().warn("Could not commit checkpoint " + relative
+ ": " + e.getMessage());
}
} catch (IOException e) {
getLog().warn("Could not write checkpoint: " + e.getMessage());
}
}
/**
* Build pre-release checkpoint YAML content from pre-gathered
* subproject data.
*
* <p>This is a pure function with no git or I/O dependencies,
* suitable for direct unit testing.
*
* @param name checkpoint name
* @param timestamp ISO-8601 UTC timestamp
* @param componentData list of {@code [name, branch, sha, version, modified]}
* arrays for each present subproject
* @return YAML checkpoint content
*/
public static String buildPreReleaseCheckpointYaml(
String name, String timestamp, List<String[]> componentData) {
StringBuilder yaml = new StringBuilder();
yaml.append("# Workspace checkpoint: ").append(name).append("\n");
yaml.append("# Generated: ").append(timestamp).append("\n");
yaml.append("checkpoint: ").append(name).append("\n");
yaml.append("timestamp: ").append(timestamp).append("\n");
yaml.append("subprojects:\n");
for (String[] sub : componentData) {
yaml.append(" ").append(sub[0]).append(":\n");
yaml.append(" branch: ").append(sub[1]).append("\n");
yaml.append(" sha: ").append(sub[2]).append("\n");
yaml.append(" version: ").append(sub[3]).append("\n");
yaml.append(" modified: ").append(sub[4]).append("\n");
}
return yaml.toString();
}
// ── Bare mode: single-repo release (no workspace.yaml) ───────────
/**
* Bare mode: release the current single repo by delegating to
* {@code ike:release-draft} (preview) or {@code ike:release-publish}
* (execute). The single-repo case of the same operation — a working
* set of one — with no workspace cascade, catch-up alignment, or
* manifest sync (ike-issues#601). This is the {@code ws:} console verb
* forwarding to the per-repo release engine; in one repo,
* {@code ws:release} is exactly {@code ike:release}.
*
* @return the goal's report
* @throws MojoException if there is no Maven project here, or the
* delegated {@code ike:release-*} fails
*/
private WorkspaceReportSpec executeBareMode() throws MojoException {
// The single-repo release is a cohort of one (ike-issues#612);
// resolve it through the shared CohortResolver rather than reading
// user.dir directly.
File repo = resolveCohort().members().getFirst().directory().toFile();
if (!new File(repo, "pom.xml").exists()) {
throw new MojoException(
"ws:release: " + repo + " has no pom.xml and no workspace.yaml "
+ "was found — run it in a Maven project, or a workspace.");
}
boolean draft = !publish;
WsGoal goal = draft ? WsGoal.RELEASE_DRAFT : WsGoal.RELEASE_PUBLISH;
String delegate = draft ? "ike:release-draft" : "ike:release-publish";
getLog().info("");
getLog().info(goal.qualified());
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Repo: " + repo.getName());
getLog().info(" Mode: single repo (no workspace.yaml) → " + delegate);
getLog().info("");
String mvn = findMvn(repo);
ReleaseSupport.exec(repo, getLog(),
draft ? draftCommand(mvn) : releaseCommand(mvn));
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Repo:** `" + repo + "`");
report.paragraph("Single-repo release " + (draft ? "preview" : "executed")
+ " via `" + delegate + "` — a working set of one, no workspace "
+ "cascade.");
return new WorkspaceReportSpec(goal, report.build());
}
/**
* Build the {@code ike:release-draft} preview command for bare mode,
* threading {@code ignoreWarnings} through. Mirrors
* {@link #releaseCommand} but for the read-only draft goal — no
* {@code pushRelease}, since a preview never pushes.
*
* @param mvn the resolved Maven executable
* @return the full command, ready for {@link ReleaseSupport#exec}
*/
String[] draftCommand(String mvn) {
List<String> cmd = new ArrayList<>();
cmd.add(mvn);
cmd.add("ike:release-draft");
if (ignoreWarnings) {
cmd.add("-Dike.release.ignoreWarnings=true");
}
cmd.add("-B");
return cmd.toArray(new String[0]);
}
// ── Helper: find mvn or mvnw ─────────────────────────────────────
private String findMvn(File subDir) {
return resolveMvnCommand(subDir);
}
/**
* Build the {@code ike:release-publish} command line for a
* subproject, threading the workspace-level {@code push} and
* {@code ignoreWarnings} flags through to the subprocess. Without
* the {@code ignoreWarnings} pass-through, a workspace release
* could never clear a subproject whose history predates the
* issue-trailer convention.
*
* @param mvn the resolved Maven executable
* @param extra any goal-specific arguments to append
* @return the full command, ready for {@link ReleaseSupport#exec}
*/
String[] releaseCommand(String mvn, String... extra) {
List<String> cmd = new ArrayList<>();
cmd.add(mvn);
cmd.add("ike:release-publish");
cmd.add("-DpushRelease=" + push);
if (ignoreWarnings) {
cmd.add("-Dike.release.ignoreWarnings=true");
}
for (String arg : extra) {
cmd.add(arg);
}
cmd.add("-B");
return cmd.toArray(new String[0]);
}
/**
* Resolve the Maven executable for a subproject directory.
*
* <p>Checks for {@code mvnw} (executable) and {@code mvnw.cmd} in
* the given directory. Falls back to {@code "mvn"} from the system
* PATH if no wrapper is found.
*
* @param subDir the subproject directory to check
* @return absolute path to mvnw/mvnw.cmd, or {@code "mvn"}
*/
public static String resolveMvnCommand(File subDir) {
File mvnw = new File(subDir, "mvnw");
if (mvnw.exists() && mvnw.canExecute()) {
return mvnw.getAbsolutePath();
}
File mvnwCmd = new File(subDir, "mvnw.cmd");
if (mvnwCmd.exists()) {
return mvnwCmd.getAbsolutePath();
}
return "mvn";
}
// ── Record for candidate tracking ────────────────────────────────
private record ReleaseCandidate(
String name,
Subproject subproject,
File dir,
String lastTag,
String reason) {}
}