ReleaseDraftMojo.java
package network.ike.plugin;
import network.ike.plugin.release.ReleaseContext;
import network.ike.plugin.release.ReleaseRequest;
import network.ike.plugin.release.RetrySchedule;
import network.ike.plugin.release.WorktreeGuard;
import network.ike.plugin.release.central.CentralOutcome;
import network.ike.plugin.release.central.CentralPhase;
import network.ike.plugin.release.coherence.CoherenceVerifier;
import network.ike.plugin.release.coherence.ResolutionScope;
import network.ike.plugin.release.finalize.FinalizeInput;
import network.ike.plugin.release.finalize.FinalizePhase;
import network.ike.plugin.release.local.LocalInput;
import network.ike.plugin.release.local.LocalPhase;
import network.ike.plugin.release.nexus.NexusOutcome;
import network.ike.plugin.release.nexus.NexusPhase;
import network.ike.plugin.release.prep.PrepOutcome;
import network.ike.plugin.release.prep.ReleasePrep;
import network.ike.plugin.release.report.DraftRenderer;
import network.ike.plugin.release.report.ReleaseReport;
import network.ike.plugin.support.AbstractGoalMojo;
import network.ike.plugin.support.GoalReportSpec;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* Full release: build, deploy, tag, merge, and bump to next SNAPSHOT.
*
* <p>This goal automates the complete release workflow in one command.
* All local git work completes before any external action, so a
* deploy failure leaves the local repository in a consistent state
* and the deploy can be retried manually.
*
* <p><strong>Local phase (idempotent):</strong></p>
* <ol>
* <li>Validate prerequisites (branch, clean worktree)</li>
* <li>Create {@code release/<version>} branch</li>
* <li>Set POM version to release version</li>
* <li>Build and verify</li>
* <li>Build site (pre-flight — catches javadoc errors early)</li>
* <li>Commit, tag</li>
* <li>Restore {@code ${project.version}}, merge to main</li>
* <li>Bump to next SNAPSHOT version, verify, install, commit</li>
* </ol>
*
* <p>The post-bump build runs {@code install} (not just
* {@code verify}) so the new {@code -SNAPSHOT} artifacts land in
* the local repository. For a self-hosting repo whose POM pins
* {@code ike-maven-plugin} to {@code ${project.version}}, this
* means the next {@code ike:*} invocation — including an
* {@code ike:release-cascade} walk to the next member — resolves
* the plugin without a manual {@code install}.
* IKE-Network/ike-issues#486.
*
* <p><strong>External phase (most reversible first, irreversible last):</strong></p>
* <ol>
* <li>Deploy site from tagged commit (overwritable — safe to retry)</li>
* <li>Deploy artifacts from tagged commit — to Maven Central via
* JReleaser when {@code ike.publishToCentral} is set, else to
* the internal Nexus (irreversible — last)</li>
* <li>Push tag and main to origin</li>
* <li>Create GitHub Release</li>
* </ol>
*
* <p>By default this goal runs as a <strong>draft preview</strong>.
* Use {@code ike:release-publish} to execute the release, or pass
* {@code -Dpublish=true} explicitly.
*
* <p>Usage: {@code mvn ike:release} (preview),
* {@code mvn ike:release-publish} (execute),
* or override version with {@code mvn ike:release-publish -DreleaseVersion=2}
*
*/
@Mojo(name = IkeGoal.NAME_RELEASE_DRAFT, projectRequired = false, aggregator = true)
public class ReleaseDraftMojo extends AbstractGoalMojo {
@Parameter(property = "releaseVersion")
String releaseVersion;
@Parameter(property = "nextVersion")
String nextVersion;
@Parameter(property = "publish", defaultValue = "false")
boolean publish;
@Parameter(property = "skipVerify", defaultValue = "false")
boolean skipVerify;
@Parameter(property = "allowBranch")
String allowBranch;
/**
* Publish the site to GitHub Pages after internal site deploy.
* Uses {@code ike:publish-site} to force-push an orphan commit
* to the {@code gh-pages} branch.
*/
@Parameter(property = "publishSite", defaultValue = "true")
boolean publishSite;
/**
* Run {@code site} and {@code site:stage} non-recursively
* ({@code -N}). Set this to {@code true} when releasing a multi-
* module aggregator (workspace root) whose subprojects inherit a
* per-artifactId {@code <site>} URL: with the full reactor active,
* sibling modules' site:stage runs all target the same staging
* root and the last-built module overwrites the workspace's own
* staged site, so {@code publishProjectSiteToGhPages} ships a
* subproject's content as the workspace's gh-pages root.
* ike-issues#356.
*
* <p>Default {@code false}: standalone subproject releases need
* the full reactor for their own site (single-module reactors
* don't collide).
*/
@Parameter(property = "nonRecursiveSite", defaultValue = "false")
boolean nonRecursiveSite;
/**
* Skip the org-site registration step that runs at the end of a
* successful release-publish. By default each release invokes
* {@code ike:site-publish -DupdateSite=false} (#398) so the IKE
* Network landing page at https://ike.network/ picks up the
* just-released version automatically. Pass
* {@code -Dike.skip.orgSite=true} to skip when batching releases
* or working offline.
*
* <p>Best-effort: failure of the org-site step warns but does
* not fail the release. ike-issues#367.
*/
@Parameter(property = "ike.skip.orgSite", defaultValue = "false")
boolean skipOrgSite;
/**
* Publish the release to Maven Central via JReleaser instead of
* deploying to the internal Nexus. Opt-in: a repository sets
* {@code <ike.publishToCentral>true</ike.publishToCentral>} in
* its POM properties. When {@code false} (default), releases
* deploy to Nexus as before.
*
* <p>When enabled, the release does a signed staging deploy to a
* local directory, prunes the Maven 4 {@code -build.pom}
* artifacts (not published to Central), then uploads via
* {@code jreleaser:deploy}. IKE-Network/ike-issues#445.
*/
@Parameter(property = "ike.publishToCentral", defaultValue = "false")
boolean publishToCentral;
/**
* Maximum attempts for the Nexus deploy phase. Default 3.
* The Nexus phase is mandatory: failure after all attempts
* aborts the release before any tag/main push.
* IKE-Network/ike-issues#482.
*/
@Parameter(property = "ike.deploy.nexus.maxAttempts",
defaultValue = "3")
int nexusDeployMaxAttempts;
/**
* Inter-attempt backoff for the Nexus deploy phase, as a
* comma-separated list of seconds. Default {@code 30,120,300}
* (30 s / 2 m / 5 m). Index {@code i} is the wait between
* attempts {@code i+1} and {@code i+2}; if shorter than
* {@code maxAttempts - 1}, the last entry is reused.
*/
@Parameter(property = "ike.deploy.nexus.backoffSeconds",
defaultValue = "30,120,300")
String nexusDeployBackoffSeconds;
/**
* Skip the Nexus deploy phase entirely. A release without
* a Nexus deploy is incomplete by design — use only for
* controlled debugging.
*/
@Parameter(property = "ike.skipNexusDeploy", defaultValue = "false")
boolean skipNexusDeploy;
/**
* Maximum attempts for the Maven Central deploy phase.
* Default 5. The Central phase is best-effort: failure after
* all attempts does <em>not</em> abort the release. Nexus
* already has the artifact (phase 1), so the team is
* unblocked; the post-release report flags the Central gap
* for human follow-up.
*/
@Parameter(property = "ike.deploy.central.maxAttempts",
defaultValue = "5")
int centralDeployMaxAttempts;
/**
* Inter-attempt backoff for the Maven Central deploy phase,
* as a comma-separated list of seconds. Default
* {@code 60,300,900,1800,3600} (1 m / 5 m / 15 m / 30 m /
* 60 m). Longer than the Nexus backoff to ride through
* Sonatype's validation-queue throttling under load. Same
* shape rules as {@link #nexusDeployBackoffSeconds}.
*/
@Parameter(property = "ike.deploy.central.backoffSeconds",
defaultValue = "60,300,900,1800,3600")
String centralDeployBackoffSeconds;
/**
* Skip the Maven Central deploy phase. Defaults to false;
* the step is also skipped automatically (with a warning,
* not a hard fail) when {@code
* JRELEASER_DEPLOY_MAVEN_MAVENCENTRAL_USERNAME} or
* {@code _PASSWORD} are absent from the environment.
*/
@Parameter(property = "ike.skipCentralDeploy", defaultValue = "false")
boolean skipCentralDeploy;
/**
* Run the Maven Central deploy phase asynchronously
* (IKE-Network/ike-issues#484). Defaults to true; opt out
* with {@code -Dike.deploy.central.async=false} for a
* release where the operator wants to block on Central
* completion.
*
* <p>When true, after Nexus phase 1 succeeds the release
* goal writes a {@code PENDING} sentinel under
* {@code ~/.cache/ike-release/}, spawns a detached
* subprocess that runs the JReleaser upload with the same
* retry budget as the sync path, and returns control
* immediately. The subprocess rewrites the sentinel on
* completion. Track outcomes with {@code ike:central-status}.
*
* <p>Primary motivation: a foundation cascade
* ({@code ike-tooling → ike-docs → ike-platform → ...})
* no longer waits for Sonatype validation between members,
* since inter-cascade dependencies resolve through Nexus.
*/
@Parameter(property = "ike.deploy.central.async",
defaultValue = "true")
boolean centralDeployAsync;
/**
* Override the sentinel directory for async Central
* deploys. Defaults to {@link CentralDeploySentinel#DEFAULT_DIR}.
* Mainly for tests; production releases should use the
* default so {@code ike:central-status} finds them.
*/
@Parameter(property = "ike.central.sentinelDir")
String centralSentinelDir;
// ── Deploy-phase outcome tracking (#482) ─────────────────────
// Written by deployArtifacts(), read by the post-release log
// and buildReleaseReport(). Defaults represent the "did not
// run" state — appropriate for a draft preview or a release
// aborted before deploy. Outcome records were extracted to
// their future packages during the Phase 4 P1 prep commit
// (IKE-Network/ike-issues#489).
private NexusOutcome nexusOutcome = NexusOutcome.initial();
private CentralOutcome centralOutcome = CentralOutcome.initial();
/**
* GitHub repository for issue tracking, used to look up a milestone
* named {@code <artifactId> v<version>} for release notes generation.
* If the milestone exists, its closed issues are formatted as the
* GitHub Release body. Falls back to {@code --generate-notes} if
* no milestone is found.
*/
@Parameter(property = "issueRepo", defaultValue = "IKE-Network/ike-issues")
String issueRepo;
/**
* Proceed past preflight <em>warnings</em> (IKE-Network/ike-issues#428).
*
* <p>By default {@code ike:release-publish} fails when the
* preflight reports any warning — a missing release milestone,
* commits with no issue trailer, a git-push auth hiccup. Set this
* to {@code true} to release anyway. Preflight <em>errors</em>
* (a {@code -SNAPSHOT} surviving in a POM, a missing Maven
* wrapper, an unclean working tree) are never ignorable and abort
* the release regardless of this flag.
*/
@Parameter(property = "ike.release.ignoreWarnings", defaultValue = "false")
boolean ignoreWarnings;
/**
* The repository scope at which a publish must confirm its own
* just-released artifact resolves before declaring success
* (IKE-Network/ike-issues#705).
*
* <p>One of {@code local}, {@code nexus} (default), or
* {@code central}. A {@code -publish} demands {@code ≥ nexus} — the
* shared, consumer-resolvable source of truth — so {@code local}
* (which verifies nothing, the build's own {@code install} having
* trivially populated the local cache) is rejected for publish.
* {@code central} is the opt-in public-availability gate. See
* {@link network.ike.plugin.release.coherence.ResolutionScope}.
*/
@Parameter(property = "ike.resolutionScope",
defaultValue = ResolutionScope.NAME_NEXUS)
String resolutionScope;
/** Override working directory for tests. If null, uses current directory. */
File baseDir;
/** Creates this goal instance. */
public ReleaseDraftMojo() {}
@Override
protected GoalReportSpec runGoal() throws MojoException {
File startDir = baseDir != null ? baseDir : new File(".");
File gitRoot = ReleaseSupport.gitRoot(startDir);
File rootPom = new File(gitRoot, "pom.xml");
// Default releaseVersion from current POM version
String oldVersion = ReleaseSupport.readPomVersion(rootPom);
if (releaseVersion == null || releaseVersion.isBlank()) {
releaseVersion = ReleaseSupport.deriveReleaseVersion(oldVersion);
getLog().info("No -DreleaseVersion specified; defaulting to: " + releaseVersion);
}
// Default nextVersion
if (nextVersion == null || nextVersion.isBlank()) {
nextVersion = ReleaseSupport.deriveNextSnapshot(releaseVersion);
}
// Reject SNAPSHOT release versions
if (releaseVersion.contains("-SNAPSHOT")) {
throw new MojoException(
"Release version must not contain -SNAPSHOT.");
}
// Enforce SNAPSHOT suffix on next version
if (!nextVersion.endsWith("-SNAPSHOT")) {
throw new MojoException(
"Next version must end with -SNAPSHOT (got '" + nextVersion + "').");
}
// Resolution scope (#705): parse + validate up front so a bad
// value or a publish-at-local fails before any work.
ResolutionScope scope = parseResolutionScope(resolutionScope);
if (publish && !scope.satisfiesPublishMinimum()) {
throw new MojoException(
"ike.resolutionScope=" + scope.literalName()
+ " is rejected for a publish: it verifies nothing (the build's"
+ " own install populates the local cache). A publish must"
+ " confirm its artifact in a shared, consumer-resolvable"
+ " repository — use '" + ResolutionScope.NAME_NEXUS
+ "' (default) or '" + ResolutionScope.NAME_CENTRAL + "' (#705).");
}
// Validate branch and detect resume scenario (#111)
String currentBranch = ReleaseSupport.currentBranch(gitRoot);
String releaseBranch = "release/" + releaseVersion;
boolean resuming = false;
if (currentBranch.equals(releaseBranch)) {
// Already on the release branch — resume from a failed attempt
getLog().info("Resuming release from existing " + releaseBranch + " branch");
resuming = true;
} else {
String expectedBranch = allowBranch != null ? allowBranch : "main";
if (!currentBranch.equals(expectedBranch)) {
throw new MojoException(
"Must be on '" + expectedBranch + "' branch (currently on '" +
currentBranch + "'). Use -DallowBranch=" +
currentBranch + " to override.");
}
// Check release branch doesn't already exist
boolean releaseBranchExists = false;
try {
ReleaseSupport.execCapture(gitRoot,
"git", "rev-parse", "--verify", releaseBranch);
releaseBranchExists = true;
} catch (Exception e) {
// Expected — branch does not exist
}
if (releaseBranchExists) {
throw new MojoException(
"Branch '" + releaseBranch + "' already exists locally. "
+ "Switch to it to resume, or delete it to start fresh:\n"
+ " Resume: git checkout " + releaseBranch
+ " && mvn " + IkeGoal.RELEASE_PUBLISH.qualified()
+ "\n"
+ " Fresh: git branch -D " + releaseBranch
+ " && mvn " + IkeGoal.RELEASE_PUBLISH.qualified());
}
}
// ── Build request + early (mvnw-less) context for ReleasePrep ──
ReleaseRequest request = new ReleaseRequest(
releaseVersion, nextVersion, publish, skipVerify, allowBranch,
publishSite, nonRecursiveSite, skipOrgSite, publishToCentral,
nexusDeployMaxAttempts, nexusDeployBackoffSeconds, skipNexusDeploy,
centralDeployMaxAttempts, centralDeployBackoffSeconds, skipCentralDeploy,
centralDeployAsync, centralSentinelDir, issueRepo, ignoreWarnings);
ReleaseContext earlyCtx = new ReleaseContext(gitRoot, null, getLog(), request);
// ── Prep phase: B3–B12 (clean worktree, preflight, javadoc,
// SNAPSHOT scan, foundation bake, upstream alignment,
// commit-timestamp resolution) ──
PrepOutcome prep = new ReleasePrep(earlyCtx, getSession()).execute();
String projectId = prep.projectId();
String releaseTimestamp = prep.releaseTimestamp();
// ── B10: Draft-mode short-circuit ────────────────────────────
if (prep.draftMode()) {
return new DraftRenderer(earlyCtx).render(
prep, startDir.toPath(), oldVersion, releaseBranch);
}
// ── Release ───────────────────────────────────────────────────
// Resolve Maven wrapper (publish path only — draft returned above).
File mvnw = ReleaseSupport.resolveMavenWrapper(gitRoot, getLog());
ReleaseContext ctx = earlyCtx.withMvnw(mvnw);
// Build environment audit (needs mvnw for --version)
logAudit(ctx, currentBranch, releaseBranch, oldVersion, projectId);
// ── Local phase: B13–B19 (cut branch, set version, install,
// pre-flight site, commit, tag, restore, merge, post-bump) ──
new LocalPhase(ctx).execute(new LocalInput(
oldVersion, releaseTimestamp, resuming));
// ── External actions (all local work is done) ─────────────────
// Everything above this point is local and idempotent. If any
// external action below fails, all local git state is consistent
// and the deploy can be retried manually.
//
// Order: generate site, deploy site, then Nexus (clean build):
// 1. Generate site (verify → site → stage — catches errors early)
// 2. Site deploy + publish (best-effort, while target/staging/ exists)
// 3. Nexus deploy (clean deploy — fresh build for artifact integrity)
// 4. Push tag + main (additive — safe to retry)
// 5. GitHub Release (additive — safe to retry)
getLog().info("");
getLog().info("Local work complete. Starting external deploys...");
getLog().info("");
// Track gh-pages publish outcome separately from Nexus deploy.
// Used to gate the "GitHub Pages: ..." line in the release-
// complete summary so the log does not falsely claim success
// when the publish actually failed. ike-issues#329.
boolean ghPagesPublished = !publishSite; // skipped == "no failure"
// Detach the worktree to the release tag for the externally-
// visible deploy steps. The WorktreeGuard restores the worktree
// to main on any exit path; foreign mid-flight changes are
// stashed first (ike-issues#373).
try (WorktreeGuard worktreeGuard = WorktreeGuard.detach(ctx,
"v" + releaseVersion,
() -> stashForeignWorktreeChanges(ctx, releaseVersion))) {
// ── Site generation (must succeed before Nexus deploy) ────
// A release without a valid site is incomplete. The tag
// checkout wiped target/, so everything is rebuilt here.
if (publishSite) {
// 1. Verify (generates JaCoCo coverage data)
ReleaseSupport.exec(gitRoot, getLog(),
mvnw.getAbsolutePath(), "verify", "-B", "-T", "1");
// 2. Generate release history XHTML for site inclusion
try {
Path generatedXhtml = gitRoot.toPath()
.resolve("target").resolve("generated-site").resolve("xhtml");
Path xhtmlFile = ReleaseNotesSupport.generateFullHistoryXhtml(
issueRepo, generatedXhtml, getLog());
if (xhtmlFile != null) {
getLog().info("Generated release history: " + xhtmlFile);
}
} catch (Exception e) {
getLog().warn("Could not generate site release notes: "
+ e.getMessage());
}
// 3. Build site (generates JaCoCo HTML from jacoco.exec).
// -Drelease.bootstrap.version=oldVersion (X-SNAPSHOT)
// activates the releaseSelfSite profile in reactor-
// root poms that declare it (#370). See the
// "X-SNAPSHOT bootstrap (2 of 2)" comment on the
// pre-flight site invocation for the full pattern.
List<String> buildArgs = new ArrayList<>();
buildArgs.add(mvnw.getAbsolutePath());
buildArgs.add("site");
buildArgs.add("-B");
buildArgs.add("-T");
buildArgs.add("1");
buildArgs.add("-Drelease.bootstrap.version=" + oldVersion);
if (nonRecursiveSite) buildArgs.add("-N");
ReleaseSupport.exec(gitRoot, getLog(),
buildArgs.toArray(new String[0]));
// 4. Inject breadcrumbs into JaCoCo reports
getLog().info("Injecting breadcrumbs into JaCoCo reports...");
ReleaseSupport.exec(gitRoot, getLog(),
mvnw.getAbsolutePath(), "network.ike.tooling:ike-maven-plugin:inject-breadcrumb",
"-B", "-T", "1");
// 4b. Overlay IKE green theme on generated Javadoc apidocs
// (IKE-Network/ike-issues#518). Writes
// resource-files/ike-theme.css next to the stock
// stylesheet.css and patches every HTML page to
// link both.
getLog().info("Injecting IKE theme into Javadoc apidocs...");
ReleaseSupport.exec(gitRoot, getLog(),
mvnw.getAbsolutePath(), "network.ike.tooling:ike-maven-plugin:inject-javadoc-theme",
"-B", "-T", "1");
// 5. Stage site (packages for deploy)
// Clean target/staging/ first — stale content from
// earlier releases (which used a different staging
// structure under pre-#304 site URLs) can survive
// the maven-clean-plugin pass on some configurations
// and get picked up by publishProjectSiteToGhPages'
// unwrap heuristics. Caused v6 workspace site to
// ship a stale v3-era footer. ike-issues#351 v3.
Path stagingDirToClean = gitRoot.toPath()
.resolve("target").resolve("staging");
if (Files.isDirectory(stagingDirToClean)) {
getLog().info("Cleaning stale target/staging/ "
+ "before site:stage (#351)...");
ReleaseSupport.deleteDirectory(stagingDirToClean);
}
// -Drelease.bootstrap.version: X-SNAPSHOT bootstrap
// (see pre-flight site invocation for full pattern).
List<String> stageArgs = new ArrayList<>();
stageArgs.add(mvnw.getAbsolutePath());
stageArgs.add("site:stage");
stageArgs.add("-B");
stageArgs.add("-T");
stageArgs.add("1");
stageArgs.add("-Drelease.bootstrap.version=" + oldVersion);
if (nonRecursiveSite) stageArgs.add("-N");
ReleaseSupport.exec(gitRoot, getLog(),
stageArgs.toArray(new String[0]));
// 6. Publish to gh-pages (while target/staging/ exists).
// Best-effort — failures warn but don't block Nexus
// deploy. Pre-#304 this step also did scpexe://proxy
// site-deploy; that path was retired since the
// gh-pages publish (public, HTTPS, no LAN/WireGuard
// dependency) is the canonical site distribution
// channel.
try {
SiteDeployResult result = deploySiteAndPublish(ctx,
projectId, releaseVersion);
ghPagesPublished = result.ghPagesPublished();
} catch (Exception e) {
logSiteDeployRetryInstructions(projectId, releaseVersion,
e.getMessage());
ghPagesPublished = false;
}
// Auto-register this release on the IKE Network org-site
// (ike-issues#367; #398 folded into ike:site-publish).
// Placed here, inside the try block, for one specific
// reason: the working tree is checked out at the
// `v<releaseVersion>` tag right now, so the pom version
// reads as <releaseVersion> (not the post-release
// SNAPSHOT bump). That means
// `mvn ike:site-publish` (short prefix) resolves the
// plugin via ${project.version} and naturally lands on
// the just-installed plugin version — no explicit
// coordinate pin needed.
//
// Gates: publishSite + ghPagesPublished prevent
// advertising a release whose canonical URL 404s;
// hasOrigin skips the call for local-only repos;
// skipOrgSite is the operator's opt-out.
//
// Best-effort: site-publish failure warns but does not
// abort the release. Nexus deploy still runs.
if (publishSite && ghPagesPublished
&& !skipOrgSite && prep.hasOrigin()) {
getLog().info("");
getLog().info("Registering release on IKE Network "
+ "landing page (#367; via "
+ IkeGoal.SITE_PUBLISH.qualified()
+ " after #398)...");
try {
// #398: site convergence — registration is the
// LandingPageRegistrationReconciler dimension of
// ike:site-publish. The site itself was already
// pushed to gh-pages above, so opt out of the
// DeployedSiteReconciler with -DupdateSite=false.
ReleaseSupport.exec(gitRoot, getLog(),
mvnw.getAbsolutePath(),
IkeGoal.SITE_PUBLISH.qualified(),
"-DupdateSite=false",
"-B");
} catch (Exception e) {
getLog().warn(" ⚠ Org-site registration "
+ "failed (non-fatal): " + e.getMessage());
getLog().warn(" The release itself is "
+ "complete. To retry: cd to a checkout "
+ "at the v" + releaseVersion
+ " tag and run "
+ "mvn ike:site-publish -DupdateSite=false");
}
}
}
// ── Artifact deploy (critical — the actual release) ──────
// Two-phase deploy (IKE-Network/ike-issues#482):
// 1. Nexus (mandatory, retried, abort on failure)
// 2. Maven Central via JReleaser (opt-in via
// publishToCentral, retried, best-effort — failure
// records the gap but lets tag/main/GH Release
// still publish since Nexus already has the artifact).
// Site was already deployed above (before clean wipes staging).
deployArtifacts(ctx);
}
// WorktreeGuard.close() has restored the worktree to main —
// rationale for the stash step is on stashForeignWorktreeChanges.
// ── Coherence gate (#705): self-resolution + pin assert ───────
// The release does not complete — and the TeamCity finish-trigger
// does not fire any downstream — until this module confirms its
// OWN just-deployed artifact resolves cold at the demanded scope,
// and its own upstream pins are current against fresh metadata.
// Runs AFTER the Nexus deploy (the artifact must exist to be
// verified) and BEFORE the tag/main push + GitHub Release, so a
// failure halts here: no tag, no release, no cascade fire —
// incoherence becomes a red build, never a silent downstream.
// Skipped when the deploy was skipped (nothing was published to
// verify) or there is no origin (a local-only release).
if (prep.hasOrigin() && nexusOutcome.succeeded()) {
CoherenceVerifier coherence = new CoherenceVerifier(getSession(), getLog());
coherence.verifySelfResolves(
ReleaseSupport.readPomGroupId(rootPom), projectId,
releaseVersion, scope);
coherence.assertUpstreamPinsCurrent(gitRoot, scope);
} else if (skipNexusDeploy) {
getLog().warn("Coherence gate skipped: ike.skipNexusDeploy=true "
+ "— nothing was published to verify (#705).");
}
// ── Finalize: push tag + main, create GitHub Release ─────
new FinalizePhase(ctx).execute(new FinalizeInput(
prep.hasOrigin(), projectId, releaseVersion,
prep.foundationUpgrades()));
// Pre-#304: this block called cleanRemoteSiteDir to ssh-delete
// the main-branch snapshot mirror on scpexe://proxy. With the
// scpexe path retired, there's no remote dir to clean.
// VCS state file now managed by ws:release for workspace-level
// releases. Single-repo ike:release does not write VCS state.
getLog().info("");
getLog().info("Release " + releaseVersion + " complete.");
getLog().info(" Tagged: v" + releaseVersion);
// Two-phase deploy outcome (#482). Nexus is mandatory and a
// failure aborts before this point, so a successful release
// by definition has the Nexus outcome marked succeeded — but
// log defensively in case skipNexusDeploy was set.
if (nexusOutcome.succeeded()) {
getLog().info(" Deployed to Nexus (cycle "
+ nexusOutcome.attempts() + "/"
+ nexusDeployMaxAttempts + ")");
} else if (skipNexusDeploy) {
getLog().warn(" Nexus deploy skipped "
+ "(ike.skipNexusDeploy=true)");
}
if (publishToCentral) {
if (centralOutcome.asyncSpawned()) {
getLog().info(" Maven Central: running async — "
+ "track with `mvn "
+ IkeGoal.CENTRAL_STATUS.qualified()
+ "`, tail " + centralOutcome.logPath());
} else if (centralOutcome.succeeded()) {
getLog().info(" Published to Maven Central "
+ "(cycle " + centralOutcome.attempts()
+ "/" + centralDeployMaxAttempts + ")");
} else if (centralOutcome.skipReason() != null) {
getLog().warn(" Maven Central skipped: "
+ centralOutcome.skipReason());
} else if (centralOutcome.attempts() > 0) {
getLog().warn(" Maven Central deploy FAILED after "
+ centralOutcome.attempts() + "/"
+ centralDeployMaxAttempts + " cycles — "
+ "Nexus has v" + releaseVersion + "; "
+ "retry: checkout v" + releaseVersion
+ " and run `mvn jreleaser:deploy`");
}
}
// GitHub Pages publish (the canonical site distribution post-#304).
// Earlier revisions also printed scpexe://proxy URLs (internal
// mirror at ike.komet.sh); that path was retired in #304 since
// gh-pages is public, HTTPS, and doesn't depend on
// LAN/WireGuard reachability.
if (publishSite) {
if (ghPagesPublished) {
// Hybrid structure (#332): current at root, versioned
// snapshot, latest alias.
getLog().info(" GitHub Pages:");
getLog().info(" Current: https://ike.network/"
+ projectId + "/");
getLog().info(" Versioned: https://ike.network/"
+ projectId + "/" + releaseVersion + "/");
getLog().info(" Latest: https://ike.network/"
+ projectId + "/latest/");
} else {
// Don't lie about state. Earlier behavior printed the
// success line unconditionally; ike-issues#329.
getLog().warn(" GitHub Pages: ❌ publish failed "
+ "(see WARNING above)");
getLog().warn(" Retry: mvn "
+ IkeGoal.SITE_PUBLISH.qualified() + " "
+ "-DupdateRegistration=false "
+ "-DreleaseVersion=" + releaseVersion);
}
}
getLog().info(" Merged to main");
getLog().info(" Next version: " + nextVersion);
ReleaseReport report = new ReleaseReport(ctx);
report.reportCascade(false);
return new GoalReportSpec(IkeGoal.RELEASE_PUBLISH,
startDir.toPath(),
report.build(false, oldVersion, releaseBranch,
projectId, releaseTimestamp,
nexusOutcome, centralOutcome,
prep.foundationUpgrades()));
}
/**
* Parses the {@code ike.resolutionScope} property into a typed
* {@link ResolutionScope}, failing with a clear message on an
* unrecognized value (IKE-Network/ike-issues#705).
*
* @param value the raw property value
* @return the matching scope
* @throws MojoException if {@code value} is not a known scope literal
*/
private static ResolutionScope parseResolutionScope(String value) throws MojoException {
// null/blank → the NEXUS default. The @Parameter defaultValue
// supplies "nexus" under a real Maven run, but a direct Mojo
// instantiation (tests) leaves the field null, so default in
// code too rather than throwing on an unset value.
String v = value == null ? "" : value.trim().toLowerCase(java.util.Locale.ROOT);
if (v.isEmpty()) {
return ResolutionScope.NEXUS;
}
for (ResolutionScope s : ResolutionScope.values()) {
if (s.literalName().equals(v)) {
return s;
}
}
throw new MojoException("Unrecognized ike.resolutionScope '" + value
+ "' — must be one of " + ResolutionScope.NAME_LOCAL + ", "
+ ResolutionScope.NAME_NEXUS + ", " + ResolutionScope.NAME_CENTRAL
+ " (#705).");
}
/**
* Two-phase artifact deploy (IKE-Network/ike-issues#482).
*
* <p>Phase 1 — Nexus. Mandatory. Retried per
* {@code ike.deploy.nexus.{maxAttempts,backoffSeconds}}.
* Failure after all attempts aborts the release before any
* tag/main push.
*
* <p>Phase 2 — Maven Central via JReleaser. Opt-in via
* {@link #publishToCentral}. Retried per
* {@code ike.deploy.central.{maxAttempts,backoffSeconds}}.
* Skipped (with a warning, not a hard fail) when Central
* credentials are absent. Failure after all attempts records
* the gap on the run but does <em>not</em> throw — Nexus
* already has the artifact, so the team is unblocked and
* tag/main/GH Release still publish.
*
* @param ctx the per-invocation release context (release working
* tree checked out at the {@code v<version>} release tag)
*/
private void deployArtifacts(ReleaseContext ctx) {
File gitRoot = ctx.gitRoot();
File mvnw = ctx.mvnw();
// ── Phase 1: Nexus ────────────────────────────────────────
if (skipNexusDeploy) {
getLog().warn("Skipping Nexus deploy "
+ "(ike.skipNexusDeploy=true). "
+ "Release is incomplete for internal consumers.");
} else {
nexusOutcome = new NexusPhase(ctx).execute();
}
// ── Phase 2: Maven Central (opt-in, best-effort) ─────────
if (!publishToCentral) {
return;
}
if (skipCentralDeploy) {
centralOutcome = centralOutcome.withSkipReason("explicit ike.skipCentralDeploy=true");
getLog().warn("Skipping Maven Central deploy "
+ "(ike.skipCentralDeploy=true).");
return;
}
String missingCreds = missingCentralCredentials();
if (missingCreds != null) {
centralOutcome = centralOutcome.withSkipReason(missingCreds);
getLog().warn("Skipping Maven Central deploy: "
+ missingCreds);
getLog().warn(" Nexus already has v" + releaseVersion
+ "; internal consumers are unblocked. To push "
+ "to Central later: set the missing env var(s) "
+ "(typically via `op run --env-file=~/.config/"
+ "ike/release.env`), check out v" + releaseVersion
+ ", and run `mvn jreleaser:deploy`.");
return;
}
if (centralDeployAsync) {
spawnCentralDeployAsync(ctx);
} else {
// Phase 4 §1.1 — execute() returns a completed future; the
// mojo joins immediately. Phase 5 forks this under a
// StructuredTaskScope alongside FinalizePhase.
centralOutcome = new CentralPhase(ctx).execute().join();
}
}
/**
* Spawn the Maven Central deploy as a detached subprocess
* (IKE-Network/ike-issues#484). The subprocess runs the retry
* loop in a bash wrapper that writes its own start/success/
* failure sentinel under {@code ~/.cache/ike-release/}, so the
* outcome survives the originating Maven JVM exit.
*
* <p>Why bash and not Java: the wrapper has to outlive this
* Maven invocation. A Java {@code ProcessBuilder.start()}
* child reparents to launchd cleanly on macOS/Linux, but the
* inherited stdin/stdout/stderr need explicit detachment to
* survive a terminal close. A bash {@code nohup ... &} pattern
* handles both — SIGHUP immunity and full background — with
* less platform-specific Java code.
*
* @param gitRoot the release working tree at v<version>
* @param mvnw the Maven wrapper executable
*/
private void spawnCentralDeployAsync(ReleaseContext ctx) {
File gitRoot = ctx.gitRoot();
File mvnw = ctx.mvnw();
Path sentinelDir = centralSentinelDir == null
|| centralSentinelDir.isBlank()
? CentralDeploySentinel.DEFAULT_DIR
: Paths.get(centralSentinelDir);
String artifactId = gitRoot.getName();
centralOutcome = centralOutcome
.withSentinelPath(CentralDeploySentinel
.resolvePath(sentinelDir, artifactId, releaseVersion))
.withLogPath(sentinelDir.resolve(
artifactId + "-" + releaseVersion + ".log"));
try {
Files.createDirectories(sentinelDir);
} catch (IOException e) {
throw new MojoException("Could not create sentinel dir "
+ sentinelDir + ": " + e.getMessage(), e);
}
// Write initial PENDING sentinel before the subprocess starts.
// The subprocess updates it to SUCCESS/FAILURE on completion;
// if the subprocess never starts (spawn failure below), the
// PENDING record is replaced with FAILURE before this method
// throws.
Instant now = Instant.now();
CentralDeploySentinel pending = CentralDeploySentinel.builder()
.state(CentralDeploySentinel.State.PENDING)
.artifactId(artifactId)
.version(releaseVersion)
.started(now)
.attempts(0)
.maxAttempts(centralDeployMaxAttempts)
.logFile(centralOutcome.logPath())
.path(centralOutcome.sentinelPath())
.build();
pending.write();
int[] backoff = RetrySchedule.parseSeconds(
"ike.deploy.central.backoffSeconds",
centralDeployBackoffSeconds);
Path scriptPath = sentinelDir.resolve(
artifactId + "-" + releaseVersion + ".sh");
String script = renderRetryScript(
gitRoot.toPath(), mvnw.toPath(),
centralOutcome.sentinelPath(), centralOutcome.logPath(),
artifactId, releaseVersion,
centralDeployMaxAttempts, backoff, now);
try {
Files.writeString(scriptPath, script);
scriptPath.toFile().setExecutable(true);
} catch (IOException e) {
markAsyncSpawnFailure(pending,
"Could not write retry script: " + e.getMessage());
throw new MojoException("Could not write Central deploy "
+ "retry script " + scriptPath + ": "
+ e.getMessage(), e);
}
// Detach: `bash -c "nohup <script> < /dev/null > /dev/null
// 2>&1 &"`. The outer bash forks the script into the
// background, immune to SIGHUP, and exits in microseconds.
// Inherited env (JRELEASER_*, PATH, etc.) propagates to the
// script. The script writes its own log via $LOG.
String spawnCommand = "nohup '" + scriptPath + "' "
+ "< /dev/null > /dev/null 2>&1 &";
ProcessBuilder pb = new ProcessBuilder("bash", "-c", spawnCommand);
pb.directory(gitRoot);
try {
Process p = pb.start();
int exit = p.waitFor();
if (exit != 0) {
markAsyncSpawnFailure(pending,
"bash forker exited " + exit);
throw new MojoException("Could not spawn detached "
+ "Central deploy: bash exited " + exit);
}
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
markAsyncSpawnFailure(pending,
"Spawn failed: " + e.getMessage());
throw new MojoException("Could not spawn detached "
+ "Central deploy: " + e.getMessage(), e);
}
centralOutcome = centralOutcome.withAsyncSpawned(true);
getLog().info("Spawned async Maven Central deploy "
+ "(IKE-Network/ike-issues#484).");
getLog().info(" Sentinel: " + centralOutcome.sentinelPath());
getLog().info(" Log: " + centralOutcome.logPath());
getLog().info(" Track: mvn " + IkeGoal.CENTRAL_STATUS.qualified());
}
/**
* Rewrite a {@code PENDING} sentinel to {@code FAILURE} when
* the async spawn itself fails (script unwritable, bash exits
* non-zero). Without this the sentinel would stay {@code
* PENDING} forever despite no subprocess being alive.
*
* @param pending the PENDING sentinel to update
* @param reason short failure summary
*/
private void markAsyncSpawnFailure(CentralDeploySentinel pending,
String reason) {
try {
pending.toBuilder()
.state(CentralDeploySentinel.State.FAILURE)
.finished(Instant.now())
.lastError(reason)
.build()
.write();
} catch (RuntimeException ignored) {
// Best-effort cleanup — surface the original failure
// upstream rather than masking it with a sentinel-write
// error.
}
}
/**
* Render the bash retry-loop script that runs
* {@code jreleaser:deploy} with bounded retries and rewrites
* the sentinel on each transition. Static + side-effect-free
* so the script body is straightforward to unit-test.
*
* <p>The script runs inside a temporary {@code git worktree}
* at the {@code v<version>} tag — critical for correctness
* since the main worktree is restored to {@code main} and
* bumped to next-SNAPSHOT immediately after the spawn.
* Without this isolation, JReleaser would read the post-bump
* pom.xml, see a snapshot version, and silently skip the
* Sonatype deployer ({@code [WARNING] Deployer ...:sonatype
* is not enabled. Skipping}).
*
* <p>Each retry cycle is logged to a per-cycle file; on
* mvn-exit-0 the script asserts the log contains
* {@code uploaded as deployment} (proves a real upload
* happened) AND does not contain {@code is not enabled.
* Skipping} (catches deployer-disabled silent no-op).
* Either check failing marks the cycle as a retry-eligible
* failure rather than success. A {@code Deployment timeout
* exceeded} line — JReleaser giving up its publish poll — is
* not a failure: the upload succeeded, so the cycle passes
* and a {@code note} is recorded on the sentinel.
*
* <p>The worktree is cleaned up via {@code trap EXIT}, so
* even an interrupted subprocess leaves no stray git state.
*
* @param gitRoot absolute repository root
* @param mvnw absolute path to the Maven wrapper
* @param sentinel absolute path to the sentinel file
* @param log absolute path to the deploy log file
* @param artifactId project artifactId
* @param version release version (matches v<version> tag)
* @param maxAttempts configured max attempts
* @param backoff inter-attempt waits in seconds
* @param started deploy start instant (UTC)
* @return the script source
*/
static String renderRetryScript(Path gitRoot, Path mvnw,
Path sentinel, Path log,
String artifactId, String version,
int maxAttempts, int[] backoff,
Instant started) {
StringBuilder backoffArr = new StringBuilder();
for (int i = 0; i < backoff.length; i++) {
if (i > 0) backoffArr.append(' ');
backoffArr.append(backoff[i]);
}
// The script is intentionally explicit (no Maven helpers) so a
// human can read it, run it directly to debug, or copy the
// jreleaser:deploy line for a manual retry.
return """
#!/bin/bash
# ike:release-publish Central deploy retry wrapper
# (IKE-Network/ike-issues#484). Generated %s.
# Safe to delete after sentinel reaches SUCCESS/FAILURE.
set -uo pipefail
ARTIFACT_ID="%s"
VERSION="%s"
STARTED="%s"
SENTINEL="%s"
LOG="%s"
GIT_ROOT="%s"
MVNW="%s"
MAX_ATTEMPTS=%d
BACKOFFS=(%s)
# Isolated worktree at the release tag — see method
# javadoc for why this is required.
WORKTREE_PARENT="$(mktemp -d -t ike-release-XXXXXX)"
WORKTREE="$WORKTREE_PARENT/$ARTIFACT_ID-$VERSION"
cleanup() {
if [ -n "${WORKTREE:-}" ] && [ -d "$WORKTREE" ]; then
git -C "$GIT_ROOT" worktree remove --force "$WORKTREE" >> "$LOG" 2>&1 || true
fi
if [ -n "${WORKTREE_PARENT:-}" ] && [ -d "$WORKTREE_PARENT" ]; then
rm -rf "$WORKTREE_PARENT"
fi
}
trap cleanup EXIT
write_sentinel() {
local state="$1"
local extra="$2"
local tmp="${SENTINEL}.tmp"
{
echo "#ike:release-publish Central deploy sentinel"
echo "state=$state"
echo "artifactId=$ARTIFACT_ID"
echo "version=$VERSION"
echo "started=$STARTED"
# Only a terminal state has a finish time. A PENDING
# refresh must not write `finished` — it would read
# as a completed deploy in ike:central-status.
if [ "$state" != "PENDING" ]; then
echo "finished=$(date -u +%%Y-%%m-%%dT%%H:%%M:%%SZ)"
fi
echo "attempts=$ATTEMPTS"
echo "maxAttempts=$MAX_ATTEMPTS"
echo "logFile=$LOG"
echo "pid=$$"
if [ -n "$extra" ]; then
printf '%%s\\n' "$extra"
fi
} > "$tmp"
mv -f "$tmp" "$SENTINEL"
}
ATTEMPTS=0
DEPLOY_NOTE=""
{
echo "[$(date)] Async Central deploy starting"
echo " artifact: $ARTIFACT_ID-$VERSION"
echo " git root: $GIT_ROOT"
echo " worktree: $WORKTREE"
echo " max cycles: $MAX_ATTEMPTS"
echo " backoffs (s): ${BACKOFFS[*]}"
} >> "$LOG"
# One-time: create the isolated worktree at v<version>.
# All deploy cycles reuse it (clean deploy inside the
# worktree wipes its own target/).
if ! git -C "$GIT_ROOT" worktree add "$WORKTREE" "v$VERSION" >> "$LOG" 2>&1; then
write_sentinel "FAILURE" "lastError=could not create worktree at v$VERSION"
echo "[$(date)] FAILURE: worktree add" >> "$LOG"
exit 1
fi
run_attempt() {
local attempt_log="$WORKTREE/target/jreleaser-cycle-$ATTEMPTS.log"
# Step 1: stage signed artifacts to the worktree's
# target/staging-deploy.
if ! (cd "$WORKTREE" && "$MVNW" clean deploy -B -T 1 \\
-P release,signArtifacts \\
-DaltDeploymentRepository=local::file://"$WORKTREE/target/staging-deploy") >> "$LOG" 2>&1; then
echo "[$(date)] Stage failed on cycle $ATTEMPTS" >> "$LOG"
return 1
fi
# Step 2: prune -build.pom (Maven 4 build POMs, not
# published to Central).
find "$WORKTREE/target/staging-deploy" \\
\\( -name '*-build.pom' -o -name '*-build.pom.asc' \\
-o -name '*-build.pom.md5' \\
-o -name '*-build.pom.sha1' \\
-o -name '*-build.pom.sha256' \\
-o -name '*-build.pom.sha512' \\) \\
-delete >> "$LOG" 2>&1 || true
# Step 3: upload via JReleaser. Capture to per-cycle
# log for validation.
if ! (cd "$WORKTREE" && "$MVNW" jreleaser:deploy -N -B) > "$attempt_log" 2>&1; then
cat "$attempt_log" >> "$LOG"
echo "[$(date)] JReleaser upload failed on cycle $ATTEMPTS" >> "$LOG"
return 1
fi
cat "$attempt_log" >> "$LOG"
# Step 4: validate. JReleaser silently skips when the
# deployer isn't enabled (e.g. snapshot version), and
# the mvn exit code is still 0. Confirm a real upload
# happened — see method javadoc.
if grep -q "is not enabled. Skipping" "$attempt_log"; then
echo "[$(date)] JReleaser logged 'Skipping' — deployer not active" >> "$LOG"
return 1
fi
if ! grep -q "uploaded as deployment" "$attempt_log"; then
echo "[$(date)] JReleaser did not log an uploaded deployment" >> "$LOG"
return 1
fi
# The upload succeeded. JReleaser may still have timed
# out polling Sonatype for the PUBLISHED transition —
# that is NOT a deploy failure (the bundle is on
# Sonatype's side and will publish), but record a note
# so ike:central-status flags publication as
# unconfirmed. IKE-Network/ike-issues#484.
if grep -q "Deployment timeout exceeded" "$attempt_log"; then
DEPLOY_NOTE="note=upload accepted by Sonatype; JReleaser poll for PUBLISHED timed out - publication unconfirmed, verify on the Central Portal"
echo "[$(date)] Upload confirmed; JReleaser poll timed out before PUBLISHED (non-fatal)" >> "$LOG"
fi
return 0
}
while [ $ATTEMPTS -lt $MAX_ATTEMPTS ]; do
ATTEMPTS=$((ATTEMPTS + 1))
echo "[$(date)] Cycle $ATTEMPTS/$MAX_ATTEMPTS" >> "$LOG"
# Refresh PENDING with current cycle count so
# ike:central-status reflects progress.
write_sentinel "PENDING" ""
if run_attempt; then
write_sentinel "SUCCESS" "$DEPLOY_NOTE"
echo "[$(date)] SUCCESS on cycle $ATTEMPTS" >> "$LOG"
exit 0
fi
if [ $ATTEMPTS -lt $MAX_ATTEMPTS ]; then
IDX=$((ATTEMPTS - 1))
if [ $IDX -ge ${#BACKOFFS[@]} ]; then
IDX=$((${#BACKOFFS[@]} - 1))
fi
WAIT=${BACKOFFS[$IDX]}
echo "[$(date)] Sleeping ${WAIT}s before next cycle" >> "$LOG"
sleep "$WAIT"
fi
done
write_sentinel "FAILURE" "lastError=exhausted $MAX_ATTEMPTS cycles; see $LOG"
echo "[$(date)] FAILURE after $ATTEMPTS cycles" >> "$LOG"
exit 1
""".formatted(
started.toString(),
artifactId, version, started.toString(),
sentinel.toAbsolutePath(),
log.toAbsolutePath(),
gitRoot.toAbsolutePath(),
mvnw.toAbsolutePath(),
maxAttempts, backoffArr);
}
/**
* Returns {@code null} when Maven Central credentials are
* present in the environment; otherwise a human-readable
* reason naming the missing variables.
*
* <p>JReleaser reads
* {@code JRELEASER_DEPLOY_MAVEN_MAVENCENTRAL_USERNAME} and
* {@code _PASSWORD} (see {@code ike-base-parent} JReleaser
* config). Both must be present and non-blank.
*
* @return missing-credentials reason, or {@code null} when all
* required env vars are set
*/
private String missingCentralCredentials() {
List<String> missing = new ArrayList<>();
String[] required = {
"JRELEASER_DEPLOY_MAVEN_MAVENCENTRAL_USERNAME",
"JRELEASER_DEPLOY_MAVEN_MAVENCENTRAL_PASSWORD"
};
for (String name : required) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
missing.add(name);
}
}
if (missing.isEmpty()) {
return null;
}
return "missing env var(s) " + String.join(", ", missing);
}
private void logAudit(ReleaseContext ctx, String branch,
String releaseBranch, String oldVersion,
String projectId) throws MojoException {
File gitRoot = ctx.gitRoot();
File mvnw = ctx.mvnw();
String gitCommit = ReleaseSupport.execCapture(gitRoot,
"git", "rev-parse", "--short", "HEAD");
String mavenVersion = ReleaseSupport.execCapture(gitRoot,
mvnw.getAbsolutePath(), "--version");
String javaVersion = System.getProperty("java.version", "unknown");
getLog().info("");
getLog().info("RELEASE PARAMETERS");
getLog().info(" Version: " + oldVersion + " -> " + releaseVersion);
getLog().info(" Next version: " + nextVersion);
getLog().info(" Source branch: " + branch);
getLog().info(" Release branch: " + releaseBranch);
getLog().info(" Tag: v" + releaseVersion);
getLog().info(" Project: " + projectId);
getLog().info(" Publish site: " + publishSite);
getLog().info(" Skip verify: " + skipVerify);
getLog().info(" Publish: "+ publish);
getLog().info("");
getLog().info("BUILD ENVIRONMENT");
getLog().info(" Date: " + Instant.now());
getLog().info(" User: " + System.getProperty("user.name", "unknown"));
getLog().info(" Git commit: " + gitCommit);
getLog().info(" Git root: " + gitRoot.getAbsolutePath());
getLog().info(" Maven: " + mavenVersion.lines().findFirst().orElse("unknown"));
getLog().info(" Java version: " + javaVersion);
getLog().info(" OS: " + System.getProperty("os.name") + " " +
System.getProperty("os.arch"));
getLog().info("");
}
/**
* Result of {@link #deploySiteAndPublish}: tracks gh-pages publish
* outcome. Pre-#304 also tracked scpexe site-deploy independently
* (ike-issues#339) but the scpexe path was retired with #304.
*
* @param ghPagesPublished {@code true} if gh-pages publish
* succeeded or was skipped;
* {@code false} if attempted and failed
*/
private record SiteDeployResult(boolean ghPagesPublished) { }
/**
* Deploy the staged site and publish to GitHub Pages.
*
* <p>Called only after site generation and Nexus deploy have both
* succeeded. Failures here are caught by the caller and reported
* as warnings with retry instructions.
*
* @return a {@link SiteDeployResult} carrying the gh-pages publish
* flag — {@code true} if the publish succeeded or was
* skipped, {@code false} if attempted and failed. Pre-#304
* the result also tracked scpexe site-deploy independently
* (ike-issues#339); that path is retired.
*/
private SiteDeployResult deploySiteAndPublish(ReleaseContext ctx,
String projectId, String version)
throws MojoException {
File gitRoot = ctx.gitRoot();
File mvnw = ctx.mvnw();
boolean ghPagesPublished = !publishSite; // skipped == "no failure to report"
// Publish to GitHub Pages. Pre-#304 this came before the
// scpexe site:deploy step (so the deploy's staging-mirror
// wouldn't corrupt target/staging/, per ike-issues#312); now
// it's the only publish target.
if (publishSite) {
String remoteUrl = ReleaseSupport.getRemoteUrl(gitRoot, "origin");
if (remoteUrl == null) {
getLog().info(" Skipping gh-pages publish "
+ "(no 'origin' remote)");
ghPagesPublished = true; // intentionally skipped, not failed
} else {
Path stagingDir = gitRoot.toPath()
.resolve("target").resolve("staging");
Path siteDir = gitRoot.toPath()
.resolve("target").resolve("site");
// Empty-staging fallback (ike-issues#334). mvn site:stage
// is a no-op for single-module projects (it's designed
// to aggregate child sites in a multi-module reactor).
// For single-module projects target/staging/ is created
// empty and target/site/ has the rendered content.
// Earlier behavior shipped the empty staging dir as the
// gh-pages tree, producing a .nojekyll-only branch and
// a 404 at https://ike.network/<projectId>/. This block
// detects that case and substitutes target/site/.
Path publishSource = stagingDir;
try {
if (Files.isDirectory(stagingDir)
&& ReleaseSupport.isEmptyDirectory(stagingDir)
&& Files.isDirectory(siteDir)
&& !ReleaseSupport.isEmptyDirectory(siteDir)) {
getLog().info(" target/staging/ is empty — "
+ "publishing target/site/ instead "
+ "(single-module project; site:stage "
+ "has no children to aggregate). "
+ "ike-issues#334.");
publishSource = siteDir;
}
} catch (MojoException emptyCheckFailed) {
// Fall through with stagingDir; the publish call
// will produce a clearer error.
getLog().debug(" Could not inspect staging/site "
+ "directories: " + emptyCheckFailed.getMessage());
}
try {
ReleaseSupport.publishProjectSiteToGhPages(
publishSource, remoteUrl, getLog(),
projectId, version);
ghPagesPublished = true;
} catch (MojoException e) {
// Log the full cause chain — earlier behavior dropped
// it, leaving only the wrapper message visible.
// ike-issues#329.
getLog().warn(" ⚠ gh-pages publish failed (non-fatal): "
+ e.getMessage());
Throwable cause = e.getCause();
while (cause != null) {
getLog().warn(" caused by: "
+ cause.getClass().getSimpleName()
+ (cause.getMessage() != null
? ": " + cause.getMessage()
: ""));
cause = cause.getCause();
}
getLog().warn(" To retry: from a checkout of v"
+ version + " with 'origin' remote, run "
+ "mvn " + IkeGoal.SITE_PUBLISH.qualified() + " "
+ "-DupdateRegistration=false "
+ "-DreleaseVersion=" + version);
}
}
}
// Pre-#304: scpexe site-deploy ran here. Retired with #304;
// gh-pages publish above is the canonical site distribution.
return new SiteDeployResult(ghPagesPublished);
}
/**
* Stash any uncommitted worktree changes that accumulated during
* the external-phase block, so the subsequent {@code git checkout
* main} can proceed unblocked. ike-issues#373.
*
* <p>By the time this runs, the release flow's own commits have
* already shipped (set-version → tag → restore-project.version →
* merge to main → post-release bump → external deploys). Anything
* captured by the stash is strictly foreign worktree state:
* operator edits, the #358 gh-pages on-disk leak files, stray
* tool output, etc.
*
* <p>Best-effort: if {@code git status} or {@code git stash} fail
* for any reason, the {@code git checkout main} that follows
* will produce its standard error message — same as before this
* guard existed. The guard only adds capability, it doesn't
* remove fallback behavior.
*
* @param gitRoot the project's git root
* @param releaseVersion the release version, used in the stash
* message so the operator can identify
* which release's stash it is
*/
private void stashForeignWorktreeChanges(ReleaseContext ctx,
String releaseVersion) {
File gitRoot = ctx.gitRoot();
String status;
try {
status = ReleaseSupport.execCapture(gitRoot,
"git", "status", "--porcelain").trim();
} catch (MojoException e) {
getLog().debug(" Could not run git status before "
+ "checkout main (#373): " + e.getMessage());
return;
}
if (status.isEmpty()) return;
getLog().warn("");
getLog().warn("Detected mid-flight worktree changes (#373) — "
+ "stashing before 'git checkout main'.");
for (String line : status.split("\n")) {
getLog().warn(" " + line);
}
String stashMessage = "release-flow: mid-flight changes "
+ "during v" + releaseVersion;
try {
ReleaseSupport.exec(gitRoot, getLog(),
"git", "stash", "push",
"--include-untracked",
"-m", stashMessage);
getLog().warn("");
getLog().warn(" Stashed as: " + stashMessage);
getLog().warn(" Recover with: git stash pop");
getLog().warn("");
} catch (Exception e) {
// Couldn't stash — let the subsequent checkout main fail
// with its standard message. Don't mask the underlying
// problem.
getLog().warn(" ⚠ Could not stash mid-flight changes: "
+ e.getMessage());
getLog().warn(" 'git checkout main' will likely fail; "
+ "recover manually per the runbook.");
}
}
/**
* Log retry instructions when site deploy/publish fails after a
* successful Nexus deploy. Keeps the release from failing.
*/
private void logSiteDeployRetryInstructions(String projectId,
String version,
String errorMessage) {
getLog().warn("");
getLog().warn("Site deploy/publish failed: " + errorMessage);
getLog().warn("Nexus deploy succeeded — artifacts are published.");
getLog().warn("To retry site deploy manually:");
getLog().warn(" git checkout v" + version);
getLog().warn(" mvn site:deploy -B -T 1");
getLog().warn(" git checkout main");
getLog().warn("");
}
}