CentralPhase.java
package network.ike.plugin.release.central;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.release.ReleaseContext;
import network.ike.plugin.release.RetrySchedule;
import org.apache.maven.api.plugin.MojoException;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
/**
* The Maven Central deploy phase of the release pipeline (sync path).
*
* <p>Stages signed artifacts to a local {@code staging-deploy}
* directory then uploads them via JReleaser to the Sonatype Central
* Portal. Retried per {@code ike.deploy.central.{maxAttempts,backoffSeconds}}.
* Central failure is best-effort and does not throw: Nexus already
* has the artifact by the time this runs, so the team is unblocked
* and tag/main push and the GitHub Release proceed regardless.
*
* <p>{@link #execute()} returns a {@link CompletableFuture} per the
* Phase 4 decision §1.1 — the standalone mojo joins the future
* immediately to preserve today's blocking semantics; the Phase 5
* orchestrator forks it as a subtask alongside {@code FinalizePhase}
* under a single {@code StructuredTaskScope}.
*
* <p>The detached async-bash spawn path
* ({@code ReleaseDraftMojo.spawnCentralDeployAsync}) remains on the
* mojo for now and is the wedge that Phase 5 replaces with
* structured concurrency; this class covers only the sync deploy path.
*
* <p>Carved out of {@code ReleaseDraftMojo.deployToMavenCentralWithRetry()}
* and {@code deployToMavenCentralCore()} during the Phase 4 Commit 3
* (IKE-Network/ike-issues#489).
*/
public final class CentralPhase {
private final ReleaseContext ctx;
/**
* Creates a new Central phase bound to the given context.
*
* @param ctx the per-invocation release context
*/
public CentralPhase(ReleaseContext ctx) {
this.ctx = ctx;
}
/**
* Executes the Maven Central deploy with retry.
*
* <p>Retry parameters are read from {@code ctx.request()}:
* {@link network.ike.plugin.release.ReleaseRequest#centralDeployMaxAttempts()}
* and
* {@link network.ike.plugin.release.ReleaseRequest#centralDeployBackoffSeconds()}.
* The caller is responsible for skip-decision logic
* ({@code skipCentralDeploy}, missing credentials,
* {@code centralDeployAsync} routing); this method runs the sync
* deploy unconditionally when invoked.
*
* <p>Returns a {@link CompletableFuture#completedFuture}-wrapped
* outcome. The future never completes exceptionally — exhausted
* retries are surfaced through {@link CentralOutcome#failureSummary()}
* with the failure logged as a warning. The release continues to
* tag/main push regardless of Central's outcome (Nexus has the
* artifact; team is unblocked).
*
* @return a completed future carrying the {@link CentralOutcome}
*/
public CompletableFuture<CentralOutcome> execute() {
CentralOutcome outcome = CentralOutcome.initial();
int maxAttempts = ctx.request().centralDeployMaxAttempts();
int[] backoff = RetrySchedule.parseSeconds(
"ike.deploy.central.backoffSeconds",
ctx.request().centralDeployBackoffSeconds());
Throwable last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
outcome = outcome.withAttempts(attempt);
if (attempt > 1) {
int wait = backoff[Math.min(attempt - 2,
backoff.length - 1)];
RetrySchedule.sleepBefore(ctx.log(), wait, "Maven Central deploy",
attempt, maxAttempts);
}
ctx.log().info("Publishing to Maven Central (cycle "
+ attempt + "/" + maxAttempts + ")...");
try {
deployToMavenCentralCore();
return CompletableFuture.completedFuture(outcome.withSucceeded(true));
} catch (MojoException e) {
last = e;
ctx.log().warn("Maven Central cycle " + attempt
+ "/" + maxAttempts
+ " failed: " + e.getMessage());
}
}
String releaseVersion = ctx.request().releaseVersion();
outcome = outcome.withFailureSummary(last == null
? "unknown failure"
: last.getMessage());
ctx.log().warn("Maven Central deploy did not succeed after "
+ maxAttempts + " cycles. "
+ "Nexus already has v" + releaseVersion
+ "; tag and main will still publish. "
+ "To retry Central later: check out v"
+ releaseVersion + " and run "
+ "`mvn jreleaser:deploy`.");
return CompletableFuture.completedFuture(outcome);
}
/**
* Single-shot Central staging + JReleaser upload. The retry
* loop in {@link #execute()} calls this per attempt.
*
* <p>Three steps: a signed {@code clean deploy} to a local
* staging directory ({@code target/staging-deploy}); a prune of
* the Maven 4 {@code -build.pom} artifacts (build-time only, not
* published to Central); then {@code jreleaser:deploy}, which
* uploads the staged bundle to the Sonatype Central Portal.
*/
private void deployToMavenCentralCore() {
File gitRoot = ctx.gitRoot();
File mvnw = ctx.mvnw();
Path stagingDir = gitRoot.toPath()
.resolve("target").resolve("staging-deploy");
ctx.log().info("Staging signed artifacts for Maven Central...");
ReleaseSupport.exec(gitRoot, ctx.log(),
mvnw.getAbsolutePath(), "clean", "deploy", "-B", "-T", "1",
"-P", "release,signArtifacts",
"-DaltDeploymentRepository=local::file://"
+ stagingDir.toAbsolutePath());
pruneBuildPoms(stagingDir);
ctx.log().info("Uploading bundle via JReleaser...");
// -N (non-recursive): jreleaser:deploy runs once, at the reactor
// root, uploading the whole staging directory as a single bundle.
// Without it the goal runs per module — the first invocation
// publishes everything, the rest fail "artifacts already deployed".
ReleaseSupport.exec(gitRoot, ctx.log(),
mvnw.getAbsolutePath(), "jreleaser:deploy", "-N", "-B");
}
/**
* Deletes the Maven 4 {@code -build.pom} artifacts — and their
* signatures and checksums — from the staging directory. The
* build POM carries the 4.1.0 model and is build-time only;
* Maven Central publishes the consumer POM (the main
* {@code .pom}). One build POM is produced per reactor module,
* so the directory is walked recursively.
*/
private void pruneBuildPoms(Path stagingDir) {
try (Stream<Path> paths = Files.walk(stagingDir)) {
List<Path> buildPoms = paths
.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString()
.contains("-build.pom"))
.toList();
for (Path p : buildPoms) {
Files.delete(p);
ctx.log().info(" pruned " + stagingDir.relativize(p));
}
} catch (IOException e) {
throw new MojoException(
"Failed to prune -build.pom artifacts from "
+ stagingDir, e);
}
}
}