ReleaseDraftMojo.java

package network.ike.plugin;

import network.ike.plugin.scaffold.FoundationBaker;
import network.ike.plugin.scaffold.ScaffoldManifest;
import network.ike.plugin.scaffold.ScaffoldManifestIo;
import network.ike.plugin.support.AbstractGoalMojo;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.support.GoalReportSpec;
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.CascadeReporter;
import network.ike.workspace.cascade.ProjectCascade;
import network.ike.workspace.cascade.ProjectCascadeIo;
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.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.nio.file.Path;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;

/**
 * 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, commit</li>
 * </ol>
 *
 * <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 to Nexus from tagged commit (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 = "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;

    /**
     * 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;

    /** 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 + "').");
        }

        // 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 ike:release-publish\n"
                        + "  Fresh:  git branch -D " + releaseBranch
                        + " && mvn ike:release-publish");
            }
        }

        String projectId = ReleaseSupport.readPomArtifactId(rootPom);
        boolean draft = !publish;

        // Validate clean worktree (cheap check — before wrapper resolution)
        ReleaseSupport.requireCleanWorktree(gitRoot);

        // ── Preflight: verify external connectivity before any work ──
        // Every external action is checked upfront so failures happen
        // in seconds, not after a 10-minute build. Each check is
        // non-destructive and idempotent.
        boolean hasOrigin = ReleaseSupport.hasRemote(gitRoot, "origin");
        if (publish) {
            preflightChecks(gitRoot, hasOrigin, projectId, releaseVersion);
        }
        // Javadoc preflight (#168) — runs in both modes. Publish fails
        // on warnings; draft logs them so the user sees what would block
        // the real release.
        preflightJavadoc(gitRoot, publish);

        // 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 (e.g. ike-parent-105.pom shipped with
        // <ike-tooling.version>112-SNAPSHOT</ike-tooling.version>).
        // Catch it 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);
            }
            getLog().warn(msg);
        }

        // Release-prep foundation bake (#414): when this release owns
        // the scaffold manifest (the ike-tooling release), refresh its
        // foundation: block to the latest released versions so the
        // scaffold zip ships a current compatibility snapshot.
        bakeFoundationSnapshot(gitRoot, draft);

        // Release-prep upstream cascade alignment (#419): bump this
        // repo's ${X.version} pins to the latest released upstreams so
        // a single-repo release never ships on a stale foundation.
        alignUpstreamProperties(gitRoot, draft);

        // Derive timestamp from the current HEAD commit, not wall-clock time.
        // This ensures two independent builds from the same tag produce the
        // same project.build.outputTimestamp value — which is the reproducibility
        // guarantee. Wall-clock time would defeat the purpose.
        String releaseTimestamp = resolveCommitTimestamp(gitRoot);

        if (draft) {
            getLog().info("[DRAFT] Would create branch: " + releaseBranch);
            getLog().info("[DRAFT] Would set version: " + oldVersion +
                    " -> " + releaseVersion);
            getLog().info("[DRAFT] Would stamp project.build.outputTimestamp: "
                    + releaseTimestamp);
            getLog().info("[DRAFT] Would resolve ${project.version} -> " +
                    releaseVersion + " in all POMs");
            getLog().info("[DRAFT] Would run: mvnw clean verify -B");
            getLog().info("[DRAFT] Would commit, tag v" + releaseVersion);
            getLog().info("[DRAFT] Would restore ${project.version} references");
            getLog().info("[DRAFT] Would merge " + releaseBranch + " to main");
            getLog().info("[DRAFT] Would bump to next version: " + nextVersion);
            getLog().info("[DRAFT] --- all local work above, external below ---");
            if (publishSite) {
                getLog().info("[DRAFT] Would generate site (must succeed)");
            }
            getLog().info("[DRAFT] Would deploy to Nexus from tag v" +
                    releaseVersion + " (critical)");
            if (publishSite) {
                getLog().info("[DRAFT] Would force-push staged site "
                        + "to gh-pages on origin (best-effort)");
                getLog().info("[DRAFT] Would publish at "
                        + "https://ike.network/" + projectId + "/");
            }
            getLog().info("[DRAFT] Would push tag and main to origin");
            getLog().info("[DRAFT] Would create GitHub Release");
            reportCascade(gitRoot, true);
            return new GoalReportSpec(IkeGoal.RELEASE_DRAFT,
                    startDir.toPath(),
                    buildReleaseReport(true, oldVersion, releaseBranch,
                            projectId, releaseTimestamp));
        }

        // ── Release ───────────────────────────────────────────────────

        // Resolve Maven wrapper (requires mvnw or mvn on PATH — skip for draft)
        File mvnw = ReleaseSupport.resolveMavenWrapper(gitRoot, getLog());

        // Build environment audit (needs mvnw for --version)
        logAudit(gitRoot, mvnw, currentBranch, releaseBranch, oldVersion, projectId);

        List<File> resolvedPoms;
        if (resuming) {
            // Skip branch creation and version setting — already done
            getLog().info("Skipping version set (already " + releaseVersion + ")");
            resolvedPoms = List.of(); // backups handle restore later
        } else {
            // Create release branch
            ReleaseSupport.exec(gitRoot, getLog(),
                    "git", "checkout", "-b", releaseBranch);

            // Set version
            getLog().info("Setting version: " + oldVersion + " -> " + releaseVersion);
            ReleaseSupport.setPomVersion(rootPom, oldVersion, releaseVersion);

            // Stamp reproducible build timestamp
            getLog().info("Stamping project.build.outputTimestamp: " + releaseTimestamp);
            ReleaseSupport.stampOutputTimestamp(rootPom, releaseTimestamp, getLog());

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

            // 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."));
            }
        }

        // Build and install (not just verify) — reactor siblings with
        // BOM imports need installed artifacts to resolve classified
        // dependencies (e.g., ike-build-standards:zip:claude). The
        // release version has never been installed, so 'verify' alone
        // fails on inter-module resolution. Using 'install' puts
        // artifacts in the local repo for sibling resolution.
        if (!skipVerify) {
            ReleaseSupport.exec(gitRoot, getLog(),
                    mvnw.getAbsolutePath(), "clean", "install", "-B", "-T", "1");
        } else {
            getLog().info("Skipping verify (-DskipVerify=true)");
        }

        // Build site (catches javadoc errors before any commits/tags).
        // -T 1 overrides .mvn/maven.config parallelism: maven-site-plugin
        // is not @ThreadSafe and emits a warning in parallel sessions.
        // -N (non-recursive) when releasing an aggregator whose
        // subproject sites would otherwise collide at the staging
        // root (ike-issues#356).
        //
        // ── X-SNAPSHOT bootstrap (2 of 2) ─────────────────────────────
        // ike-issues#370.
        //
        // Every `mvn site` / `mvn site:stage` invocation in this mojo
        // passes -Drelease.bootstrap.version=<oldVersion>. oldVersion
        // is the pre-release pom version (i.e., X-SNAPSHOT, where X is
        // the version about to be released — captured at line ~139,
        // before setPomVersion runs).
        //
        // The property activates the releaseSelfSite profile in any
        // reactor-root pom that declares it (currently just ike-tooling
        // itself, which has the cycle problem). Inside that profile,
        // ike-maven-plugin is bound at <version>${release.bootstrap.
        // version}</version> — i.e., at X-SNAPSHOT, which is a
        // DIFFERENT GAV than the reactor submodules (set to X by this
        // mojo's setPomVersion). 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.
        //
        // Why X-SNAPSHOT is guaranteed in ~/.m2: this mojo's pre-
        // release step (above) runs `mvn clean install` BEFORE any
        // version bump, installing X-SNAPSHOT locally. Subsequent
        // site invocations resolve the plugin descriptor from there.
        //
        // 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.
        //
        // THE OTHER HALF OF THIS PATTERN — see
        //   ike-tooling/pom.xml
        // (search "X-SNAPSHOT bootstrap (1 of 2)") for the profile
        // declaration that consumes ${release.bootstrap.version}.
        //
        // Note: only `mvn site` / `mvn site:stage` invocations pass
        // the property. Other release-flow `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 <plugins> does.
        if (publishSite) {
            getLog().info("Building site (pre-flight check)...");
            List<String> siteArgs = new ArrayList<>();
            siteArgs.add(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 (nonRecursiveSite) siteArgs.add("-N");
            ReleaseSupport.exec(gitRoot, getLog(),
                    siteArgs.toArray(new String[0]));
        }

        // Commit
        ReleaseSupport.exec(gitRoot, getLog(), "git", "add", "pom.xml");
        ReleaseSupport.gitAddFiles(gitRoot, getLog(), resolvedPoms);
        ReleaseSupport.exec(gitRoot, getLog(),
                "git", "commit", "-m",
                "release: set version to " + releaseVersion);

        // Tag
        ReleaseSupport.exec(gitRoot, getLog(),
                "git", "tag", "-a", "v" + releaseVersion,
                "-m", "Release " + releaseVersion);

        // Restore ${project.version} references
        getLog().info("Restoring ${project.version} references:");
        List<File> restoredPoms = ReleaseSupport.restoreBackups(gitRoot, getLog());
        if (!restoredPoms.isEmpty()) {
            ReleaseSupport.gitAddFiles(gitRoot, getLog(), restoredPoms);
            ReleaseSupport.exec(gitRoot, getLog(),
                    "git", "commit", "-m",
                    "release: restore ${project.version} references");
        }

        // Merge back to main
        ReleaseSupport.exec(gitRoot, getLog(), "git", "checkout", "main");
        ReleaseSupport.exec(gitRoot, getLog(),
                "git", "merge", "--no-ff", releaseBranch,
                "-m", "merge: release " + releaseVersion);

        // ── Post-release bump ─────────────────────────────────────────

        getLog().info("");
        getLog().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 build with new SNAPSHOT version
        ReleaseSupport.exec(gitRoot, getLog(),
                mvnw.getAbsolutePath(), "clean", "verify", "-B", "-T", "1");

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

        // Clean up release branch
        ReleaseSupport.exec(gitRoot, getLog(),
                "git", "branch", "-d", releaseBranch);

        // ── 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("");

        // Deploy from the tagged release commit
        ReleaseSupport.exec(gitRoot, getLog(),
                "git", "checkout", "v" + releaseVersion);

        // 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"
        try {
            // ── 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");

                // 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(gitRoot,
                            mvnw, 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 && hasOrigin) {
                    getLog().info("");
                    getLog().info("Registering release on IKE Network "
                            + "landing page (#367; via ike:site-publish "
                            + "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(),
                                "ike:site-publish",
                                "-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");
                    }
                }
            }

            // ── Nexus deploy (critical — the actual release) ─────────
            // clean deploy: fresh build ensures artifact integrity.
            // Site was already deployed above (before clean wipes staging).
            getLog().info("Deploying to Nexus...");
            ReleaseSupport.exec(gitRoot, getLog(),
                    mvnw.getAbsolutePath(), "clean", "deploy", "-B", "-T", "1",
                    "-P", "release,signArtifacts");
        } finally {
            // Always return to main, even if deploy fails.
            //
            // Stash any mid-flight worktree changes first (#373). By
            // this point the release has shipped: Nexus deploy +
            // gh-pages + org-site register are all done. The only
            // remaining work is `git checkout main`, push tag + main,
            // and the GitHub Release. If something has written to the
            // worktree mid-flight (an operator edit, a stray tool
            // output), `git checkout main` fails with
            //
            //   "Your local changes ... would be overwritten by checkout"
            //
            // — and the housekeeping never runs. Recovery is mechanical
            // but manual. Pre-empt by stashing foreign worktree
            // changes; the operator gets them back with `git stash
            // pop` after the release reports complete.
            //
            // Release-flow's own commits ran before this block (set-
            // version → tag → restore-project.version → merge → bump-
            // to-next-SNAPSHOT). So anything captured by the stash is
            // strictly foreign — by construction.
            stashForeignWorktreeChanges(gitRoot, releaseVersion);
            ReleaseSupport.exec(gitRoot, getLog(), "git", "checkout", "main");
        }

        // Push tag and main
        if (hasOrigin) {
            ReleaseSupport.exec(gitRoot, getLog(),
                    "git", "push", "origin", "v" + releaseVersion);
            ReleaseSupport.exec(gitRoot, getLog(),
                    "git", "push", "origin", "main");
        } else {
            getLog().info("No 'origin' remote — skipping push");
        }

        // Create GitHub Release with milestone-based release notes
        if (hasOrigin) {
            createGitHubRelease(gitRoot, projectId, releaseVersion);
        } else {
            getLog().info("No 'origin' remote — skipping GitHub Release");
        }

        // 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);
        getLog().info("  Deployed to Nexus");
        // 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 ike:site-publish "
                        + "-DupdateRegistration=false "
                        + "-DreleaseVersion=" + releaseVersion);
            }
        }
        getLog().info("  Merged to main");
        getLog().info("  Next version: " + nextVersion);

        reportCascade(gitRoot, false);

        return new GoalReportSpec(IkeGoal.RELEASE_PUBLISH,
                startDir.toPath(),
                buildReleaseReport(false, oldVersion, releaseBranch,
                        projectId, releaseTimestamp));
    }

    /**
     * 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 gitRoot the releasing repository's git root
     * @param draft   {@code true} for the draft preview, {@code false}
     *                for the post-publish footer
     */
    private void reportCascade(File gitRoot, boolean draft) {
        try {
            Optional<ProjectCascade> loaded = ProjectCascadeIo.load(
                    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 = gitRoot.getName();
            List<String> lines = draft
                    ? CascadeReporter.draftPreview(loaded.get(), repo)
                    : CascadeReporter.publishFooter(loaded.get(), repo);
            getLog().info("");
            lines.forEach(getLog()::info);
        } catch (RuntimeException e) {
            getLog().warn("Release cascade report skipped: "
                    + e.getMessage());
        }
    }


    /**
     * Build 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}
     * @return the markdown body
     */
    private String buildReleaseReport(boolean draft, String oldVersion,
                                       String releaseBranch,
                                       String projectId,
                                       String releaseTimestamp) {
        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");

        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("`\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());

        return report.build();
    }

    /**
     * Return 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
     * TeamCity verification build, defeating reproducibility.
     *
     * <p>Falls back to the current wall-clock time if git is unavailable.
     */
    private String resolveCommitTimestamp(File 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(java.time.OffsetDateTime.parse(raw).toInstant());
        } catch (Exception e) {
            getLog().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 gitRoot the release repository root
     * @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(File gitRoot, boolean draft)
            throws MojoException {
        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) {
            getLog().warn("Foundation bake: scaffold manifest has no "
                    + "foundation: block — skipping.");
            return;
        }

        List<FoundationBaker.Finding> findings;
        try {
            findings = FoundationBaker.assess(manifest.foundation(),
                    new SessionCandidateVersionResolver(getSession()));
        } catch (RuntimeException e) {
            String msg = "Foundation bake: could not resolve latest "
                    + "released versions — " + e.getMessage();
            if (publish) {
                throw new MojoException(msg, e);
            }
            getLog().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());
            }
            getLog().warn(msg.toString());
        }

        if (bumps.isEmpty()) {
            getLog().info("Foundation bake: scaffold foundation: block "
                    + "already at the latest released versions.");
            return;
        }

        getLog().info("Foundation bake:");
        for (FoundationBaker.Finding f : bumps) {
            getLog().info("  " + (draft ? "→ " : "✓ ")
                    + f.coordinate().label() + ": "
                    + f.current() + " -> " + f.latest());
        }
        if (draft) {
            getLog().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, getLog(), "git", "add",
                "ike-build-standards/src/main/scaffold/scaffold-manifest.yaml");
        ReleaseSupport.exec(gitRoot, getLog(), "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 gitRoot the release repository root
     * @param draft   {@code true} to report only; {@code false} to
     *                rewrite the POM and commit
     * @throws MojoException on an unresolvable upstream or a missing
     *                       {@code version-property} in publish mode,
     *                       or on an I/O failure
     */
    private void alignUpstreamProperties(File gitRoot, boolean draft)
            throws MojoException {
        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;
        }

        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);
        }

        CandidateVersionResolver resolver =
                new SessionCandidateVersionResolver(getSession());
        List<String> bumps = new ArrayList<>();
        List<String> problems = new ArrayList<>();
        String updated = content;

        for (CascadeEdge up : loaded.get().upstream()) {
            String property = up.versionProperty();
            String current = ReleaseSupport.readPomProperty(
                    pomFile, property);
            if (current == null) {
                problems.add(up.ga() + ": POM has no <" + property
                        + "> property.");
                continue;
            }
            if (current.contains("${")) {
                continue;
            }
            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) {
                continue;
            }
            String after = PomRewriter.updateProperty(
                    updated, property, latest);
            if (!after.equals(updated)) {
                updated = after;
                bumps.add("<" + property + ">: " + current
                        + " -> " + latest);
            }
        }

        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());
            }
            getLog().warn(msg.toString());
        }

        if (bumps.isEmpty()) {
            getLog().info("Upstream cascade alignment: ${X.version}"
                    + " pins already at the latest released versions.");
            return;
        }

        getLog().info("Upstream cascade alignment:");
        for (String b : bumps) {
            getLog().info("  " + (draft ? "→ " : "✓ ") + b);
        }
        if (draft) {
            getLog().info("  [DRAFT] pom.xml not modified — publish"
                    + " would rewrite and commit it.");
            return;
        }

        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, getLog(), "git", "add", "pom.xml");
        ReleaseSupport.exec(gitRoot, getLog(), "git", "commit", "-m",
                "release: align upstream cascade versions");
    }

    /**
     * Verify 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>Checks: git-push auth, {@code gh} CLI availability, {@code gh}
     * write permission on {@code issueRepo} (#392), {@code pending-release}
     * label existence (#392), commit trailer compliance (#392),
     * release-milestone existence (#392), and Maven wrapper presence.
     * Only invoked for a publish.
     *
     * @param gitRoot        the release repository root
     * @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(File gitRoot, boolean hasOrigin,
                                  String projectId, String releaseVersion)
            throws MojoException {
        getLog().info("");
        getLog().info("PREFLIGHT CHECKS");
        List<String> errors = new java.util.ArrayList<>();
        List<String> warnings = new java.util.ArrayList<>();

        // 1. Git push auth — draft push (sends nothing, tests auth)
        if (hasOrigin) {
            try {
                ReleaseSupport.execCapture(gitRoot,
                        "git", "push", "--dry-run", "origin", "main");
                getLog().info("  Git push:    authenticated  ✓");
            } catch (Exception e) {
                errors.add("Cannot push to origin — fix authentication"
                        + " before releasing. Error: " + e.getMessage());
                getLog().error("  Git push:    authentication failed  ✗");
            }
        } else {
            getLog().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");
                getLog().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");
                getLog().warn("  gh CLI:      not available (GitHub Release "
                        + "will be skipped)");
            }
        }

        // 3. gh write permission on issueRepo (#392) — an error.
        // Required for closeMilestone and removePendingReleaseLabels.
        if (ghAvailable && issueRepo != null && !issueRepo.isBlank()) {
            try {
                String pushPerm = ReleaseSupport.execCapture(gitRoot,
                        "gh", "api", "/repos/" + issueRepo,
                        "--jq", ".permissions.push");
                if ("true".equals(pushPerm.trim())) {
                    getLog().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");
                    getLog().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");
                getLog().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\"");
                getLog().warn("  pending-rel label: missing on " + issueRepo);
            }
        }

        // 5. Trailer compliance for commits in release range (#392) — warn.
        if (hasOrigin) {
            List<String> nonCompliant =
                    findCommitsWithoutIssueTrailer(gitRoot);
            if (nonCompliant.isEmpty()) {
                getLog().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());
                getLog().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) {
                    getLog().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 + "'");
                    getLog().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, getLog());
            getLog().info("  Maven:       wrapper found  ✓");
        } catch (Exception e) {
            errors.add("Maven wrapper (mvnw) not found."
                    + " Run: mvn wrapper:wrapper");
            getLog().error("  Maven:       wrapper not found  ✗");
        }

        // Report the complete preflight picture, then decide (#428).
        // Every check above ran to completion and recorded into
        // `errors` or `warnings` rather than failing fast, so this one
        // pass logs everything wrong. Errors always abort the release;
        // warnings abort too unless -Dike.release.ignoreWarnings=true.
        if (!errors.isEmpty() || !warnings.isEmpty()) {
            getLog().info("");
            for (String err : errors) {
                getLog().error("  ✗ " + err);
            }
            for (String w : warnings) {
                getLog().warn("  ⚠ " + w);
            }
            getLog().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) {
                getLog().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.");
            }
        }
        getLog().info("");
    }

    /**
     * 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 .+)$");

    /**
     * Find 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.
     *
     * @param gitRoot the git working tree
     * @return list of "short-sha subject" strings, empty if all comply
     */
    private List<String> findCommitsWithoutIssueTrailer(File gitRoot) {
        try {
            String previousTag;
            try {
                previousTag = ReleaseSupport.execCapture(gitRoot,
                        "git", "describe", "--tags", "--abbrev=0", "HEAD");
            } catch (Exception e) {
                getLog().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 java.util.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) {
            getLog().debug("  Trailer compliance check failed: "
                    + e.getMessage());
            return List.of();
        }
    }

    /**
     * Check that javadoc generation — as the release profile runs it —
     * produces no warnings across every reactor module. 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.
     *
     * <p>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 gitRoot reactor root whose javadoc is inspected
     * @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
     */
    private void preflightJavadoc(File gitRoot, boolean publish)
            throws MojoException {
        if (!hasAnyJavaSource(gitRoot)) return;

        List<String> warnings = collectJavadocWarnings(gitRoot);
        getLog().info("");
        if (warnings.isEmpty()) {
            getLog().info("  Javadoc:     warning-free  ✓");
            return;
        }

        getLog().info("  Javadoc:     " + warnings.size()
                + " warning(s)  ✗");
        for (String w : warnings) {
            getLog().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.");
        }
        getLog().warn("  (Draft mode — would block publish.)");
        getLog().info("");
    }

    /**
     * Return {@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.
     *
     * @param gitRoot the repository root to search
     * @return {@code true} if at least one Java source tree is present
     */
    private 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;
    }

    /**
     * Run {@code mvn compile javadoc:jar} at {@code gitRoot} to mirror
     * the release's javadoc path across every reactor module, and
     * return every line matching {@code warning:} stripped of the
     * leading {@code [WARNING] } prefix. Tolerates subprocess failure
     * so the release does not abort on an infrastructure issue (a real
     * javadoc failure will resurface during the subsequent build
     * phase).
     *
     * @param gitRoot the reactor root in which to run javadoc
     * @return the captured warning lines in encounter order; empty if
     *         javadoc produced no warnings or the subprocess failed
     */
    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) {
            getLog().debug("Javadoc preflight subprocess failed: "
                    + e.getMessage());
        }
        return warnings;
    }

    private void logAudit(File gitRoot, File mvnw, String branch,
                          String releaseBranch, String oldVersion,
                          String projectId) throws MojoException {
        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(File gitRoot, File mvnw,
                                          String projectId, String version)
            throws MojoException {
        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 ike:site-publish "
                            + "-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(File gitRoot,
                                              String releaseVersion) {
        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("");
    }

    /**
     * Create a GitHub Release with milestone-based release notes.
     *
     * <p>Looks for a milestone named {@code <projectId> v<version>}
     * in the configured issue repository. If found, generates formatted
     * release notes from its closed issues. Falls back to GitHub's
     * auto-generated commit-based notes if no milestone exists.
     */
    private void createGitHubRelease(File gitRoot, String projectId,
                                      String version)
            throws MojoException {
        String milestoneName = projectId + " v" + version;

        // Try milestone-based notes first
        Path notesFile = ReleaseNotesSupport.generateToFile(
                issueRepo, milestoneName, getLog());

        try {
            if (notesFile != null) {
                getLog().info("Release notes generated from milestone: "
                        + milestoneName);
                ReleaseSupport.exec(gitRoot, getLog(),
                        "gh", "release", "create", "v" + version,
                        "--title", version,
                        "--notes-file", notesFile.toString(),
                        "--verify-tag");
            } else {
                getLog().info("No milestone \"" + milestoneName
                        + "\" found — using auto-generated notes");
                ReleaseSupport.exec(gitRoot, getLog(),
                        "gh", "release", "create", "v" + version,
                        "--title", version,
                        "--generate-notes", "--verify-tag");
            }
        } catch (Exception e) {
            getLog().warn("GitHub Release creation failed "
                    + "(gh CLI may not be installed): " + e.getMessage());
            getLog().warn("Run manually: gh release create v" + version
                    + " --title " + version + " --generate-notes");
        }

        // Close the milestone now that the release has shipped.
        // Non-fatal — the release is already done at this point.
        if (notesFile != null) {
            try {
                ReleaseNotesSupport.closeMilestone(issueRepo, milestoneName, getLog());
            } catch (Exception e) {
                getLog().warn("Could not close milestone (release succeeded): "
                        + e.getMessage());
                getLog().warn("Close manually: gh api repos/" + issueRepo
                        + "/milestones/1 -X PATCH -f state=closed");
            }
        }

        // Remove pending-release label from issues resolved in this
        // release range. Runs regardless of milestone presence: cross-
        // org Fixes/Closes/Resolves trailers reach issues that won't
        // be in our milestone. Non-fatal.
        try {
            ReleaseNotesSupport.removePendingReleaseLabels(
                    gitRoot, null, "v" + version, issueRepo, getLog());
        } catch (Exception e) {
            getLog().warn("Could not remove pending-release labels "
                    + "(release succeeded): " + e.getMessage());
        }
    }
}