LocalPhase.java

package network.ike.plugin.release.local;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.SnapshotScanner;
import network.ike.plugin.release.ReleaseContext;
import org.apache.maven.api.plugin.MojoException;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

/**
 * The local-only release phase — B13 through B19 of the
 * {@code ReleaseDraftMojo} block audit:
 *
 * <ol>
 *   <li>B13 — cut {@code release/<version>} branch from main</li>
 *   <li>B14 — replace project-version refs, scan for surviving SNAPSHOTs</li>
 *   <li>B15 — first {@code mvn clean install} (Maven 4 consumer POM flatten + local install)</li>
 *   <li>B16 — pre-flight site build (catches javadoc errors before tag)</li>
 *   <li>B17 — release commit on {@code release/<version>}</li>
 *   <li>B18 — {@code git tag v<version>} — the irreversibility boundary</li>
 *   <li>B19a — restore {@code ${project.version}} references, restore-commit</li>
 *   <li>B19b — merge {@code release/<version>} to {@code main}</li>
 *   <li>B19c — post-release bump to next {@code -SNAPSHOT}, install, commit, delete release branch</li>
 * </ol>
 *
 * <p>Everything in this phase is local and reversible — the moment B18
 * runs, the release tag is in place, but nothing externally visible
 * has happened yet. External deploys run from the tagged commit
 * inside a {@code WorktreeGuard} (see {@code ReleaseDraftMojo}).
 *
 * <p>Carved out of {@code ReleaseDraftMojo.runGoal()} during the
 * Phase 4 Commit 4 (IKE-Network/ike-issues#489).
 */
public final class LocalPhase {

    private final ReleaseContext ctx;

    /**
     * Creates a new local phase bound to the given context.
     *
     * @param ctx the per-invocation release context
     */
    public LocalPhase(ReleaseContext ctx) {
        this.ctx = ctx;
    }

    /**
     * Executes the local release phase.
     *
     * <p>On resume ({@code input.resuming()} true), branch creation
     * and version-setting (B13, B14) are skipped — the previous
     * attempt left the {@code release/<version>} branch and the
     * version-mutated POMs in place. The remaining steps (install,
     * site, commit, tag, restore, merge, post-bump) run as usual.
     *
     * @param input the prep-stage outputs needed for the local phase
     * @return a {@link LocalOutcome} summarizing which sub-steps ran
     * @throws MojoException on any subprocess failure or surviving SNAPSHOT version
     */
    public LocalOutcome execute(LocalInput input) throws MojoException {
        String releaseVersion = ctx.request().releaseVersion();
        String nextVersion = ctx.request().nextVersion();
        String releaseBranch = "release/" + releaseVersion;
        File rootPom = new File(ctx.gitRoot(), "pom.xml");

        List<File> resolvedPoms = cutBranchAndSetVersion(
                input, releaseVersion, releaseBranch, rootPom);

        List<File> bakedPoms = bakeIndirections();

        firstInstall();
        preflightSite(input.oldVersion());
        commitAndTag(resolvedPoms, bakedPoms, releaseVersion);
        restoreReferences();
        mergeToMain(releaseBranch, releaseVersion);
        postBump(rootPom, nextVersion, releaseBranch);

        return new LocalOutcome("v" + releaseVersion, true, true, true);
    }

