ReleaseReport.java
package network.ike.plugin.release.report;
import network.ike.plugin.CascadeBump;
import network.ike.plugin.release.ReleaseContext;
import network.ike.plugin.release.central.CentralOutcome;
import network.ike.plugin.release.nexus.NexusOutcome;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.workspace.cascade.CascadeReporter;
import network.ike.workspace.cascade.ProjectCascade;
import network.ike.workspace.cascade.ProjectCascadeIo;
import java.util.List;
import java.util.Optional;
/**
* Renders the markdown report body and the cascade-preview/footer log
* lines for {@code ike:release-draft} and {@code ike:release-publish}.
*
* <p>The report body is the markdown payload of the {@code GoalReportSpec}
* returned by {@code ReleaseDraftMojo.runGoal()}; the IKE goal-report
* aggregator collects it for the workspace-level run report. The
* cascade log lines are advisory output emitted directly through
* {@code ctx.log()}.
*
* <p>Carved out of {@code ReleaseDraftMojo} during the Phase 4
* Commit 6 (IKE-Network/ike-issues#489). The companion
* {@link DraftRenderer} wraps this class for the draft-mode short-circuit.
*/
public final class ReleaseReport {
private final ReleaseContext ctx;
/**
* Creates a new report renderer bound to the given context.
*
* @param ctx the per-invocation release context
*/
public ReleaseReport(ReleaseContext ctx) {
this.ctx = ctx;
}
/**
* Prints the foundation release cascade section
* (IKE-Network/ike-issues#402, #420).
*
* <p>When the releasing repository version-controls its own
* {@code src/main/cascade/release-cascade.yaml} it is a cascade
* member: this surfaces the downstream repos the release affects
* — a preview in draft mode, a "what's next" footer in publish
* mode. A repository with no such file (an ordinary consumer) or
* an unreadable manifest is silently skipped — cascade reporting
* is purely advisory and never fails or blocks a release.
*
* @param draft {@code true} for the draft preview, {@code false}
* for the post-publish footer
*/
public void reportCascade(boolean draft) {
try {
Optional<ProjectCascade> loaded = ProjectCascadeIo.load(
ctx.gitRoot().toPath().resolve(
ProjectCascadeIo.MANIFEST_RELATIVE_PATH));
if (loaded.isEmpty()) {
// No release-cascade.yaml — an ordinary consumer, not
// a foundation cascade member. Nothing to report.
return;
}
String repo = ctx.gitRoot().getName();
List<String> lines = draft
? CascadeReporter.draftPreview(loaded.get(), repo)
: CascadeReporter.publishFooter(loaded.get(), repo);
ctx.log().info("");
lines.forEach(ctx.log()::info);
} catch (RuntimeException e) {
ctx.log().warn("Release cascade report skipped: "
+ e.getMessage());
}
}
/**
* Builds the markdown body for an {@code ike:release-*} session report.
*
* @param draft {@code true} for draft preview, {@code false}
* for a completed publish run
* @param oldVersion the pre-release POM version
* @param releaseBranch the release branch that was (or would be) created
* @param projectId the artifactId of the project being released
* @param releaseTimestamp the reproducible build timestamp stamped into
* {@code project.build.outputTimestamp}
* @param nexus the Nexus deploy outcome (use {@code NexusOutcome.initial()} for draft)
* @param central the Central deploy outcome (use {@code CentralOutcome.initial()} for draft)
* @param foundationUpgrades the upstream-version bumps the release applied; rendered as a
* "Foundation upgrades" section when non-empty (#706)
* @return the markdown body
*/
public String build(boolean draft, String oldVersion, String releaseBranch,
String projectId, String releaseTimestamp,
NexusOutcome nexus, CentralOutcome central,
List<CascadeBump> foundationUpgrades) {
String releaseVersion = ctx.request().releaseVersion();
String nextVersion = ctx.request().nextVersion();
boolean publishSite = ctx.request().publishSite();
boolean publishToCentral = ctx.request().publishToCentral();
boolean skipNexusDeploy = ctx.request().skipNexusDeploy();
int nexusDeployMaxAttempts = ctx.request().nexusDeployMaxAttempts();
int centralDeployMaxAttempts = ctx.request().centralDeployMaxAttempts();
GoalReportBuilder report = new GoalReportBuilder();
report.raw("**Project:** " + projectId + "\n"
+ "**Mode:** " + (draft ? "draft (preview)" : "publish") + "\n"
+ "**Version:** " + oldVersion + " → " + releaseVersion + "\n"
+ "**Next version:** " + nextVersion + "\n"
+ "**Release branch:** " + releaseBranch + "\n"
+ "**Tag:** v" + releaseVersion + "\n"
+ "**Timestamp:** " + releaseTimestamp + "\n\n");
// Foundation upgrades (#706) — a cascade-only rebuild's "what
// changed." Empty in draft mode (no bumps are applied) and for
// ordinary non-cascade releases.
if (foundationUpgrades != null && !foundationUpgrades.isEmpty()) {
report.section("Foundation upgrades");
StringBuilder up = new StringBuilder();
for (CascadeBump b : foundationUpgrades) {
up.append("- `").append(b.ga()).append("` ")
.append(b.current()).append(" → ").append(b.latest())
.append('\n');
}
up.append('\n');
report.raw(up.toString());
}
String verb = draft ? "Would" : "Did";
report.section("Local actions");
StringBuilder local = new StringBuilder();
local.append("1. ").append(verb)
.append(" create branch `").append(releaseBranch).append("`\n");
local.append("2. ").append(verb)
.append(" set version ").append(oldVersion).append(" → ")
.append(releaseVersion).append("\n");
local.append("3. ").append(verb)
.append(" stamp `project.build.outputTimestamp`\n");
local.append("4. ").append(verb)
.append(" resolve `${project.version}` in all POMs\n");
local.append("5. ").append(verb).append(" run `mvnw clean verify -B`\n");
local.append("6. ").append(verb)
.append(" commit and tag `v").append(releaseVersion)
.append("`\n");
local.append("7. ").append(verb)
.append(" merge `").append(releaseBranch).append("` to main\n");
local.append("8. ").append(verb)
.append(" bump to next version ").append(nextVersion)
.append("\n\n");
report.raw(local.toString());
report.section("External actions");
StringBuilder external = new StringBuilder();
int step = 1;
if (publishSite) {
external.append(step++).append(". ").append(verb)
.append(" generate site\n");
}
external.append(step++).append(". ").append(verb)
.append(" deploy to Nexus from tag `v")
.append(releaseVersion).append("`")
.append(deployAttemptSuffix(
nexus.attempts(), nexusDeployMaxAttempts))
.append('\n');
if (publishToCentral) {
external.append(step++).append(". ").append(verb)
.append(" publish to Maven Central from tag `v")
.append(releaseVersion).append("`")
.append(centralOutcomeSuffix(central, centralDeployMaxAttempts))
.append('\n');
}
if (publishSite) {
external.append(step++).append(". ").append(verb)
.append(" force-push site to gh-pages on origin "
+ "(serves at `https://ike.network/")
.append(projectId).append("/`)\n");
}
external.append(step++).append(". ").append(verb)
.append(" push tag and main to origin\n");
external.append(step).append(". ").append(verb)
.append(" create GitHub Release\n");
report.raw(external.toString());
// Deploy details section (publish mode only — draft has no
// cycle data to report). Always renders the Nexus line.
// The Maven Central line renders only when publishToCentral
// is set, with three possible outcomes (success / skip /
// failure). IKE-Network/ike-issues#482.
if (!draft) {
report.section("Deploy details");
StringBuilder deploy = new StringBuilder();
deploy.append("- **Nexus:** ")
.append(nexus.succeeded()
? "✅ succeeded on cycle "
+ nexus.attempts() + "/"
+ nexusDeployMaxAttempts
: skipNexusDeploy
? "⚠ skipped (ike.skipNexusDeploy=true)"
: "❌ did not run")
.append('\n');
if (publishToCentral) {
deploy.append("- **Maven Central:** ");
if (central.asyncSpawned()) {
// Async path (#484) — outcome unknown at this point.
deploy.append("⏳ running async (#484) — track "
+ "with `mvn ")
.append("ike:central-status")
.append("`")
.append("\n - Sentinel: `")
.append(central.sentinelPath())
.append("`\n - Log: `")
.append(central.logPath())
.append('`');
} else if (central.succeeded()) {
deploy.append("✅ succeeded on cycle ")
.append(central.attempts()).append("/")
.append(centralDeployMaxAttempts);
} else if (central.skipReason() != null) {
deploy.append("⚠ skipped — ")
.append(central.skipReason());
} else if (central.attempts() > 0) {
deploy.append("❌ failed after ")
.append(central.attempts()).append("/")
.append(centralDeployMaxAttempts)
.append(" cycles");
if (central.failureSummary() != null) {
deploy.append(" — ")
.append(central.failureSummary());
}
deploy.append("\n Retry: `git checkout v")
.append(releaseVersion)
.append(" && mvn jreleaser:deploy`");
} else {
deploy.append("⚠ did not run");
}
deploy.append('\n');
}
report.raw(deploy.toString());
}
return report.build();
}
/**
* Renders a {@code " (cycle N/M)"} suffix for the post-release
* report, or empty when no cycles were tracked (draft mode).
*/
private static String deployAttemptSuffix(int attempts, int max) {
if (attempts <= 0) {
return "";
}
return " (cycle " + attempts + "/" + max + ")";
}
/**
* Renders an outcome suffix for the Maven Central row in the
* External-actions list. Distinguishes succeeded / skipped /
* failed / pending-draft.
*/
private static String centralOutcomeSuffix(CentralOutcome central, int maxAttempts) {
if (central.asyncSpawned()) {
return " (async — see Deploy details)";
}
if (central.succeeded()) {
return " (cycle " + central.attempts() + "/"
+ maxAttempts + ")";
}
if (central.skipReason() != null) {
return " — skipped (" + central.skipReason() + ")";
}
if (central.attempts() > 0) {
return " — FAILED after " + central.attempts() + "/"
+ maxAttempts + " cycles";
}
return "";
}
}