ReleasePrep.java
package network.ike.plugin.release.prep;
import network.ike.plugin.CascadeBump;
import network.ike.plugin.PomRewriter;
import network.ike.plugin.ReleaseNotesSupport;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.SnapshotScanner;
import network.ike.plugin.release.ReleaseContext;
import network.ike.plugin.release.coherence.ColdLocalRepo;
import network.ike.support.enums.ConstantBackedEnum;
import network.ike.support.enums.ReleasePolicy;
import network.ike.plugin.scaffold.FoundationBaker;
import network.ike.plugin.scaffold.ScaffoldManifest;
import network.ike.plugin.scaffold.ScaffoldManifestIo;
import network.ike.plugin.support.version.CandidateVersionResolver;
import network.ike.plugin.support.version.MavenVersionComparator;
import network.ike.plugin.support.version.SessionCandidateVersionResolver;
import network.ike.workspace.cascade.CascadeEdge;
import network.ike.workspace.cascade.EdgeKind;
import network.ike.workspace.cascade.ProjectCascade;
import network.ike.workspace.cascade.ProjectCascadeIo;
import org.apache.maven.api.Session;
import org.apache.maven.api.plugin.MojoException;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* The release prep phase — B1–B12 of the {@code ReleaseDraftMojo}
* block audit minus the version/branch resolution that has to run
* before {@link ReleaseContext} can be constructed.
*
* <p>Concretely:
*
* <ol>
* <li>B3 — require a clean worktree</li>
* <li>B4 — {@code gh auth status} / {@code gh repo view} preflight</li>
* <li>B5 — {@code preflightJavadoc} (warnings hard-fail in publish, log in draft)</li>
* <li>B6 — SNAPSHOT-in-properties scan</li>
* <li>B7 — bake the foundation snapshot if this release owns the scaffold manifest</li>
* <li>B8 — align upstream cascade {@code ${X.version}} pins to latest released versions</li>
* <li>B9 — resolve the reproducible-build timestamp from the current HEAD commit</li>
* <li>B11 — {@code preflightChecks} (gh CLI, milestone, release-cadence trailers, Maven wrapper)</li>
* <li>B12 — final pre-cut validation; rolled into the orchestrator's logAudit for now</li>
* </ol>
*
* <p>The B10 draft-mode short-circuit lives in the orchestrator
* ({@code ReleaseDraftMojo.runGoal()}) — it reads
* {@link PrepOutcome#draftMode()} and dispatches to the draft renderer
* instead of continuing into the publish-only phases. The draft-renderer
* helpers ({@code reportCascade}, {@code buildReleaseReport}) extract
* to their own classes in Commit 6.
*
* <p>Carved out of {@code ReleaseDraftMojo} during the Phase 4
* Commit 5 (IKE-Network/ike-issues#489).
*/
public final class ReleasePrep {
/**
* Release-cadence commit subjects — the tool-generated bookkeeping
* commits the release flow itself produces ({@code release: …},
* {@code post-release: …}, the {@code merge: release …} commit,
* {@code site: publish …}). They legitimately carry no issue
* trailer and must be exempt from the trailer-compliance check,
* or every release would fail its own preflight on the previous
* cycle's bookkeeping (IKE-Network/ike-issues#428).
*/
private static final Pattern RELEASE_CADENCE = Pattern.compile(
"^(release: .+"
+ "|post-release: .+"
+ "|merge: release .+"
+ "|site: publish .+)$");
/**
* {@link ReleasePolicy} indexed by literal rung name
* ({@code notify}, {@code verify}, {@code propose},
* {@code integrate}, {@code release}).
*/
private static final Map<String, ReleasePolicy> RELEASE_POLICY_INDEX =
ConstantBackedEnum.index(ReleasePolicy.class);
private final ReleaseContext ctx;
private final Session session;
/**
* Creates a new release prep phase bound to the given context.
*
* @param ctx the per-invocation release context (built before
* {@code mvnw} is resolved — prep does not need the wrapper)
* @param session the active Maven session, needed to construct the
* {@link SessionCandidateVersionResolver} for foundation-bake
* and upstream-property alignment
*/
public ReleasePrep(ReleaseContext ctx, Session session) {
this.ctx = ctx;
this.session = session;
}
/**
* Executes the release prep phase.
*
* @return a {@link PrepOutcome} carrying {@code projectId},
* {@code hasOrigin}, {@code releaseTimestamp}, and the
* {@code draftMode} dispatch flag
* @throws MojoException on a worktree-state, preflight, javadoc,
* SNAPSHOT, or upstream-alignment failure
*/
public PrepOutcome execute() throws MojoException {
File gitRoot = ctx.gitRoot();
File rootPom = new File(gitRoot, "pom.xml");
String projectId = ReleaseSupport.readPomArtifactId(rootPom);
boolean publish = ctx.request().publish();
boolean draft = !publish;
// B3 — validate clean worktree (cheap check)
ReleaseSupport.requireCleanWorktree(gitRoot);
// B4 + B11 — preflight: verify external connectivity before any work.
// Each check is non-destructive and idempotent; failures happen in
// seconds, not after a 10-minute build.
boolean hasOrigin = ReleaseSupport.hasRemote(gitRoot, "origin");
if (publish) {
preflightChecks(hasOrigin, projectId, ctx.request().releaseVersion());
}
// B5 — javadoc preflight (#168). Runs in both modes; publish hard-fails
// on warnings, draft logs them so the user sees what would block release.
preflightJavadoc(publish);
// B6 — SNAPSHOT-in-properties preflight (#175, #177): Maven 4's
// consumer POM flattener resolves properties and promotes
// pluginManagement into plugins when writing the released
// artifact. If a <properties> value ends in -SNAPSHOT it leaks
// into the released POM as a literal, breaking downstream
// builds. Catch before any mutation — publish hard-fails, draft warns.
List<SnapshotScanner.Violation> propViolations =
SnapshotScanner.scanSourceProperties(rootPom);
if (!propViolations.isEmpty()) {
String msg = SnapshotScanner.formatViolations(propViolations, gitRoot,
propViolations.size() + " SNAPSHOT property value(s) would"
+ " leak into released POMs:",
" These values are resolved by Maven 4's consumer POM\n"
+ " flattener and baked into released artifacts. Bump\n"
+ " each property to a released (non-SNAPSHOT) version\n"
+ " before re-running the release.");
if (publish) {
throw new MojoException(msg);
}
ctx.log().warn(msg);
}
// B7 — foundation bake (#414): when this release owns the scaffold
// manifest, refresh foundation: pins to latest released versions.
bakeFoundationSnapshot(draft);
// B8 — upstream cascade alignment (#419): bump this repo's
// ${X.version} pins to latest released upstreams so a single-repo
// release never ships on a stale foundation. The applied upgrades
// flow to the release notes so a cascade-only rebuild announces
// what it was rebuilt against rather than "no changes" (#706).
List<CascadeBump> foundationUpgrades = alignUpstreamProperties(draft);
// B9 — derive reproducible-build timestamp from current HEAD commit.
String releaseTimestamp = resolveCommitTimestamp();
return new PrepOutcome(projectId, hasOrigin, releaseTimestamp, draft,
foundationUpgrades);
}
/**
* Returns the ISO-8601 UTC timestamp of the current HEAD commit.
*
* <p>Using the commit timestamp (not wall-clock time) for
* {@code project.build.outputTimestamp} ensures that two independent
* builds from the same tag produce identical byte-for-byte output.
* Wall-clock time would differ between the developer build and the
* verification build, defeating reproducibility.
*
* <p>Falls back to the current wall-clock time if git is unavailable.
*/
private String resolveCommitTimestamp() {
File gitRoot = ctx.gitRoot();
try {
// %cI = commit timestamp in strict ISO 8601 format
String raw = ReleaseSupport.execCapture(gitRoot,
"git", "log", "-1", "--format=%cI", "HEAD");
// Normalise to the yyyy-MM-dd'T'HH:mm:ss'Z' form Maven expects
return DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)
.format(OffsetDateTime.parse(raw).toInstant());
} catch (Exception e) {
ctx.log().warn("Could not read HEAD commit timestamp; falling back to wall-clock: "
+ e.getMessage());
return DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)
.format(Instant.now());
}
}
/**
* Release-prep foundation bake (IKE-Network/ike-issues#414).
*
* <p>When the release being cut owns the scaffold manifest — i.e.
* this is the {@code ike-tooling} release — refresh the manifest's
* {@code foundation:} block to the latest released {@code ike-parent},
* {@code ike-docs}, and {@code ike-platform} versions, so the
* scaffold zip {@code ike-tooling} ships always carries a current
* compatibility snapshot with no manual edit. A no-op for every
* other project's release (no scaffold manifest present).
*
* <p>A pin newer than any resolvable GA, or one that cannot be
* resolved at all, fails a publish (warns a draft): staleness or a
* misconfigured remote must never be silently baked into the zip.
*
* @param draft {@code true} to report only; {@code false} to
* rewrite the manifest and commit it
* @throws MojoException on a backward or unresolvable pin in
* publish mode, or on an I/O failure
*/
private void bakeFoundationSnapshot(boolean draft) throws MojoException {
File gitRoot = ctx.gitRoot();
boolean publish = !draft;
File manifestFile = new File(gitRoot,
"ike-build-standards/src/main/scaffold/scaffold-manifest.yaml");
if (!manifestFile.isFile()) {
// Not the ike-tooling release — nothing to bake.
return;
}
String content;
ScaffoldManifest manifest;
try {
content = Files.readString(manifestFile.toPath(),
StandardCharsets.UTF_8);
manifest = ScaffoldManifestIo.read(manifestFile.toPath());
} catch (IOException e) {
throw new MojoException("Could not read scaffold manifest "
+ manifestFile + ": " + e.getMessage(), e);
}
if (manifest.foundation() == null) {
ctx.log().warn("Foundation bake: scaffold manifest has no "
+ "foundation: block — skipping.");
return;
}
List<FoundationBaker.Finding> findings;
try {
findings = FoundationBaker.assess(manifest.foundation(),
new SessionCandidateVersionResolver(session));
} catch (RuntimeException e) {
String msg = "Foundation bake: could not resolve latest "
+ "released versions — " + e.getMessage();
if (publish) {
throw new MojoException(msg, e);
}
ctx.log().warn(msg);
return;
}
List<FoundationBaker.Finding> problems = new ArrayList<>();
List<FoundationBaker.Finding> bumps = new ArrayList<>();
for (FoundationBaker.Finding f : findings) {
switch (f.status()) {
case AHEAD -> bumps.add(f);
case BEHIND, UNRESOLVED -> problems.add(f);
case CURRENT -> { }
}
}
if (!problems.isEmpty()) {
StringBuilder msg = new StringBuilder(
"Foundation bake found pin(s) that cannot be baked:\n");
for (FoundationBaker.Finding f : problems) {
msg.append(" ").append(f.coordinate().label()).append(": ");
if (f.status() == FoundationBaker.Status.UNRESOLVED) {
msg.append("no released version resolved (current pin ")
.append(f.current()).append(").");
} else {
msg.append("pin ").append(f.current())
.append(" is newer than the latest released ")
.append(f.latest()).append(" — a backward bake.");
}
msg.append('\n');
}
msg.append("Verify the remote repository and the manifest "
+ "foundation: block before releasing.");
if (publish) {
throw new MojoException(msg.toString());
}
ctx.log().warn(msg.toString());
}
if (bumps.isEmpty()) {
ctx.log().info("Foundation bake: scaffold foundation: block "
+ "already at the latest released versions.");
return;
}
ctx.log().info("Foundation bake:");
for (FoundationBaker.Finding f : bumps) {
ctx.log().info(" " + (draft ? "→ " : "✓ ")
+ f.coordinate().label() + ": "
+ f.current() + " -> " + f.latest());
}
if (draft) {
ctx.log().info(" [DRAFT] manifest not modified — publish would "
+ "rewrite and commit scaffold-manifest.yaml.");
return;
}
String updated = FoundationBaker.rewrite(content, findings);
try {
Files.writeString(manifestFile.toPath(), updated,
StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException("Could not write baked scaffold "
+ "manifest " + manifestFile + ": " + e.getMessage(), e);
}
ReleaseSupport.exec(gitRoot, ctx.log(), "git", "add",
"ike-build-standards/src/main/scaffold/scaffold-manifest.yaml");
ReleaseSupport.exec(gitRoot, ctx.log(), "git", "commit", "-m",
"release: bake foundation snapshot to latest GA");
}
/**
* Aligns this repository's upstream-cascade {@code ${X.version}}
* properties to the latest released version of each upstream
* (IKE-Network/ike-issues#419, #420).
*
* <p>Before a foundation repo is released it must carry current
* upstream pins, or it ships a stale foundation. This reads the
* repo's own {@code src/main/cascade/release-cascade.yaml} and, for
* every {@code upstream} edge, resolves the latest released (GA)
* version of that upstream and bumps the edge's
* {@code version-property} when the POM is behind. A property is
* only advanced, never lowered.
*
* <p>The cascade head (no upstream edges) and ordinary consumers
* (no {@code release-cascade.yaml}) are no-ops. In draft mode the
* alignment is reported but not applied; in publish mode the bumps
* are written and committed before the release branch is cut, so a
* plain single-repo {@code ike:release-publish} is correct on its own.
*
* @param draft {@code true} to report only; {@code false} to
* rewrite the POM and commit
* @return the upgrades actually applied (empty in draft mode, when
* this repo is not a cascade member, or when every pin was
* already current) — surfaced into the align commit message
* and the release notes (IKE-Network/ike-issues#706)
* @throws MojoException on an unresolvable upstream or a missing
* {@code version-property} in publish mode,
* or on an I/O failure
*/
private List<CascadeBump> alignUpstreamProperties(boolean draft) throws MojoException {
File gitRoot = ctx.gitRoot();
Optional<ProjectCascade> loaded = ProjectCascadeIo.load(
gitRoot.toPath().resolve(
ProjectCascadeIo.MANIFEST_RELATIVE_PATH));
if (loaded.isEmpty() || loaded.get().upstream().isEmpty()) {
// Not a cascade member, or the cascade head — nothing
// upstream to align.
return List.of();
}
File pomFile = new File(gitRoot, "pom.xml");
String content;
try {
content = Files.readString(pomFile.toPath(),
StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException("Could not read " + pomFile
+ " for upstream cascade alignment: "
+ e.getMessage(), e);
}
List<CascadeBump> bumps = new ArrayList<>();
List<String> problems = new ArrayList<>();
String updated = content;
// Resolve "latest released" against FRESH metadata (#705). A
// normal resolver trusts the local metadata cache (daily update
// policy); if that cache is stale — or Nexus hasn't finished
// propagating a just-deployed upstream — B8 would see the OLD
// version, leave the pin untouched, and ship an incoherent
// build (the ike-platform v110 incident, 2026-06-18). An empty
// local repo forces a real metadata fetch from the remotes.
ColdLocalRepo cold;
try {
cold = new ColdLocalRepo(session);
} catch (IOException e) {
throw new MojoException("Could not create a fresh-metadata"
+ " resolver for upstream cascade alignment: "
+ e.getMessage(), e);
}
try {
CandidateVersionResolver resolver =
new SessionCandidateVersionResolver(cold.session);
for (CascadeEdge up : loaded.get().upstream()) {
// ── Resolve policy (IKE-Network/ike-issues#498, #525) ────
// <G>__GA__<A>__POLICY (typed-marker family) declares how
// this project responds when the upstream releases. The
// pre-#525 form (<G>·<A>·policy) is also accepted during
// the foundation cascade transition. Default (no property
// declared) is INTEGRATE — bump the pin in place, no
// human gate. Policy is read and validated BEFORE the
// upstream-version resolution so the dispatch below sees
// the full gap context (current pin + latest released)
// when reporting to the operator. The policy property is
// also validated at consumer build time by
// ike-version-management-extension; ReleasePrep re-validates
// here so a release run gives a clear error even on
// consumers that don't register that extension.
String policyKey = up.policyProperty();
String policyValue = ReleaseSupport.readPomProperty(pomFile, policyKey);
if (policyValue == null) {
// Transition fallback: pre-#525 ·policy form
policyKey = up.policyPropertyLegacy();
policyValue = ReleaseSupport.readPomProperty(pomFile, policyKey);
}
if (policyValue != null) {
policyValue = policyValue.trim();
}
if (policyValue == null || policyValue.isEmpty() || policyValue.contains("${")) {
policyValue = ReleasePolicy.INTEGRATE.literalName();
}
ReleasePolicy policy = RELEASE_POLICY_INDEX.get(policyValue);
if (policy == null) {
problems.add(up.ga() + ": unrecognized policy '"
+ policyValue + "' for property " + policyKey
+ " — must be one of " + RELEASE_POLICY_INDEX.keySet() + ".");
continue;
}
// ── Read current pin value ───────────────────────────────
// PARENT-kind edges rewrite the <parent><version> block
// directly; property-kind edges rewrite the version-pin
// property that pins the upstream. The site of the value
// (the read and the write) differs by kind; the
// candidate-resolution and "is the pin stale" logic is
// the same for both.
//
// For property-kind edges we look up the typed-marker
// form (<G>__GA__<A>__VERSION, post-#525) first, then
// fall back to the legacy form (<G>·<A>) so the cascade
// works on both pre- and post-#525 POMs during the
// transition. Whichever form resolves becomes the write
// target — the form is preserved naturally.
boolean parentEdge = up.kind() == EdgeKind.PARENT;
String property = up.versionProperty();
String current = parentEdge
? PomRewriter.readParentVersion(content,
up.groupId(), up.artifactId()).orElse(null)
: ReleaseSupport.readPomProperty(pomFile, property);
if (!parentEdge && current == null) {
// Transition fallback: pre-#525 ·-form pin
property = up.versionPropertyLegacy();
current = ReleaseSupport.readPomProperty(pomFile, property);
}
String displaySite = parentEdge
? "<parent>" + up.ga() + "</parent>"
: "<" + property + ">";
if (current == null) {
problems.add(up.ga() + ": POM has no " + displaySite
+ ".");
continue;
}
if (current.contains("${")) {
// Value is itself a property reference — the canonical
// pin lives elsewhere (typically ike-base-parent). No
// local action regardless of policy.
continue;
}
// ── Resolve latest released upstream version ─────────────
String latest;
try {
List<String> candidates = resolver.resolveCandidates(
up.groupId(), up.artifactId(), null);
latest = candidates.isEmpty() ? null
: candidates.get(candidates.size() - 1);
} catch (RuntimeException e) {
problems.add(up.ga() + ": could not resolve latest"
+ " release — " + e.getMessage());
continue;
}
if (latest == null) {
problems.add(up.ga()
+ ": no released version resolved.");
continue;
}
if (MavenVersionComparator.INSTANCE
.compare(latest, current) <= 0) {
// Pin already at or ahead of latest released — no gap.
// No policy dispatch needed; nothing to act on.
continue;
}
// ── Policy dispatch with full gap context ────────────────
boolean autoAlign = switch (policy) {
case INTEGRATE -> true;
case RELEASE -> {
ctx.log().info(" " + up.ga() + " " + current + " → "
+ latest + " (policy=release; aligning + downstream"
+ " release follows via cascade).");
yield true;
}
case NOTIFY -> {
ctx.log().warn(" " + up.ga() + " has a new release: "
+ current + " → " + latest
+ " (policy=notify; pin not auto-updated).");
ctx.log().warn(" Update manually: mvn versions:set-property"
+ " -Dproperty=" + property + " -DnewVersion=" + latest);
ctx.log().warn(" Or change " + policyKey
+ " to `integrate` for automatic alignment.");
yield false;
}
case VERIFY -> {
ctx.log().warn(" " + up.ga() + " has a new release: "
+ current + " → " + latest + " (policy=verify).");
if (draft) {
ctx.log().warn(" [DRAFT] Would create a git-worktree"
+ " sandbox and run `mvn verify` with the bump;"
+ " no action in draft mode.");
} else {
String bumpedContent = parentEdge
? PomRewriter.updateParentVersion(content,
up.groupId(), up.artifactId(), latest)
: PomRewriter.updateProperty(content,
property, latest);
File sandbox = new File(
System.getProperty("java.io.tmpdir"),
"ike-verify-" + gitRoot.getName()
+ "-" + up.artifactId()
+ "-v" + latest);
boolean verified = verifyUpstreamBump(gitRoot,
sandbox, bumpedContent);
if (verified) {
ctx.log().info(" ✓ " + up.ga()
+ " bump verified — consumer still builds"
+ " with " + latest + ".");
ctx.log().info(" Pin not auto-updated (policy=verify"
+ " is hand-gated). To integrate: change "
+ policyKey + " to `integrate`, or apply"
+ " manually with mvn versions:set-property"
+ " -Dproperty=" + property + " -DnewVersion="
+ latest);
} else {
ctx.log().warn(" ✗ " + up.ga()
+ " bump FAILED verify — see sandbox log."
+ " Investigation required before integration.");
}
}
yield false;
}
case PROPOSE -> {
String proposeBranch = "propose/" + up.artifactId()
+ "-v" + latest;
ctx.log().warn(" " + up.ga() + " has a new release: "
+ current + " → " + latest
+ " (policy=propose).");
if (draft) {
ctx.log().warn(" [DRAFT] Would create branch "
+ proposeBranch + " with the bump applied;"
+ " not modifying anything in draft mode.");
} else if (branchExistsLocally(gitRoot, proposeBranch)) {
ctx.log().warn(" Branch " + proposeBranch
+ " already exists — leaving it for the"
+ " operator to integrate.");
} else {
String bumpedContent = parentEdge
? PomRewriter.updateParentVersion(content,
up.groupId(), up.artifactId(), latest)
: PomRewriter.updateProperty(content,
property, latest);
boolean hasOrigin = ReleaseSupport.hasRemote(gitRoot, "origin");
createProposeBranch(gitRoot, proposeBranch, pomFile,
bumpedContent,
"propose: bump " + up.ga() + " "
+ current + " → " + latest,
hasOrigin);
ctx.log().warn(" Created branch " + proposeBranch
+ (hasOrigin
? " and pushed to origin."
: " (local only — no origin remote)."));
if (hasOrigin) {
ctx.log().warn(" Open a PR: gh pr create"
+ " --head " + proposeBranch);
}
}
yield false;
}
};
if (!autoAlign) {
continue;
}
// ── Apply the bump ───────────────────────────────────────
String after = parentEdge
? PomRewriter.updateParentVersion(updated,
up.groupId(), up.artifactId(), latest)
: PomRewriter.updateProperty(updated, property,
latest);
if (!after.equals(updated)) {
updated = after;
bumps.add(new CascadeBump(up.groupId(), up.artifactId(),
current, latest));
}
}
} finally {
cold.close();
}
if (!problems.isEmpty()) {
StringBuilder msg = new StringBuilder("Upstream cascade"
+ " alignment found unresolvable upstream pin(s):\n");
for (String p : problems) {
msg.append(" ").append(p).append('\n');
}
msg.append("Verify the remote repository and the upstream"
+ " edges in release-cascade.yaml before releasing.");
if (!draft) {
throw new MojoException(msg.toString());
}
ctx.log().warn(msg.toString());
}
if (bumps.isEmpty()) {
ctx.log().info("Upstream cascade alignment: ${X.version}"
+ " pins already at the latest released versions.");
return List.of();
}
ctx.log().info("Upstream cascade alignment:");
for (CascadeBump b : bumps) {
ctx.log().info(" " + (draft ? "→ " : "✓ ") + b.ga()
+ ": " + b.current() + " -> " + b.latest());
}
if (draft) {
ctx.log().info(" [DRAFT] pom.xml not modified — publish"
+ " would rewrite and commit it.");
// Draft must not leak bumps as "applied": nothing was
// written or committed, so the draft report has no real
// upgrades to announce (IKE-Network/ike-issues#706).
return List.of();
}
try {
Files.writeString(pomFile.toPath(), updated,
StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException("Could not write aligned " + pomFile
+ ": " + e.getMessage(), e);
}
ReleaseSupport.exec(gitRoot, ctx.log(), "git", "add", "pom.xml");
// Descriptive commit message naming each upgrade — a cascade-only
// rebuild's commit is otherwise generic and the notes generator
// treats a generic "release:" commit as noise (#706). e.g.
// "release: align upstream cascade — ike-tooling 221→222, ike-docs 75→76"
String summary = bumps.stream()
.map(CascadeBump::compact)
.collect(Collectors.joining(", "));
ReleaseSupport.exec(gitRoot, ctx.log(), "git", "commit", "-m",
"release: align upstream cascade — " + summary);
return bumps;
}
/**
* Runs {@code mvn verify} against the consumer with a hypothetical
* upstream-pin bump applied, in a {@code git worktree} sandbox
* that doesn't disturb the release worktree.
*
* <p>The sandbox lives under {@code java.io.tmpdir} for the
* duration of the verify, then is removed via
* {@code git worktree remove --force}. The shared {@code .git/}
* directory means the sandbox carries no history-copy cost; it
* is just a parallel working tree at the same HEAD with the
* proposed POM applied.
*
* <p>The mvnw inherited into the sandbox is the same script the
* release-flow itself uses, so the verify exercises the consumer's
* exact Maven toolchain. Output streams through the shared logger
* so the operator sees verify progress in real time.
*
* <p>Best-effort cleanup: a stale sandbox from an interrupted
* prior run is removed before the new worktree is added; if the
* final remove fails, the operator can clean up with
* {@code git worktree remove --force <path>}.
*
* @param gitRoot project working tree (used for the
* {@code git worktree} subcommands)
* @param sandbox desired sandbox directory (absolute; must
* not already be a worktree of this repo)
* @param bumpedContent the POM content with the upstream pin advanced
* @return {@code true} when {@code mvn verify} exits zero,
* {@code false} otherwise
*/
private boolean verifyUpstreamBump(File gitRoot, File sandbox,
String bumpedContent) {
// Remove any stale sandbox from a prior interrupted run.
if (sandbox.exists()) {
try {
ReleaseSupport.exec(gitRoot, ctx.log(),
"git", "worktree", "remove", "--force",
sandbox.getAbsolutePath());
} catch (RuntimeException ignored) {
// Not a registered worktree — try a plain remove.
}
}
try {
ReleaseSupport.exec(gitRoot, ctx.log(),
"git", "worktree", "add", sandbox.getAbsolutePath(), "HEAD");
} catch (RuntimeException e) {
ctx.log().warn(" Could not create verify sandbox at "
+ sandbox + ": " + e.getMessage());
return false;
}
try {
Files.writeString(
new File(sandbox, "pom.xml").toPath(),
bumpedContent, StandardCharsets.UTF_8);
File sandboxMvnw = new File(sandbox, "mvnw");
// The verify goal is configurable per IKE-Network/ike-issues#510
// — operators can dial down to `test`, `package`, or
// `compile` when full `verify` is too slow for the
// upstream-bump assurance they want.
String verifyGoal = System.getProperty(
"ike.policy.verify.goal", "verify");
ctx.log().info(" Running `mvnw " + verifyGoal
+ "` in sandbox: " + sandbox);
ReleaseSupport.exec(sandbox, ctx.log(),
sandboxMvnw.getAbsolutePath(), verifyGoal, "-B");
return true;
} catch (IOException e) {
ctx.log().warn(" Could not write bumped POM to sandbox: "
+ e.getMessage());
return false;
} catch (RuntimeException e) {
// mvn verify subprocess failed — message already streamed.
return false;
} finally {
try {
ReleaseSupport.exec(gitRoot, ctx.log(),
"git", "worktree", "remove", "--force",
sandbox.getAbsolutePath());
} catch (RuntimeException e) {
ctx.log().warn(" Could not remove verify sandbox at "
+ sandbox + ": " + e.getMessage()
+ " — clean up manually with"
+ " `git worktree remove --force " + sandbox + "`.");
}
}
}
/**
* Returns {@code true} when {@code branchName} resolves to a local
* git ref. Used by the {@code propose} policy arm to skip
* recreating an existing release-gate branch.
*/
private static boolean branchExistsLocally(File gitRoot, String branchName) {
try {
ReleaseSupport.execCapture(gitRoot, "git", "rev-parse",
"--verify", "refs/heads/" + branchName);
return true;
} catch (Exception e) {
return false;
}
}
/**
* Creates a {@code propose/…} release-gate branch carrying a single
* commit that applies a proposed upstream-pin bump.
*
* <p>Sequence: cut a new branch from the current HEAD, write the
* bumped POM content, stage + commit, push to {@code origin} when
* available, then force-checkout back to the original branch.
* The final checkout runs in a {@code finally} so a push failure
* doesn't strand the worktree on the propose branch.
*
* @param gitRoot project working tree
* @param branchName the propose branch name to create
* @param pomFile the POM file to overwrite with {@code bumpedContent}
* @param bumpedContent the POM content with the upstream pin advanced
* @param commitMessage the commit message for the bump
* @param push when {@code true}, also push the new branch
* to {@code origin} with upstream tracking
*/
private void createProposeBranch(File gitRoot, String branchName,
File pomFile, String bumpedContent,
String commitMessage, boolean push) {
String currentBranch = ReleaseSupport.currentBranch(gitRoot);
try {
ReleaseSupport.exec(gitRoot, ctx.log(),
"git", "checkout", "-b", branchName);
try {
Files.writeString(pomFile.toPath(), bumpedContent,
StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException("Could not write "
+ pomFile + " on propose branch "
+ branchName + ": " + e.getMessage(), e);
}
ReleaseSupport.exec(gitRoot, ctx.log(),
"git", "add", "pom.xml");
ReleaseSupport.exec(gitRoot, ctx.log(),
"git", "commit", "-m", commitMessage);
if (push) {
ReleaseSupport.exec(gitRoot, ctx.log(),
"git", "push", "-u", "origin", branchName);
}
} finally {
// Always return to the original branch; force checkout
// overwrites any stray uncommitted worktree state from a
// failure mid-sequence.
ReleaseSupport.exec(gitRoot, ctx.log(),
"git", "checkout", "-f", currentBranch);
}
}
/**
* Verifies all external dependencies before starting the release.
*
* <p>Each check is non-destructive and fast — failures here happen
* in seconds instead of after a 10-minute build cycle. Every check
* runs to completion and records into one of two buckets rather
* than failing fast, so a single run logs the complete picture of
* everything wrong (IKE-Network/ike-issues#428):
* <ul>
* <li><b>errors</b> — git-push authentication, {@code gh} push
* permission on {@code issueRepo}, a missing Maven wrapper.
* Always abort the release; never ignorable.</li>
* <li><b>warnings</b> — {@code gh} CLI unavailable, a missing
* {@code pending-release} label or release milestone,
* commits with no issue trailer. Abort the release too,
* unless {@code -Dike.release.ignoreWarnings=true}.</li>
* </ul>
*
* <p>Only invoked for a publish; draft mode skips this step.
*
* @param hasOrigin whether an {@code origin} remote is configured
* @param projectId the project artifactId, for the milestone name
* @param releaseVersion the version being released
* @throws MojoException if any preflight error is found, or any
* warning is found and {@code ignoreWarnings}
* is not set
*/
private void preflightChecks(boolean hasOrigin, String projectId, String releaseVersion)
throws MojoException {
File gitRoot = ctx.gitRoot();
String issueRepo = ctx.request().issueRepo();
boolean ignoreWarnings = ctx.request().ignoreWarnings();
ctx.log().info("");
ctx.log().info("PREFLIGHT CHECKS");
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
// 1. Git push auth — draft push (sends nothing, tests auth)
if (hasOrigin) {
try {
ReleaseSupport.execCapture(gitRoot,
"git", "push", "--dry-run", "origin", "main");
ctx.log().info(" Git push: authenticated ✓");
} catch (Exception e) {
errors.add("Cannot push to origin — fix authentication"
+ " before releasing. Error: " + e.getMessage());
ctx.log().error(" Git push: authentication failed ✗");
}
} else {
ctx.log().info(" Git push: no origin remote (local-only release)");
}
// 2. gh CLI — installed and authenticated?
boolean ghAvailable = false;
if (hasOrigin) {
try {
ReleaseSupport.execCapture(gitRoot, "gh", "auth", "status");
ctx.log().info(" gh CLI: authenticated ✓");
ghAvailable = true;
} catch (Exception e) {
warnings.add("gh CLI not available or not authenticated — "
+ "GitHub Release will be skipped. "
+ "Run: gh auth login");
ctx.log().warn(" gh CLI: not available (GitHub Release "
+ "will be skipped)");
}
}
// 3. gh write permission on issueRepo (#392) — an error.
if (ghAvailable && issueRepo != null && !issueRepo.isBlank()) {
try {
String pushPerm = ReleaseSupport.execCapture(gitRoot,
"gh", "api", "/repos/" + issueRepo,
"--jq", ".permissions.push");
if ("true".equals(pushPerm.trim())) {
ctx.log().info(" gh perms: push on "
+ issueRepo + " ✓");
} else {
errors.add("gh token lacks push permission on "
+ issueRepo + " — required for milestone"
+ " close and pending-release label removal."
+ " Re-authenticate with repo scope:"
+ " gh auth refresh -s repo");
ctx.log().error(" gh perms: no push on "
+ issueRepo + " ✗");
}
} catch (Exception e) {
warnings.add("Could not verify gh permissions on "
+ issueRepo + ": " + e.getMessage());
}
}
// 4. pending-release label exists on issueRepo (#392) — warn.
if (ghAvailable && issueRepo != null && !issueRepo.isBlank()) {
try {
ReleaseSupport.execCapture(gitRoot, "gh", "api",
"/repos/" + issueRepo + "/labels/pending-release");
ctx.log().info(" pending-rel label on " + issueRepo + " ✓");
} catch (Exception e) {
warnings.add("Label 'pending-release' missing on "
+ issueRepo + " — label removal will be a no-op. "
+ "Create it: gh label create pending-release "
+ "--repo " + issueRepo
+ " --description \"Code complete; awaiting next release\"");
ctx.log().warn(" pending-rel label: missing on " + issueRepo);
}
}
// 5. Trailer compliance for commits in release range (#392) — warn.
if (hasOrigin) {
List<String> nonCompliant = findCommitsWithoutIssueTrailer();
if (nonCompliant.isEmpty()) {
ctx.log().info(" Trailer compliance: all commits ✓");
} else {
StringBuilder msg = new StringBuilder(nonCompliant.size()
+ " commit(s) in release range have no issue trailer "
+ "(IKE-COMMITS.md):");
for (String line : nonCompliant) {
msg.append("\n ").append(line);
}
msg.append("\n Add Fixes/Refs <owner>/<repo>#N to comply.");
warnings.add(msg.toString());
ctx.log().warn(" Trailer compliance: " + nonCompliant.size()
+ " commit(s) without issue trailer");
}
}
// 6. Milestone for releaseVersion exists on issueRepo (#392) — warn.
if (ghAvailable && issueRepo != null && !issueRepo.isBlank()
&& releaseVersion != null && !releaseVersion.isBlank()) {
String milestoneName = projectId + " v" + releaseVersion;
try {
String titles = ReleaseSupport.execCapture(gitRoot, "gh", "api",
"/repos/" + issueRepo + "/milestones?state=open&per_page=100",
"--jq", ".[].title");
boolean found = false;
for (String title : titles.split("\n")) {
if (milestoneName.equals(title.trim())) {
found = true;
break;
}
}
if (found) {
ctx.log().info(" Milestone: " + milestoneName + " ✓");
} else {
warnings.add("Milestone \"" + milestoneName
+ "\" not found on " + issueRepo
+ " — release will use auto-generated notes. "
+ "Create it: gh api /repos/" + issueRepo
+ "/milestones -f title='" + milestoneName + "'");
ctx.log().warn(" Milestone: " + milestoneName
+ " missing (auto-notes fallback)");
}
} catch (Exception e) {
warnings.add("Could not check milestone existence: "
+ e.getMessage());
}
}
// 7. Maven wrapper
try {
ReleaseSupport.resolveMavenWrapper(gitRoot, ctx.log());
ctx.log().info(" Maven: wrapper found ✓");
} catch (Exception e) {
errors.add("Maven wrapper (mvnw) not found."
+ " Run: mvn wrapper:wrapper");
ctx.log().error(" Maven: wrapper not found ✗");
}
// 8. Site lint — catch drift in <url>/site.xml shapes before
// they ship as broken decoration links. Surfaced from the
// bannerRight-collapse incident (IKE-Network/ike-issues#521).
// Each finding is a warning (ignoreable via -Dike.release.ignoreWarnings=true)
// so a release can ship through known drift while we fix
// upstream.
List<String> siteFindings = siteLintFindings(gitRoot, projectId);
if (siteFindings.isEmpty()) {
ctx.log().info(" Site lint: no issues ✓");
} else {
for (String f : siteFindings) {
warnings.add("Site lint: " + f);
}
ctx.log().warn(" Site lint: " + siteFindings.size()
+ " issue(s)");
}
// Report the complete preflight picture, then decide (#428).
if (!errors.isEmpty() || !warnings.isEmpty()) {
ctx.log().info("");
for (String err : errors) {
ctx.log().error(" ✗ " + err);
}
for (String w : warnings) {
ctx.log().warn(" ⚠ " + w);
}
ctx.log().info("");
}
if (!errors.isEmpty()) {
throw new MojoException("Release preflight found "
+ errors.size() + " error(s)"
+ (warnings.isEmpty() ? ""
: " and " + warnings.size() + " warning(s)")
+ " — see above. Errors must be resolved before"
+ " releasing; they are never ignorable.");
}
if (!warnings.isEmpty()) {
if (ignoreWarnings) {
ctx.log().warn(" Proceeding past " + warnings.size()
+ " warning(s) (ike.release.ignoreWarnings=true).");
} else {
throw new MojoException("Release preflight found "
+ warnings.size() + " warning(s) — see above."
+ " Resolve them, or pass"
+ " -Dike.release.ignoreWarnings=true to release"
+ " anyway.");
}
}
ctx.log().info("");
}
/**
* Finds commits in {@code <previous-tag>..HEAD} whose body contains
* no IKE-COMMITS.md issue trailer ({@code Fixes}, {@code Closes},
* {@code Resolves}, {@code Refs} and grammatical variants).
*
* <p>Uses NUL-delimited git-log output to handle commit messages
* containing arbitrary characters. Returns short SHA + subject for
* each non-compliant commit. Release-cadence commits ({@link
* #RELEASE_CADENCE}) are exempt — they are tool-generated and
* carry no issue trailer by design.
*
* <p>Returns an empty list (not an error) if the previous tag
* cannot be resolved — typical for first-release scenarios.
*/
private List<String> findCommitsWithoutIssueTrailer() {
File gitRoot = ctx.gitRoot();
try {
String previousTag;
try {
previousTag = ReleaseSupport.execCapture(gitRoot,
"git", "describe", "--tags", "--abbrev=0", "HEAD");
} catch (Exception e) {
ctx.log().debug(" No previous tag — skipping trailer compliance");
return List.of();
}
// Per-commit body separated by NUL byte (-z) so embedded
// newlines don't confuse the parser.
String log = ReleaseSupport.execCapture(gitRoot, "git", "log",
"-z", "--format=%h%x00%B", previousTag + "..HEAD");
if (log.isBlank()) {
return List.of();
}
List<String> nonCompliant = new ArrayList<>();
// Stream is "<sha>\0<body>\0<sha>\0<body>\0..." after -z.
// Splitting on NUL gives alternating sha/body pairs.
String[] records = log.split("\u0000");
for (int i = 0; i + 1 < records.length; i += 2) {
String sha = records[i].trim();
String body = records[i + 1];
if (!ReleaseNotesSupport.hasAnyIssueTrailer(body)) {
String firstLine = body.contains("\n")
? body.substring(0, body.indexOf('\n'))
: body;
String subject = firstLine.trim();
if (RELEASE_CADENCE.matcher(subject).matches()) {
// Tool-generated bookkeeping — no trailer by design.
continue;
}
nonCompliant.add(sha + " " + subject);
}
}
return nonCompliant;
} catch (Exception e) {
ctx.log().debug(" Trailer compliance check failed: "
+ e.getMessage());
return List.of();
}
}
/**
* Checks that javadoc generation — as the release profile runs it —
* produces no warnings across every reactor module.
*
* <p>On {@code publish} mode any warning aborts the release; on
* draft mode warnings are logged so the user sees what would block
* the real release. Skipped when no {@code src/main/java} tree
* exists anywhere in the reactor (doc-only / POM-only repos have
* nothing to check).
*
* <p>Matches the release path by invoking {@code mvn compile
* javadoc:jar} across the reactor — the same goal the {@code
* release} profile uses. {@code -DfailOnError=false
* -DfailOnWarnings=false} prevent the child build from exiting
* early so every module's warnings are collected in a single pass.
*
* @param publish {@code true} for publish mode (hard fail),
* {@code false} for draft mode (warn only)
* @throws MojoException if publish mode and warnings are present
*/
// ── Site lint (IKE-Network/ike-issues#521) ───────────────────────
/**
* Project {@code <url>} element pattern. Matches the {@code <url>}
* that sits between {@code </description>} and {@code <inceptionYear>}
* — the canonical position for a Maven {@code <url>} top-level
* element. {@code <scm><url>} and {@code <license><url>} are not
* picked up because they live elsewhere in the tree.
*/
private static final Pattern PROJECT_URL_ELEMENT = Pattern.compile(
"</description>\\s*<url>([^<]+)</url>",
Pattern.DOTALL);
/**
* Matches a {@code <bannerRight>} declaration with a GitHub
* {@code href}. Indicates the site's primary GitHub link lives in
* header chrome — the canonical placement.
*/
private static final Pattern BANNER_RIGHT_GITHUB = Pattern.compile(
"<bannerRight[^>]*href=\"https://github\\.com/",
Pattern.DOTALL);
/**
* Matches a {@code <links><item name="GitHub" ...></links>} block —
* the redundant utility-bar link to GitHub that duplicates
* {@code <bannerRight>} on per-project sites.
*/
private static final Pattern LINKS_GITHUB_ITEM = Pattern.compile(
"<links>\\s*<item\\s+name=\"GitHub\"",
Pattern.DOTALL);
/**
* Inspect {@code pom.xml} and {@code src/site/site.xml} for known
* drift patterns that produce broken site decoration links.
*
* <p>Rules:
* <ol>
* <li>Project {@code <url>} must match
* {@code https://ike.network/<projectId>/}. If it points at
* GitHub instead (a common drift), {@code maven-site-plugin}
* relativizes {@code <bannerRight>}'s GitHub href to {@code ./} —
* a self-loop that breaks the link.</li>
* <li>{@code site.xml} must not have a GitHub link in BOTH
* {@code <bannerRight>} and a top-bar {@code <links>} item.
* Pick one (bannerRight is canonical) — the duplication is
* chrome noise.</li>
* </ol>
*
* <p>No-op when {@code pom.xml} or {@code site.xml} is missing.
* Findings are returned as warnings; the caller composes the
* report block.
*
* @param gitRoot the project's git root
* @param projectId the project's artifact ID (drives the expected
* {@code <url>} value)
* @return zero or more human-readable findings; empty if everything
* passes
*/
static List<String> siteLintFindings(File gitRoot, String projectId) {
List<String> findings = new ArrayList<>();
File pomFile = new File(gitRoot, "pom.xml");
if (pomFile.isFile() && projectId != null && !projectId.isBlank()) {
try {
String pom = Files.readString(pomFile.toPath());
Matcher m = PROJECT_URL_ELEMENT.matcher(pom);
if (m.find()) {
String actual = m.group(1).trim();
String expected = "https://ike.network/" + projectId + "/";
if (!expected.equals(actual)) {
findings.add("pom.xml <url> is '" + actual
+ "', expected '" + expected
+ "' — site-plugin will relativize"
+ " bannerRight to './' when the two"
+ " URLs share a prefix.");
}
}
} catch (IOException e) {
// tolerate read failure — release-publish has bigger
// problems if pom.xml is unreadable, and that surfaces
// in other checks.
}
}
File siteXml = new File(gitRoot, "src/site/site.xml");
if (siteXml.isFile()) {
try {
String descriptor = Files.readString(siteXml.toPath());
boolean bannerRightGitHub = BANNER_RIGHT_GITHUB
.matcher(descriptor).find();
boolean linksGitHub = LINKS_GITHUB_ITEM
.matcher(descriptor).find();
if (bannerRightGitHub && linksGitHub) {
findings.add("src/site/site.xml has GitHub in both"
+ " <bannerRight> and <links> — drop the"
+ " <links> item; bannerRight is the"
+ " canonical placement.");
}
} catch (IOException e) {
// tolerate read failure
}
}
return findings;
}
private void preflightJavadoc(boolean publish) throws MojoException {
File gitRoot = ctx.gitRoot();
if (!hasAnyJavaSource(gitRoot)) {
return;
}
List<String> warnings = collectJavadocWarnings(gitRoot);
ctx.log().info("");
if (warnings.isEmpty()) {
ctx.log().info(" Javadoc: warning-free ✓");
return;
}
ctx.log().info(" Javadoc: " + warnings.size()
+ " warning(s) ✗");
for (String w : warnings) {
ctx.log().warn(" " + w);
}
if (publish) {
throw new MojoException(
"Javadoc preflight failed: " + warnings.size()
+ " warning(s) must be resolved before publish.\n"
+ " Convention: every public method needs"
+ " complete @param / @return / @throws tags.");
}
ctx.log().warn(" (Draft mode — would block publish.)");
ctx.log().info("");
}
/**
* Returns {@code true} if {@code gitRoot} or any direct subdirectory
* contains a {@code src/main/java} tree. Covers both single-module
* and flat multi-module reactor layouts.
*/
private static boolean hasAnyJavaSource(File gitRoot) {
if (new File(gitRoot, "src/main/java").isDirectory()) {
return true;
}
File[] entries = gitRoot.listFiles();
if (entries == null) {
return false;
}
for (File entry : entries) {
if (!entry.isDirectory()) {
continue;
}
if (new File(entry, "src/main/java").isDirectory()) {
return true;
}
}
return false;
}
/**
* Runs {@code mvn compile javadoc:jar} at {@code gitRoot} to mirror
* the release's javadoc path across every reactor module, and
* returns every line matching {@code warning:} stripped of the
* leading {@code [WARNING] } prefix.
*
* <p>Tolerates subprocess failure so the release does not abort on
* an infrastructure issue (a real javadoc failure will resurface
* during the subsequent build phase).
*/
private List<String> collectJavadocWarnings(File gitRoot) {
List<String> warnings = new ArrayList<>();
try {
// -q stripped the [WARNING] prefix the grep below keys on,
// letting javadoc "reference not found" warnings slip through
// preflight (see ike-issues #178). -B keeps output non-interactive.
Process proc = new ProcessBuilder(
"mvn", "-B",
"compile", "javadoc:jar",
"-DskipTests",
"-DfailOnError=false",
"-DfailOnWarnings=false")
.directory(gitRoot)
.redirectErrorStream(true)
.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(proc.getInputStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (!line.contains("warning:")) {
continue;
}
warnings.add(line.replaceFirst(
"^\\[WARNING\\] ", "").strip());
}
}
proc.waitFor();
} catch (IOException | InterruptedException e) {
ctx.log().debug("Javadoc preflight subprocess failed: "
+ e.getMessage());
}
return warnings;
}
}