    /**
     * B13 + B14 — cuts the {@code release/<version>} branch from main,
     * sets the release version in the root POM, stamps the
     * reproducible-build timestamp, then resolves {@code ${project.version}}
     * references across all POMs and scans for any surviving
     * {@code -SNAPSHOT} versions (defense in depth for #175 / #177).
     *
     * <p>Skipped on resume; returns an empty list of resolved POMs in
     * that case.
     */
    private List<File> cutBranchAndSetVersion(LocalInput input,
                                              String releaseVersion,
                                              String releaseBranch,
                                              File rootPom) {
        File gitRoot = ctx.gitRoot();
        if (input.resuming()) {
            ctx.log().info("Skipping version set (already " + releaseVersion + ")");
            return List.of();
        }
        ReleaseSupport.exec(gitRoot, ctx.log(),
                "git", "checkout", "-b", releaseBranch);

        ctx.log().info("Setting version: " + input.oldVersion()
                + " -> " + releaseVersion);
        ReleaseSupport.setPomVersion(rootPom, input.oldVersion(), releaseVersion);

        ctx.log().info("Stamping project.build.outputTimestamp: "
                + input.releaseTimestamp());
        ReleaseSupport.stampOutputTimestamp(rootPom, input.releaseTimestamp(), ctx.log());

        // WORKAROUND: Maven 4 consumer POM doesn't resolve ${project.version}
        // in <build><plugins>, <pluginManagement>, or <dependencyManagement>.
        ctx.log().info("Resolving ${project.version} references:");
        List<File> resolvedPoms =
                ReleaseSupport.replaceProjectVersionRefs(gitRoot, releaseVersion, ctx.log());

        // Defense in depth (#175, #177): after ${project.version}
        // substitution the only legitimate <version> values are
        // released literals. Scan all POMs for any surviving
        // <version>...-SNAPSHOT</version> before we commit the
        // release tag — this is Layer 2 of the SNAPSHOT preflight.
        List<File> allPoms = ReleaseSupport.findPomFiles(gitRoot);
        List<SnapshotScanner.Violation> versionViolations =
                SnapshotScanner.scanForSnapshotVersions(allPoms);
        if (!versionViolations.isEmpty()) {
            throw new MojoException(SnapshotScanner.formatViolations(
                    versionViolations, gitRoot,
                    versionViolations.size() + " literal SNAPSHOT <version>"
                            + " element(s) remain after property resolution:",
                    "  These would be baked into the released artifact.\n"
                    + "  Replace each with a released version or resolve via\n"
                    + "  ${project.version} before re-running the release."));
        }
        return resolvedPoms;
    }

    /**
     * B15 — runs the first {@code mvn clean install} from the release
     * branch.
     *
     * <p>Uses {@code install} rather than {@code verify} so reactor
     * siblings with BOM imports can resolve classified dependencies
     * (e.g., {@code ike-build-standards:zip:claude}). The release
     * version has never been installed; {@code verify} alone fails on
     * inter-module resolution. {@code install} puts artifacts in the
     * local repo for sibling resolution.
     *
     * <p>Skipped when {@code skipVerify} is set on the request.
     */
    private void firstInstall() {
        if (ctx.request().skipVerify()) {
            ctx.log().info("Skipping verify (-DskipVerify=true)");
            return;
        }
        ReleaseSupport.exec(ctx.gitRoot(), ctx.log(),
                ctx.mvnw().getAbsolutePath(), "clean", "install", "-B", "-T", "1");
    }

    /**
     * B16 — pre-flight site build (catches javadoc errors before any
     * commits/tags).
     *
     * <p>{@code -T 1} overrides any project-level parallelism: the
     * maven-site-plugin is not {@code @ThreadSafe} and emits warnings
     * in parallel sessions. {@code -N} (non-recursive) when releasing
     * an aggregator whose subproject sites would otherwise collide at
     * the staging root (ike-issues#356).
     *
     * <h4>X-SNAPSHOT bootstrap (2 of 2) — ike-issues#370</h4>
     *
     * <p>Every {@code mvn site} / {@code mvn site:stage} invocation in
     * this mojo passes {@code -Drelease.bootstrap.version=<oldVersion>}.
     * {@code oldVersion} is the pre-release pom version (i.e.,
     * {@code X-SNAPSHOT}, where X is the version about to be released).
     *
     * <p>The property activates the {@code releaseSelfSite} profile in
     * any reactor-root pom that declares it (currently just
     * {@code ike-tooling} itself, which has the cycle problem). Inside
     * that profile, {@code ike-maven-plugin} is bound at
     * {@code <version>${release.bootstrap.version}</version>} — i.e.,
     * at {@code X-SNAPSHOT}, which is a DIFFERENT GAV than the reactor
     * submodules (set to X by the version-set step above). Different
     * GAV → no graph edge to a submodule → no reactor cycle. Maven
     * flags the cycle at reactor evaluation time, so the indirection
     * has to live in the pom; we just supply the property value.
     *
     * <p>Why {@code X-SNAPSHOT} is guaranteed in {@code ~/.m2}: the
     * first {@code mvn clean install} above runs against {@code X}
     * (the release version), but the prior pre-release {@code install}
     * (run by the cascade orchestrator or manual setup) put
     * {@code X-SNAPSHOT} in the local repo. Subsequent site invocations
     * resolve the plugin descriptor from there.
     *
     * <p>No-op for projects that do not declare the releaseSelfSite
     * profile — setting a property Maven does not see has no effect,
     * so this is safe to pass unconditionally.
     *
     * <p>THE OTHER HALF OF THIS PATTERN — see
     * {@code ike-tooling/pom.xml} (search "X-SNAPSHOT bootstrap (1 of 2)")
     * for the profile declaration that consumes
     * {@code ${release.bootstrap.version}}.
     *
     * <p>Note: only {@code mvn site} / {@code mvn site:stage}
     * invocations pass the property. Other release-flow {@code mvn}
     * calls (verify, deploy, site-publish, etc.) stay outside the
     * profile and resolve plugin coords via pluginManagement — the
     * standard self-host pattern, which works because pluginManagement
     * does not create reactor edges the way live {@code <plugins>} does.
     */
    private void preflightSite(String oldVersion) {
        if (!ctx.request().publishSite()) {
            return;
        }
        ctx.log().info("Building site (pre-flight check)...");
        List<String> siteArgs = new ArrayList<>();
        siteArgs.add(ctx.mvnw().getAbsolutePath());
        siteArgs.add("site");
        siteArgs.add("site:stage");
        siteArgs.add("-B");
        siteArgs.add("-T");
        siteArgs.add("1");
        siteArgs.add("-Drelease.bootstrap.version=" + oldVersion);
        if (ctx.request().nonRecursiveSite()) {
            siteArgs.add("-N");
        }
        ReleaseSupport.exec(ctx.gitRoot(), ctx.log(),
                siteArgs.toArray(new String[0]));
    }

    /**
     * B17 + B18 — release commit on {@code release/<version>}, then
     * {@code git tag -a v<version>}. The tag creation is the
     * irreversibility boundary of the local phase.
     */
    private void commitAndTag(List<File> resolvedPoms,
                               List<File> bakedPoms,
                               String releaseVersion) {
        File gitRoot = ctx.gitRoot();
        ReleaseSupport.exec(gitRoot, ctx.log(), "git", "add", "pom.xml");
        ReleaseSupport.gitAddFiles(gitRoot, ctx.log(), resolvedPoms);
        ReleaseSupport.gitAddFiles(gitRoot, ctx.log(), bakedPoms);
        ReleaseSupport.exec(gitRoot, ctx.log(),
                "git", "commit", "-m",
                "release: set version to " + releaseVersion);

        ReleaseSupport.exec(gitRoot, ctx.log(),
                "git", "tag", "-a", "v" + releaseVersion,
                "-m", "Release " + releaseVersion);
    }

    /**
     * Bakes {@code __ALIAS}-driven indirections into all POM files
     * before the release tag (IKE-Network/ike-issues#527). Source
     * poms declare the alias relationship via {@code __ALIAS}
     * metadata only; this step materializes the corresponding
     * {@code <short>${G__GA__A__VERSION}</short>} indirections into
     * the tagged source pom so descendants without vm-ext can
     * resolve legacy short-name references via Maven inheritance.
     * {@link #restoreReferences} removes them after the tag.
     *
     * @return the list of POM files modified by indirection bake
     */
    private List<File> bakeIndirections() {
        File gitRoot = ctx.gitRoot();
        ctx.log().info("Baking __ALIAS indirections:");
        return ReleaseSupport.bakeAliasIndirections(gitRoot, ctx.log());
    }

    /**
     * B19a — restores {@code ${project.version}} references that
     * B14 substituted and removes the {@code __ALIAS} indirections
     * that B14b baked (IKE-Network/ike-issues#527), then commits
     * the restore on the release branch. The tag created by B18
     * stays on the release commit (above this restore-commit), not
     * on the restored commit.
     */
    private void restoreReferences() {
        File gitRoot = ctx.gitRoot();
        ctx.log().info("Restoring ${project.version} references:");
        List<File> restoredPoms = ReleaseSupport.restoreBackups(gitRoot, ctx.log());
        ctx.log().info("Unbaking __ALIAS indirections:");
        List<File> unbakedPoms = ReleaseSupport.unbakeAliasIndirections(
                gitRoot, ctx.log());
        if (restoredPoms.isEmpty() && unbakedPoms.isEmpty()) {
            return;
        }
        if (!restoredPoms.isEmpty()) {
            ReleaseSupport.gitAddFiles(gitRoot, ctx.log(), restoredPoms);
        }
        if (!unbakedPoms.isEmpty()) {
            ReleaseSupport.gitAddFiles(gitRoot, ctx.log(), unbakedPoms);
        }
        ReleaseSupport.exec(gitRoot, ctx.log(),
                "git", "commit", "-m",
                "release: restore source pom state");
    }

    /**
     * B19b — checks out {@code main} and merges
     * {@code release/<version>} with a no-fast-forward merge commit.
     */
    private void mergeToMain(String releaseBranch, String releaseVersion) {
        File gitRoot = ctx.gitRoot();
        ReleaseSupport.exec(gitRoot, ctx.log(), "git", "checkout", "main");
        ReleaseSupport.exec(gitRoot, ctx.log(),
                "git", "merge", "--no-ff", releaseBranch,
                "-m", "merge: release " + releaseVersion);
    }

    /**
     * B19c — bumps the POM version to the next {@code -SNAPSHOT},
     * runs {@code mvn clean install} on the post-bump tree (seeds
     * {@code ~/.m2} for self-hosting reactors per #486), commits,
     * and deletes the now-merged release branch.
     */
    private void postBump(File rootPom, String nextVersion, String releaseBranch) {
        File gitRoot = ctx.gitRoot();
        ctx.log().info("");
        ctx.log().info("Bumping to next version: " + nextVersion);

        // Re-read version after merge (it's the release version on main now)
        String currentVersion = ReleaseSupport.readPomVersion(rootPom);
        ReleaseSupport.setPomVersion(rootPom, currentVersion, nextVersion);

        // Verify AND install the new SNAPSHOT (IKE-Network/ike-issues#486).
        // `install` (not just `verify`) puts the post-bump -SNAPSHOT in
        // the local repo so a self-hosting repo — whose POM pins
        // ike-maven-plugin to ${project.version} — can run the next
        // ike:* goal (or an ike:release-cascade walk to the next
        // member) without a manual `mvn install` first.
        ReleaseSupport.exec(gitRoot, ctx.log(),
                ctx.mvnw().getAbsolutePath(), "clean", "install", "-B", "-T", "1");

        ReleaseSupport.exec(gitRoot, ctx.log(), "git", "add", "pom.xml");
        ReleaseSupport.exec(gitRoot, ctx.log(),
                "git", "commit", "-m",
                "post-release: bump to " + nextVersion);

        ReleaseSupport.exec(gitRoot, ctx.log(),
                "git", "branch", "-d", releaseBranch);
    }
}