WsReleaseDraftMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.PomRewriter;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.ReleasePlan.ArtifactReleasePlan;
import network.ike.plugin.ws.ReleasePlan.GA;
import network.ike.plugin.ws.ReleasePlan.PropertyReleasePlan;
import network.ike.plugin.ws.ReleasePlan.ReferenceKind;
import network.ike.plugin.ws.ReleasePlan.ReferenceSite;
import network.ike.plugin.ws.ReleasePlanCompute.ArtifactReleaseIntent;
import network.ike.plugin.ws.ReleasePlanCompute.SubprojectRoot;
import network.ike.plugin.ws.preflight.Preflight;
import network.ike.plugin.ws.preflight.PreflightCondition;
import network.ike.plugin.ws.preflight.PreflightContext;
import network.ike.plugin.ws.preflight.PreflightResult;
import network.ike.plugin.support.GoalReportBuilder;

import network.ike.workspace.ManifestWriter;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;


import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
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.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Workspace-level release — release all release-pending checked-out
 * components (those with unreleased commits since their last tag, or
 * cascaded as transitive downstream of one) in topological order.
 *
 * <p>Scans checked-out components for commits since their last release
 * tag. The release set is the union of:
 * <ul>
 *   <li><b>source-changed</b> — subprojects with unreleased commits
 *       since their last tag (or never released);</li>
 *   <li><b>transitive downstream</b> — every checked-out subproject
 *       that depends, directly or transitively, on a source-changed
 *       subproject. Catches workspaces where a mid-graph change forces
 *       downstream re-publish even though those downstream subprojects
 *       have no source changes of their own.</li>
 * </ul>
 * The release set is topologically sorted and released in dependency
 * order. Before each subproject's release, a single
 * <em>catch-up alignment commit</em> bumps every workspace-internal
 * upstream version reference (parent and {@code <X.version>} property)
 * to the upstream's current target version — this-cycle's new version
 * if the upstream is releasing this cycle, otherwise the upstream's
 * current published version on disk. All upstream bumps for a single
 * subproject batch into one commit (never two).</p>
 *
 * <p>Catch-up never expands the release set: a subproject with stale
 * upstream properties but no source changes <em>and</em> no upstream
 * releasing in this cycle is not pulled in.</p>
 *
 * <p>If catch-up alignment fails for any subproject (POM rewrite or
 * commit error), the release halts at that subproject with a
 * {@link MojoException} naming the failing subproject and property —
 * never silently continues.</p>
 *
 * <p><strong>What it does, per subproject:</strong></p>
 * <ol>
 *   <li>Detect latest release tag ({@code v*})</li>
 *   <li>Check for commits since that tag</li>
 *   <li>If source-changed or cascade-induced: catch-up upstream version
 *       references in this subproject's POM (single commit), then run
 *       {@code mvn ike:release-publish} in that subproject's directory</li>
 * </ol>
 *
 * <p><strong>Workspace-level preflight</strong> (applied before any
 * subproject is released):</p>
 * <ul>
 *   <li>{@link PreflightCondition#WORKING_TREE_CLEAN} — every
 *       checked-out subproject (and the workspace root) must have no
 *       uncommitted changes.</li>
 *   <li>{@link PreflightCondition#NO_SNAPSHOT_PROPERTIES} — no root
 *       POM may carry a {@code <properties>} value ending in
 *       {@code -SNAPSHOT}. Catches the {@code ike-parent-105.pom}
 *       leakage class of bug at its source (see issues #175, #177).</li>
 * </ul>
 *
 * <p>Per-subproject preflight (javadoc warnings, git push auth, SSH
 * proxy, gh CLI auth, Maven wrapper, post-mutation SNAPSHOT
 * <code>&lt;version&gt;</code> scan) runs inside each
 * {@code ike:release-publish} invocation — see {@code ReleaseDraftMojo}
 * in the {@code ike-maven-plugin} module. This ensures the same
 * gates apply whether a release is invoked workspace-level or
 * directly inside a single subproject.
 *
 * <p>The cascade is self-limiting: only checked-out components with
 * changes since their last release are candidates. Components not
 * present in the aggregator are not considered.</p>
 *
 * <pre>{@code
 * mvn ws:release-draft                       # preview what would be released
 * mvn ws:release-publish                     # release all release-pending components
 * }</pre>
 */
@Mojo(name = "release-draft", projectRequired = false, aggregator = true)
public class WsReleaseDraftMojo extends AbstractWorkspaceMojo {

    private static final DateTimeFormatter ISO_UTC =
            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
                    .withZone(ZoneOffset.UTC);

    /** Preview what would be released without executing. */
    @Parameter(property = "publish", defaultValue = "false")
    boolean publish;

    /** Skip the pre-release checkpoint. */
    @Parameter(property = "skipCheckpoint", defaultValue = "false")
    boolean skipCheckpoint;

    /** Push releases to remote. Passed through to ike:release. */
    @Parameter(property = "push", defaultValue = "true")
    boolean push;

    /**
     * Release even when a subproject's release preflight reports
     * warnings (e.g. commits without an issue trailer). Forwarded to
     * each subproject's {@code ike:release-publish} invocation as
     * {@code -Dike.release.ignoreWarnings}. Errors remain fatal.
     */
    @Parameter(property = "ike.release.ignoreWarnings", defaultValue = "false")
    boolean ignoreWarnings;

    /**
     * GitHub repository for release creation (e.g., "IKE-Network/komet").
     * If set, creates a GitHub Release for each released subproject and
     * attaches any platform installers found in the subproject's
     * {@code target/installers/} directory.
     */
    @Parameter(property = "githubRepo")
    String githubRepo;

    /**
     * Glob pattern for installer artifacts to attach to the GitHub Release.
     * Matched relative to each subproject's {@code target/} directory.
     */
    @Parameter(property = "installerGlob", defaultValue = "installers/*.{pkg,dmg,msi,deb,rpm}")
    String installerGlob;

    /** Creates this goal instance. */
    public WsReleaseDraftMojo() {}

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();

        // ── 1. Determine candidate components ─────────────────────────
        List<String> candidates = graph.topologicalSort();

        boolean draft = !publish;

        // Refresh local main from origin/main before any release work —
        // the cascade picks up parent versions and property bumps off
        // local main, and Syncthing-paired workflows can leave local
        // main stale (ike-issues#284). Same invariant the feature
        // flows establish; releases need it for the same reason.
        if (publish) {
            RefreshMainSupport.refreshOrThrow(root, candidates, "main", getLog());
        }

        // ── 1.5. Pre-release upstream alignment ──────────────────────────
        // An ike-parent bump (or any foundation property bump) is a
        // release-worthy change even when no source has been edited.
        // Without this pass, ws:release-publish only saw per-subproject
        // source changes and skipped workspaces whose only diff vs.
        // last release was "absorb the new foundations".
        //
        // For each subproject (and the workspace root), scan its pom
        // for <parent> blocks and <X.version> properties referencing
        // an IKE foundation. If a reference is older than the
        // foundation's latest released version (resolved from the
        // sibling repo's tip v* tag in the canonical ~/ike-dev/
        // layout), bump via OpenRewrite and commit. The bump commit
        // becomes a meaningful commit, so the existing
        // meaningfulCommitsSinceTag detector includes the subproject
        // in the release set automatically.
        //
        // Also clean any on-disk gh-pages leak directories
        // (<pomDir>/<artifactId>/<artifactId>/index.html — the #358
        // signature). Those are produced by a stale ike-parent's
        // broken <site><url> inheritance; once alignment bumps the
        // parent to a fixed version (v45+), they stop being produced,
        // but the existing dirs remain on disk until somebody
        // explicitly removes them. Doing it here avoids the
        // chicken-and-egg where the NO_ON_DISK_GHPAGES_LEAK preflight
        // would block on a leak that alignment is about to fix at
        // the source — a stale workspace on its first cascade after
        // the fix shipped.
        //
        // Runs BEFORE preflight (rather than after) for the same
        // reason: alignment and the leak cleanup it triggers are
        // exactly what makes the preflight checks pass. WORKING_TREE_CLEAN
        // is unaffected — alignment commits its own changes, leaving
        // worktree clean from git's POV; gitignored leak dirs were
        // never counted by `git status --porcelain` anyway.
        //
        // Publish-only: drafts skip alignment because the goal is
        // preview, not mutation. A draft will under-report
        // workspaces with stale parents — accept that for now;
        // an alignment dry-run mode is a follow-up.
        if (publish) {
            preReleaseUpstreamAlignment(graph, root);
        }

        // ── Preflight: all working trees clean, no POM-shape gotchas ──
        // (Javadoc cleanliness is checked per-module by ike:release
        //  preflight — see ReleaseDraftMojo — so every entry point
        //  enforces it, not only workspace-level releases.)
        //
        // #346 expanded the preflight set so the dry-run is
        // authoritative: every cascade-time gotcha that has bitten a
        // release is checked at draft time, not discovered mid-flight
        // after some subprojects have already tagged:
        //   WORKING_TREE_CLEAN                   — #132 #154
        //   NO_SNAPSHOT_PROPERTIES               — Maven 4 consumer
        //                                          flattener leak
        //   SUBPROJECT_HAS_DISTRIBUTION_MANAGEMENT — site:stage gate
        //                                          (#343 surfaced)
        //   NO_FOUNDATION_PROPERTY_SHADOWING     — ike-tooling.version
        //                                          shadowing pinned the
        //                                          plugin to a stale
        //                                          version that lacked
        //                                          newer goals
        //   PARENT_COHERENCE                     — #324 release gate
        //                                          form
        PreflightResult releasePreflight = Preflight.of(
                List.of(PreflightCondition.WORKING_TREE_CLEAN,
                        PreflightCondition.NO_ON_DISK_GHPAGES_LEAK,
                        PreflightCondition.NO_SCPEXE_SITE_URLS,
                        PreflightCondition.NO_SNAPSHOT_PROPERTIES,
                        PreflightCondition.SUBPROJECT_HAS_DISTRIBUTION_MANAGEMENT,
                        PreflightCondition.NO_FOUNDATION_PROPERTY_SHADOWING,
                        PreflightCondition.PARENT_COHERENCE),
                PreflightContext.of(root, graph, candidates));
        if (draft) {
            releasePreflight.warnIfFailed(getLog(), WsGoal.RELEASE_PUBLISH);
        } else {
            releasePreflight.requirePassed(WsGoal.RELEASE_PUBLISH);
        }

        // ── 2a. Detect source-changed checked-out subprojects ────────────
        // First pass: gather the set of subprojects whose own commits
        // require a release. Cascade-only downstream is added in 2b.
        Map<String, ReleaseCandidate> releasable = new LinkedHashMap<>();
        Set<String> sourceChanged = new LinkedHashSet<>();
        for (String name : graph.topologicalSort()) {
            if (!candidates.contains(name)) continue;

            Subproject sub = graph.manifest().subprojects().get(name);
            if (sub == null) continue;

            File subDir = new File(root, name);
            if (!subDir.isDirectory() || !new File(subDir, "pom.xml").exists()) {
                getLog().debug("Skipping " + name + " — not checked out");
                continue;
            }

            String latestTag = latestReleaseTag(subDir);
            if (latestTag == null) {
                // No release tag exists — subproject has never been released
                releasable.put(name, new ReleaseCandidate(name, sub, subDir,
                        null, "never released"));
                sourceChanged.add(name);
                continue;
            }

            // #347: count only commits whose subjects don't match
            // the release-cadence pattern, so retries of a partial
            // cascade don't re-release subprojects whose only
            // post-tag commits are release/merge/post-release/site
            // bookkeeping from a previous successful attempt.
            int meaningfulCommits =
                    meaningfulCommitsSinceTag(subDir, latestTag);
            if (meaningfulCommits > 0) {
                releasable.put(name, new ReleaseCandidate(name, sub, subDir,
                        latestTag,
                        meaningfulCommits + " commits since " + latestTag));
                sourceChanged.add(name);
                continue;
            }

            getLog().debug("Skipping " + name + " — clean (at "
                    + latestTag + "; only cadence commits since)");
        }

        // ── 2b. Cascade — add transitive downstream of source-changed ───
        // Every checked-out subproject that depends (directly or
        // transitively) on a source-changed subproject must also release
        // so its parent/property references can pick up the new upstream
        // version. Catch-up never expands the release set: subprojects
        // with stale properties but no source change and no upstream in
        // this cycle stay out.
        Set<String> releaseSet = computeReleaseSet(graph, sourceChanged);
        for (String name : releaseSet) {
            if (releasable.containsKey(name)) continue;

            Subproject sub = graph.manifest().subprojects().get(name);
            if (sub == null) continue;

            File subDir = new File(root, name);
            if (!subDir.isDirectory() || !new File(subDir, "pom.xml").exists()) {
                getLog().info("  Skipping cascaded " + name
                        + " — not checked out (downstream version stays stale)");
                continue;
            }

            String latestTag = latestReleaseTag(subDir);
            String reason = "downstream of " + describeUpstreamCause(
                    name, graph, sourceChanged);
            releasable.put(name, new ReleaseCandidate(name, sub, subDir,
                    latestTag, reason));
        }

        if (releasable.isEmpty()) {
            getLog().info("No components need releasing. All are clean.");
            return new WorkspaceReportSpec(
                    publish ? WsGoal.RELEASE_PUBLISH : WsGoal.RELEASE_DRAFT,
                    "No components need releasing — all are clean.\n");
        }

        // ── 3. Topological sort of release-pending components ────────────
        List<String> releaseOrder = graph.topologicalSort().stream()
                .filter(releasable::containsKey)
                .toList();

        // ── 4. Report plan ────────────────────────────────────────────
        getLog().info("════════════════════════════════════════════════════");
        getLog().info(draft ? "  WORKSPACE RELEASE — DRAFT" : "  WORKSPACE RELEASE");
        getLog().info("════════════════════════════════════════════════════");
        getLog().info("");
        getLog().info("Components to release (" + releaseOrder.size() + "):");
        for (int i = 0; i < releaseOrder.size(); i++) {
            ReleaseCandidate rc = releasable.get(releaseOrder.get(i));
            String version = currentVersion(rc.dir);
            getLog().info("  " + (i + 1) + ". " + rc.name
                    + " (" + version + ") — " + rc.reason);
        }
        getLog().info("");

        // ── 4a. Compute release plan (single source of truth) ────────
        // One plan for the entire cascade, computed once up front. Every
        // pre-release alignment is a blind lookup in this plan — no
        // mid-flight heuristics. See dev-release-plan design topic.
        ReleasePlan plan;
        try {
            plan = buildReleasePlan(releaseOrder, releasable);
        } catch (IOException e) {
            throw new MojoException(
                    "Release plan compute failed: " + e.getMessage(), e);
        }
        logReleasePlan(plan);
        writeReleasePlan(root, plan);

        if (draft) {
            getLog().info("[DRAFT] No releases executed (draft mode).");
            return new WorkspaceReportSpec(WsGoal.RELEASE_DRAFT,
                    buildReleasePlanMarkdownReport(releaseOrder, releasable));
        }

        // ── 5. Pre-release checkpoint ─────────────────────────────────
        if (!skipCheckpoint) {
            String checkpointName = "pre-release-"
                    + Instant.now().atZone(ZoneOffset.UTC)
                            .format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
            getLog().info("Creating pre-release checkpoint: " + checkpointName);
            writeCheckpoint(root, graph, checkpointName);
        }

        // ── 6. Release each subproject in order ────────────────────────
        List<String> released = new ArrayList<>();
        Map<String, String> releasedVersions = new LinkedHashMap<>();

        // Capture (subproject name → post-release SNAPSHOT) so we can
        // sync workspace.yaml after the cascade completes (#371).
        Map<String, String> manifestVersionUpdates = new LinkedHashMap<>();
        for (String name : releaseOrder) {
            ReleaseCandidate rc = releasable.get(name);
            getLog().info("");
            getLog().info("────────────────────────────────────────────────");
            getLog().info("  Releasing: " + rc.name);
            getLog().info("────────────────────────────────────────────────");

            // Catch-up alignment: bump every workspace-internal upstream
            // version reference (this-cycle bumps + catch-up to current
            // published versions) into a single commit. Hard-stops the
            // release on failure (#192) — no silent stale POMs.
            updateParentVersions(plan, rc, releasedVersions, root);

            // Derive release version from current SNAPSHOT
            String currentVersion = currentVersion(rc.dir);
            String releaseVersion = currentVersion.replace("-SNAPSHOT", "");

            try {
                // Find mvnw or mvn
                String mvn = findMvn(rc.dir);

                ReleaseSupport.exec(rc.dir, getLog(),
                        releaseCommand(mvn));

                released.add(rc.name);
                releasedVersions.put(rc.name, releaseVersion);
                getLog().info(Ansi.green("  ✓ ") + "Released " + rc.name + " " + releaseVersion);

                // Capture the post-release SNAPSHOT for workspace.yaml
                // sync (#371). ike:release-publish ended on a post-
                // release bump, so reading the POM now yields the new
                // -SNAPSHOT. Tolerate read failures: the release
                // succeeded, so don't fail the cascade over a manifest
                // sync hiccup — the gap will surface as a #371 warning
                // on the next preflight.
                try {
                    String postReleaseVersion = currentVersion(rc.dir);
                    if (postReleaseVersion != null
                            && !postReleaseVersion.isBlank()) {
                        manifestVersionUpdates.put(rc.name, postReleaseVersion);
                    }
                } catch (Exception readFail) {
                    getLog().warn("  ⚠ Could not read post-release version "
                            + "for " + rc.name + " — workspace.yaml "
                            + "version: field will stay stale until the "
                            + "next ws:scaffold-publish. "
                            + readFail.getMessage());
                }
            } catch (Exception e) {
                getLog().error(Ansi.red("  ✗ ") + "Failed to release " + rc.name + ": " + e.getMessage());
                getLog().error("");
                getLog().error("Released so far: " + released);
                getLog().error("Failed at: " + rc.name);
                getLog().error("Remaining: " + releaseOrder.subList(
                        releaseOrder.indexOf(name) + 1, releaseOrder.size()));
                throw new MojoException(
                        "Workspace release failed at " + rc.name, e);
            }
        }

        // ── 6a. Sync workspace.yaml version: fields (#371) ────────────
        // Each ike:release-publish bumped its subproject's POM but had
        // no visibility into the workspace manifest. Now that the
        // cascade is complete, fold the new SNAPSHOTs back into
        // workspace.yaml in one commit so the manifest stops drifting.
        // Filter to changes only — idempotent re-run of an already-
        // synced workspace writes nothing.
        if (!manifestVersionUpdates.isEmpty()) {
            syncWorkspaceVersions(root, manifestVersionUpdates);
        }

        // ── 6b. Release the workspace root last (#326, #328) ─────────
        // After all subprojects release, the workspace.yaml has been
        // updated (per-subproject version: pin) by the post-release
        // bumps inside ike:release-publish, AND the workspace pom may
        // have been touched earlier in the cycle (parent bump from
        // ws:scaffold-publish's ParentVersionReconciler,
        // .mvn/maven.config from ws:ide-sync).
        // The workspace itself is therefore source-changed and should
        // tag + deploy + refresh its site so the published cycle has
        // a single anchor: "the workspace was at this commit when
        // these subprojects released v_n".
        //
        // Skipped when nothing released (released.isEmpty()) since
        // there's no cycle to anchor.
        if (!released.isEmpty() && hasUnreleasedWorkspaceChanges(root)) {
            getLog().info("");
            getLog().info("────────────────────────────────────────────────");
            getLog().info("  Releasing: workspace root");
            getLog().info("────────────────────────────────────────────────");
            String workspaceCurrent = currentVersion(root);
            String workspaceVersion = workspaceCurrent.replace("-SNAPSHOT", "");
            try {
                String mvn = findMvn(root);
                // -DnonRecursiveSite=true on the workspace root release:
                // the workspace pom is an aggregator and every subproject
                // inherits a per-artifactId <site> URL with no common
                // ancestor, so running site:stage with the full reactor
                // active causes sibling modules to overwrite each other
                // at the same target/staging/ root. The last-built
                // subproject wins and the workspace's own staged content
                // is lost — publishProjectSiteToGhPages then ships
                // whichever subproject's content was last to land.
                // -N restricts the workspace's site build to its own
                // pom only; subprojects already published their own
                // sites in step 6 above. ike-issues#356.
                ReleaseSupport.exec(root, getLog(),
                        releaseCommand(mvn, "-DnonRecursiveSite=true"));
                released.add("(workspace root)");
                releasedVersions.put("(workspace root)", workspaceVersion);
                getLog().info(Ansi.green("  ✓ ") + "Released workspace root "
                        + workspaceVersion);
            } catch (Exception e) {
                getLog().error(Ansi.red("  ✗ ") + "Failed to release "
                        + "workspace root: " + e.getMessage());
                getLog().error("");
                getLog().error("Subprojects released so far: " + released);
                throw new MojoException(
                        "Workspace root release failed", e);
            }
        }

        // ── 7. Summary ───────────────────────────────────────────────
        getLog().info("");
        getLog().info("════════════════════════════════════════════════════");
        getLog().info("  WORKSPACE RELEASE COMPLETE");
        getLog().info("════════════════════════════════════════════════════");
        for (var entry : releasedVersions.entrySet()) {
            getLog().info("  " + entry.getKey() + " → " + entry.getValue());
        }
        getLog().info("");

        // ── 8. GitHub Release (optional) ──────────────────────────────
        if (githubRepo != null && !githubRepo.isBlank()) {
            createGitHubReleases(root, releasedVersions);
        }

        // Structured markdown report
        return new WorkspaceReportSpec(
                publish ? WsGoal.RELEASE_PUBLISH : WsGoal.RELEASE_DRAFT,
                buildReleaseMarkdownReport(releasedVersions));
    }

    /**
     * Compute the release set: source-changed subprojects union the
     * transitive downstream cascade of each.
     *
     * <p>This is a pure function over the workspace graph and the set
     * of source-changed subprojects. Cascade is computed via
     * {@link WorkspaceGraph#cascade(String)} (BFS on reverse edges).
     * Order in the returned set follows the graph's topological sort.
     *
     * <p>By construction, the release set contains every member of
     * {@code sourceChanged} plus everything that depends on any of
     * them (directly or transitively). It never contains a subproject
     * that has neither a source change nor a release-set upstream —
     * stale properties alone cannot expand the release set (see #192).
     *
     * @param graph         the workspace dependency graph
     * @param sourceChanged subproject names whose own commits warrant
     *                      a release this cycle
     * @return release set in topological order (dependencies first)
     */
    public static Set<String> computeReleaseSet(WorkspaceGraph graph,
                                                Set<String> sourceChanged) {
        Set<String> set = new LinkedHashSet<>(sourceChanged);
        for (String name : sourceChanged) {
            if (!graph.manifest().subprojects().containsKey(name)) continue;
            set.addAll(graph.cascade(name));
        }
        // Reorder by topo sort so the result is deterministic and matches
        // the dependency order callers expect.
        Set<String> ordered = new LinkedHashSet<>();
        for (String name : graph.topologicalSort()) {
            if (set.contains(name)) ordered.add(name);
        }
        return ordered;
    }

    /**
     * Describe which source-changed subproject(s) caused a downstream
     * subproject to be cascaded into the release set. Used purely for
     * the human-readable "downstream of X" reason in the release plan.
     *
     * <p>If multiple source-changed subprojects are upstream, returns
     * the topologically-nearest set joined by {@code ", "}.
     */
    private static String describeUpstreamCause(String downstream,
                                                 WorkspaceGraph graph,
                                                 Set<String> sourceChanged) {
        // Walk the forward edges to find which source-changed subprojects
        // this one transitively depends on. Use BFS from downstream.
        List<String> causes = new ArrayList<>();
        Set<String> visited = new LinkedHashSet<>();
        java.util.Deque<String> queue = new java.util.ArrayDeque<>();
        queue.add(downstream);
        visited.add(downstream);
        while (!queue.isEmpty()) {
            String current = queue.poll();
            Subproject sub = graph.manifest().subprojects().get(current);
            if (sub == null) continue;
            for (network.ike.workspace.Dependency dep : sub.dependsOn()) {
                String up = dep.subproject();
                if (!visited.add(up)) continue;
                if (sourceChanged.contains(up)) {
                    causes.add(up);
                } else {
                    queue.add(up);
                }
            }
        }
        if (causes.isEmpty()) return "(unknown upstream)";
        return String.join(", ", causes);
    }

    /**
     * Create GitHub Releases for released components and attach
     * platform installers. Uses {@code gh} CLI. Each subproject gets
     * a release tagged {@code v<version>}. If the release already
     * exists, uploads are appended with {@code --clobber}.
     */
    private void createGitHubReleases(File root,
                                        Map<String, String> releasedVersions)
            throws MojoException {
        for (var entry : releasedVersions.entrySet()) {
            String name = entry.getKey();
            String version = entry.getValue();
            String tag = "v" + version;
            File subDir = new File(root, name);

            // Collect installer artifacts
            java.nio.file.Path targetDir = subDir.toPath().resolve("target");
            List<String> artifacts = new ArrayList<>();
            if (java.nio.file.Files.exists(targetDir)) {
                try {
                    java.nio.file.PathMatcher matcher =
                            targetDir.getFileSystem().getPathMatcher(
                                    "glob:" + installerGlob);
                    try (var walk = java.nio.file.Files.walk(targetDir, 3)) {
                        walk.filter(java.nio.file.Files::isRegularFile)
                            .filter(p -> matcher.matches(
                                    targetDir.relativize(p)))
                            .forEach(p -> artifacts.add(p.toString()));
                    }
                } catch (java.io.IOException e) {
                    getLog().debug("Could not scan installers for " + name
                            + ": " + e.getMessage());
                }
            }

            getLog().info("  Creating GitHub Release: " + tag
                    + (artifacts.isEmpty() ? ""
                        : " (" + artifacts.size() + " installer"
                          + (artifacts.size() == 1 ? "" : "s") + ")"));

            try {
                // Try create first; fall back to upload if release exists
                List<String> cmd = new ArrayList<>(List.of(
                        "gh", "release", "create", tag,
                        "--repo", githubRepo,
                        "--title", name + " " + version,
                        "--generate-notes"));
                cmd.addAll(artifacts);

                ReleaseSupport.exec(subDir, getLog(),
                        cmd.toArray(String[]::new));
            } catch (MojoException e) {
                // Release may already exist — append assets
                if (!artifacts.isEmpty()) {
                    try {
                        List<String> uploadCmd = new ArrayList<>(List.of(
                                "gh", "release", "upload", tag,
                                "--repo", githubRepo, "--clobber"));
                        uploadCmd.addAll(artifacts);
                        ReleaseSupport.exec(subDir, getLog(),
                                uploadCmd.toArray(String[]::new));
                    } catch (MojoException uploadErr) {
                        getLog().warn("  Could not upload to release " + tag
                                + ": " + uploadErr.getMessage());
                    }
                } else {
                    getLog().warn("  GitHub Release creation failed for "
                            + tag + ": " + e.getMessage());
                }
            }
        }
    }

    /**
     * Build the draft-mode report: the planned release order and the
     * reason each subproject is included. No subproject is released in
     * draft mode, so this records intent rather than outcomes.
     *
     * @param releaseOrder topologically sorted names of the release plan
     * @param releasable   release candidates keyed by subproject name
     * @return the Markdown report body
     */
    private String buildReleasePlanMarkdownReport(
            List<String> releaseOrder,
            Map<String, ReleaseCandidate> releasable) {
        List<String[]> rows = new ArrayList<>();
        for (String name : releaseOrder) {
            ReleaseCandidate rc = releasable.get(name);
            rows.add(new String[] {
                    rc.name, currentVersion(rc.dir), rc.reason });
        }
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph(releaseOrder.size()
                        + " subproject(s) would be released (draft).")
                .table(List.of("Subproject", "Version", "Reason"), rows);
        return report.build();
    }

    private String buildReleaseMarkdownReport(
            Map<String, String> releasedVersions) {
        List<String[]> rows = new ArrayList<>();
        for (var entry : releasedVersions.entrySet()) {
            rows.add(new String[] {
                    entry.getKey(), entry.getValue(), "✓" });
        }
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph(releasedVersions.size()
                        + " subproject(s) released.")
                .table(List.of("Subproject", "Version", "Status"), rows);
        return report.build();
    }

    // ── Helper: find latest release tag ──────────────────────────────

    private String latestReleaseTag(File subDir) {
        try {
            String tags = ReleaseSupport.execCapture(subDir,
                    "git", "tag", "-l", "v*", "--sort=-version:refname");
            if (tags == null || tags.isBlank()) return null;
            return tags.lines().findFirst().orElse(null);
        } catch (Exception e) {
            return null;
        }
    }

    // ── Helper: count commits since tag ──────────────────────────────

    private int commitsSinceTag(File subDir, String tag) {
        try {
            String count = ReleaseSupport.execCapture(subDir,
                    "git", "rev-list", tag + "..HEAD", "--count");
            return Integer.parseInt(count.strip());
        } catch (Exception e) {
            return -1;
        }
    }

    // ── Helper: count meaningful (non-release-cadence) commits ────────
    //
    // ike-issues#347: when a partial cascade fails after some
    // subprojects have already released, retries kept finding "new"
    // commits since each subproject's tag — the post-release-bump
    // commit, the merge commit, and the release-set-version commit
    // produced by the prior attempt's ike:release-publish. Each retry
    // saw commitsSinceTag > 0 and re-released, ratcheting subprojects
    // forward by one version per retry.
    //
    // Filter out commits whose subject matches the well-known
    // release-cadence patterns produced by ReleaseSupport:
    //   - "release: set version to N"
    //   - "release: restore ${project.version} references"
    //   - "merge: release N"
    //   - "post-release: bump to <next>-SNAPSHOT"
    //   - "site: publish <project> N"
    //
    // If every commit since the tag matches one of those patterns,
    // the subproject has no real source changes — return 0 so the
    // outer logic treats it as already-released.

    private static final java.util.regex.Pattern RELEASE_CADENCE_PATTERN =
            java.util.regex.Pattern.compile(
                    "^(release: set version to .+"
                            + "|release: restore .+"
                            + "|merge: release .+"
                            + "|post-release: bump to .+"
                            + "|site: publish .+)$");

    /**
     * Count commits between {@code tag} and HEAD whose subjects do
     * NOT match a release-cadence pattern.
     *
     * <p>Used in place of {@link #commitsSinceTag(File, String)} for
     * "do we need to release this again?" decisions, so that retries
     * after a partial cascade failure don't re-release subprojects
     * whose only post-tag commits are cadence-emitted ones.
     *
     * @param subDir the subproject directory
     * @param tag    the latest release tag
     * @return number of non-cadence commits since {@code tag}, or
     *         {@code -1} on error
     */
    int meaningfulCommitsSinceTag(File subDir, String tag) {
        try {
            String log = ReleaseSupport.execCapture(subDir,
                    "git", "log", tag + "..HEAD",
                    "--pretty=format:%s", "--no-merges");
            if (log == null) return 0;
            String trimmed = log.strip();
            if (trimmed.isEmpty()) return 0;
            int count = 0;
            for (String subject : trimmed.split("\n")) {
                if (!RELEASE_CADENCE_PATTERN.matcher(subject.strip()).matches()) {
                    count++;
                }
            }
            return count;
        } catch (Exception e) {
            return -1;
        }
    }

    /**
     * Test whether a commit subject matches a release-cadence
     * pattern (would be filtered out by
     * {@link #meaningfulCommitsSinceTag}). Public for unit testing.
     *
     * @param subject the commit subject line
     * @return {@code true} when the subject is cadence-emitted
     */
    public static boolean isReleaseCadenceCommit(String subject) {
        if (subject == null) return false;
        return RELEASE_CADENCE_PATTERN.matcher(subject.strip()).matches();
    }

    // ── Pre-release upstream alignment (#377) ─────────────────────────
    // Foundation-tracking map used by preReleaseUpstreamAlignment.
    // groupId → foundation name in ~/ike-dev/<name>/. Each foundation
    // releases as a single Maven reactor whose tip v* tag is the
    // version we'll align downstream consumers to.
    static final Map<String, String> FOUNDATION_GROUP_TO_DIR = Map.of(
            "network.ike.tooling", "ike-tooling",
            "network.ike.docs", "ike-docs",
            "network.ike.platform", "ike-platform");

    // Property-name → groupId. Properties shaped <X.version> conventionally
    // pin coordinates whose groupId starts with "network.ike.X" (with
    // ike-platform handling both "platform" and "parent" because
    // ike-parent ships from the ike-platform reactor).
    static final Map<String, String> PROPERTY_TO_GROUP = Map.of(
            "ike-tooling.version", "network.ike.tooling",
            "ike-docs.version", "network.ike.docs",
            "ike-platform.version", "network.ike.platform");

    /**
     * Before release detection runs, walk each subproject (and the
     * workspace root) and bump any stale upstream-foundation references
     * to the latest released version. An upstream is "stale" when the
     * pom declares a {@code <parent>} or {@code <X.version>} property
     * pinned older than the foundation's tip {@code v*} tag in the
     * canonical {@code ~/ike-dev/<foundation>/} layout. Bumps land as
     * "chore: align upstream versions before release" commits per
     * subproject — those commits register as meaningful, so the
     * {@link #meaningfulCommitsSinceTag} detector includes the
     * subproject in the release set automatically.
     *
     * <p>This is what makes "ike-parent was released, so absorb it"
     * a release-worthy change. Without it, ws:release-publish only
     * saw per-subproject source edits and missed transitive-dependency
     * upgrades entirely (ike-issues#377).
     *
     * <p>Non-fatal: failures (unreadable poms, git commit errors)
     * log a warning and continue. Worst case the alignment doesn't
     * commit and the release subsequently treats the subproject as
     * "no meaningful commits" — same outcome as before this method
     * existed, no regression.
     *
     * @param graph the loaded workspace graph
     * @param root  the workspace root directory
     */
    private void preReleaseUpstreamAlignment(WorkspaceGraph graph, File root) {
        File foundationsDir = root.getParentFile();
        if (foundationsDir == null || !foundationsDir.isDirectory()) {
            getLog().debug("  No siblings directory available for foundation lookup; "
                    + "skipping pre-release alignment.");
            return;
        }

        // Build groupId → latest released version once.
        Map<String, String> groupIdToLatest = new LinkedHashMap<>();
        for (var entry : FOUNDATION_GROUP_TO_DIR.entrySet()) {
            File siblingDir = new File(foundationsDir, entry.getValue());
            if (!siblingDir.isDirectory()) continue;
            String tag = latestReleaseTag(siblingDir);
            if (tag == null) continue;
            String version = tag.startsWith("v") ? tag.substring(1) : tag;
            groupIdToLatest.put(entry.getKey(), version);
        }
        if (groupIdToLatest.isEmpty()) {
            getLog().debug("  No foundation tags found in " + foundationsDir
                    + "; skipping pre-release alignment.");
            return;
        }

        // Walk: workspace root + each subproject.
        List<File> poms = new ArrayList<>();
        poms.add(new File(root, "pom.xml"));
        for (String name : graph.manifest().subprojects().keySet()) {
            File sub = new File(new File(root, name), "pom.xml");
            if (sub.isFile()) poms.add(sub);
        }

        int aligned = 0;
        int leaksCleaned = 0;
        for (File pom : poms) {
            if (alignPom(pom, groupIdToLatest)) aligned++;
            if (cleanGhPagesLeak(pom)) leaksCleaned++;
        }
        if (aligned > 0) {
            getLog().info("  Pre-release alignment: bumped upstream references in "
                    + aligned + " pom(s) (#377).");
        }
        if (leaksCleaned > 0) {
            getLog().info("  Pre-release alignment: removed gh-pages leak from "
                    + leaksCleaned + " pom dir(s) (#358).");
        }
    }

    /**
     * Auto-clean any on-disk gh-pages leak directory under the given
     * pom's project directory. The leak signature is exactly the one
     * {@code PreflightCondition.NO_ON_DISK_GHPAGES_LEAK} detects:
     * {@code <pomDir>/<artifactId>/<artifactId>/index.html} — produced
     * by maven-site-plugin's site:stage when {@code ike-parent}'s
     * site URL inheritance was broken (ike-issues#358; root cause
     * fixed in ike-parent v45+). The directory always escapes
     * {@code target/} and is gitignored, so {@code git status} doesn't
     * see it — operators discover it only when the preflight blocks
     * their release.
     *
     * <p>This pre-release step cleans the leak BEFORE the preflight
     * runs, so a workspace inheriting a still-stale ike-parent (and
     * therefore still producing leaks) can bootstrap to a newer
     * ike-parent in the same cascade without the operator having to
     * {@code rm -rf} by hand. After that one bootstrap cascade,
     * future builds don't leak.
     *
     * <p>Per {@code feedback_workspace_ops_completion}: recoverable
     * side effects default on.
     *
     * @param pomFile the pom whose project directory to inspect
     * @return {@code true} when a leak was cleaned, {@code false} otherwise
     */
    private boolean cleanGhPagesLeak(File pomFile) {
        File pomDir = pomFile.getParentFile();
        if (pomDir == null) return false;
        String artifactId;
        try {
            String content = Files.readString(pomFile.toPath(),
                    StandardCharsets.UTF_8);
            artifactId = extractArtifactId(content);
        } catch (IOException e) {
            return false;
        }
        if (artifactId == null || artifactId.isBlank()) return false;
        java.nio.file.Path leakDir = pomDir.toPath()
                .resolve(artifactId).resolve(artifactId);
        java.nio.file.Path leakIndex = leakDir.resolve("index.html");
        if (!Files.isRegularFile(leakIndex)) return false;
        // The OUTER doubled dir is the one to remove; .resolve(artifactId)
        // once gives us <pomDir>/<artifactId>/ which is what
        // NO_ON_DISK_GHPAGES_LEAK's report prints as the rm-rf path.
        java.nio.file.Path outerLeak = pomDir.toPath().resolve(artifactId);
        try {
            deleteRecursively(outerLeak);
            getLog().info("    Cleaned gh-pages leak: " + pomDir.getName()
                    + "/" + artifactId + "/ (#358)");
            return true;
        } catch (IOException e) {
            getLog().warn("    Could not clean leak dir " + outerLeak
                    + ": " + e.getMessage());
            return false;
        }
    }

    /**
     * Extract the project's own {@code <artifactId>}. Skips a
     * preceding {@code <parent>} block so we don't return the parent's
     * artifactId. Same shape as the helper in
     * {@code RegisterSiteDraftMojo} — repeated here to keep the
     * dependency direction (this mojo doesn't depend on it).
     */
    private static String extractArtifactId(String pomContent) {
        if (pomContent == null) return null;
        int searchFrom = 0;
        int parentOpen = pomContent.indexOf("<parent>");
        if (parentOpen >= 0) {
            int parentClose = pomContent.indexOf("</parent>", parentOpen);
            if (parentClose > parentOpen) {
                searchFrom = parentClose + "</parent>".length();
            }
        }
        int open = pomContent.indexOf("<artifactId>", searchFrom);
        if (open < 0) return null;
        int valueStart = open + "<artifactId>".length();
        int close = pomContent.indexOf("</artifactId>", valueStart);
        if (close < 0) return null;
        return pomContent.substring(valueStart, close).trim();
    }

    /**
     * Delete a directory tree recursively. Mirrors common helpers in
     * the codebase but inlined to avoid coupling to a specific util.
     */
    private static void deleteRecursively(java.nio.file.Path path) throws IOException {
        if (!Files.exists(path)) return;
        try (var stream = Files.walk(path)) {
            stream.sorted(java.util.Comparator.reverseOrder())
                    .forEach(p -> {
                        try { Files.delete(p); }
                        catch (IOException ignore) { /* best effort */ }
                    });
        }
    }

    /**
     * Apply alignment to one pom. Returns {@code true} when the pom
     * was changed + committed; {@code false} when no change was
     * needed (idempotent re-run).
     *
     * @param pomFile         the pom to align
     * @param groupIdToLatest groupId → latest released version
     * @return whether the pom was bumped
     */
    private boolean alignPom(File pomFile, Map<String, String> groupIdToLatest) {
        String content;
        try {
            content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            getLog().warn("  Could not read " + pomFile + " for alignment: "
                    + e.getMessage());
            return false;
        }
        String original = content;
        List<String> bumps = new ArrayList<>();

        // Align <parent> block.
        try {
            PomParentSupport.ParentInfo parent =
                    PomParentSupport.readParent(pomFile.toPath());
            if (parent != null) {
                String target = groupIdToLatest.get(parent.groupId());
                if (target != null && !target.equals(parent.version())) {
                    content = PomParentSupport.updateParentVersion(content,
                            parent.groupId(), parent.artifactId(), target);
                    bumps.add("<parent>" + parent.groupId() + ":"
                            + parent.artifactId() + ">: "
                            + parent.version() + " → " + target);
                }
            }
        } catch (IOException e) {
            getLog().warn("  Could not read parent block of " + pomFile
                    + ": " + e.getMessage());
        }

        // Align <X.version> properties.
        for (var entry : PROPERTY_TO_GROUP.entrySet()) {
            String propertyName = entry.getKey();
            String groupId = entry.getValue();
            String target = groupIdToLatest.get(groupId);
            if (target == null) continue;
            String current = extractPropertyValue(content, propertyName);
            if (current == null) continue;
            if (target.equals(current)) continue;
            content = PomRewriter.updateProperty(content, propertyName, target);
            bumps.add("<" + propertyName + ">: " + current + " → " + target);
        }

        if (content.equals(original)) {
            return false;
        }

        try {
            Files.writeString(pomFile.toPath(), content,
                    StandardCharsets.UTF_8);
        } catch (IOException e) {
            getLog().warn("  Could not write aligned " + pomFile
                    + ": " + e.getMessage());
            return false;
        }

        File pomDir = pomFile.getParentFile();
        if (!new File(pomDir, ".git").isDirectory()) {
            // No git repo here — leave the worktree edit for ws:commit
            // (or a sibling tool) to pick up later. Same fail-soft
            // pattern as #371 manifest sync.
            getLog().info("  Pre-release alignment: " + pomDir.getName()
                    + " (no .git — bumped on disk, not committed):");
            for (String b : bumps) getLog().info("    " + b);
            return false;
        }
        try {
            ReleaseSupport.exec(pomDir, getLog(), "git", "add", "pom.xml");
            ReleaseSupport.exec(pomDir, getLog(), "git", "commit", "-m",
                    "chore: align upstream versions before release");
        } catch (Exception e) {
            getLog().warn("  Pre-release alignment commit failed for "
                    + pomDir.getName() + ": " + e.getMessage());
            return false;
        }
        getLog().info("  Pre-release alignment: " + pomDir.getName());
        for (String b : bumps) getLog().info("    " + b);
        return true;
    }

    /**
     * Pure-string extract of a {@code <properties>}-block value by
     * name. Returns {@code null} when absent.
     */
    static String extractPropertyValue(String pomContent,
                                                 String propertyName) {
        if (pomContent == null) return null;
        String openTag = "<" + propertyName + ">";
        int open = pomContent.indexOf(openTag);
        if (open < 0) return null;
        int valueStart = open + openTag.length();
        int close = pomContent.indexOf("</" + propertyName + ">",
                valueStart);
        if (close < 0) return null;
        return pomContent.substring(valueStart, close).trim();
    }

    // ── Helper: workspace root has unreleased changes? ───────────────
    // ike-issues#328: the workspace itself participates in the
    // release set when source-changed. Returns true when the
    // workspace has never been tagged or has commits since its last
    // release tag.

    private boolean hasUnreleasedWorkspaceChanges(File root) {
        // Treat the workspace as a git repo only if .git is present.
        // Some workspace setups (e.g., a Syncthing-only checkout
        // without a per-machine git init) won't have one and there's
        // nothing to release.
        if (!new File(root, ".git").exists()) {
            return false;
        }
        String latestTag = latestReleaseTag(root);
        if (latestTag == null) {
            // Never released — the first cycle that touches the
            // workspace anchors it.
            return true;
        }
        // #347: filter out cadence commits so a previous successful
        // workspace release isn't seen as "still needs releasing"
        // on a retry triggered by a downstream subproject failure.
        return meaningfulCommitsSinceTag(root, latestTag) > 0;
    }

    /**
     * Write the post-cascade {@code version:} updates into
     * {@code workspace.yaml} and commit. Filters out no-op entries
     * (where the manifest already matches the new SNAPSHOT) so an
     * idempotent re-run writes nothing — important because
     * {@code WORKING_TREE_CLEAN} on the workspace root would otherwise
     * fail on a re-run that "succeeded" but left a manifest dirty
     * with no actual changes.
     *
     * <p>Failures are logged but do not abort the cascade: the
     * subproject release tags + Nexus deploys have already shipped,
     * so a manifest-sync hiccup is recoverable via
     * {@code ws:scaffold-publish} or the next release cycle.
     * ike-issues#371.
     *
     * @param root              workspace root
     * @param versionUpdates    subprojectName → new SNAPSHOT (full
     *                          post-release pom version)
     */
    private void syncWorkspaceVersions(File root,
                                        Map<String, String> versionUpdates) {
        Path manifestPath = root.toPath().resolve("workspace.yaml");
        if (!Files.isRegularFile(manifestPath)) {
            getLog().debug("  No workspace.yaml at " + manifestPath
                    + " — skipping manifest sync (#371)");
            return;
        }

        String before;
        try {
            before = Files.readString(manifestPath, StandardCharsets.UTF_8);
        } catch (IOException e) {
            getLog().warn("  ⚠ Could not read workspace.yaml for "
                    + "manifest sync (#371): " + e.getMessage());
            return;
        }

        String after = before;
        for (Map.Entry<String, String> entry : versionUpdates.entrySet()) {
            after = ManifestWriter.updateSubprojectField(
                    after, entry.getKey(), "version", entry.getValue());
        }

        if (after.equals(before)) {
            getLog().debug("  workspace.yaml already in sync — "
                    + "no manifest write needed (#371)");
            return;
        }

        try {
            Files.writeString(manifestPath, after, StandardCharsets.UTF_8);
        } catch (IOException e) {
            getLog().warn("  ⚠ Could not write workspace.yaml for "
                    + "manifest sync (#371): " + e.getMessage());
            return;
        }

        getLog().info("");
        getLog().info("  Synced workspace.yaml version: fields for "
                + versionUpdates.size() + " subproject(s) (#371)");

        // Stage and commit on the workspace root only if it's a git
        // repo. If staging or commit fails, the file write already
        // happened — leave it for ws:commit to pick up rather than
        // wedging the cascade.
        if (!new File(root, ".git").exists()) {
            return;
        }
        try {
            ReleaseSupport.exec(root, getLog(),
                    "git", "add", "workspace.yaml");
            ReleaseSupport.exec(root, getLog(),
                    "git", "commit", "-m",
                    "post-release: sync workspace.yaml versions (#371)");
        } catch (Exception e) {
            getLog().warn("  ⚠ workspace.yaml updated on disk but "
                    + "could not commit (#371): " + e.getMessage()
                    + ". Pick it up with ws:commit.");
        }
    }

    // ── Helper: read current POM version ─────────────────────────────

    private String currentVersion(File subDir) {
        try {
            Path pom = subDir.toPath().resolve("pom.xml");
            String content = Files.readString(pom, StandardCharsets.UTF_8);
            return extractVersionFromPom(content);
        } catch (Exception e) {
            return "unknown";
        }
    }

    /**
     * Extract the project's own {@code <version>} value from POM XML
     * content.
     *
     * <p>Strips any {@code <parent>...</parent>} block before scanning
     * so we don't accidentally return the inherited parent's version
     * for projects that declare a parent (like the workspace root pom
     * inheriting {@code ike-parent}). Then takes the first remaining
     * {@code <version>}, which is the project's own.
     *
     * @param pomContent raw POM XML as a string
     * @return the version string, or {@code "unknown"} if not found
     */
    public static String extractVersionFromPom(String pomContent) {
        if (pomContent == null || pomContent.isBlank()) return "unknown";
        // Strip <parent>...</parent> so its <version> doesn't match.
        String stripped = pomContent.replaceAll(
                "(?s)<parent>.*?</parent>", "");
        var matcher = java.util.regex.Pattern.compile(
                "<version>([^<]+)</version>").matcher(stripped);
        if (matcher.find()) return matcher.group(1);
        return "unknown";
    }

    // ── Helper: catch-up alignment for a single subproject ──────────

    /**
     * Catch-up alignment for a single subproject, driven by the
     * pre-computed {@link ReleasePlan}.
     *
     * <p>Two passes:
     * <ol>
     *   <li><b>Plan-driven, in-cascade:</b> walk the plan's artifact
     *       and property entries; apply updates to any POM under
     *       {@code rc.dir}. Property names, target values, and POM paths
     *       are all pre-computed — no heuristics, no reinterpretation.
     *       Covers child-override properties and parent references in
     *       submodules that the old manifest-only logic missed.</li>
     *   <li><b>Out-of-cascade catch-up:</b> for each manifest dependency
     *       whose upstream is <em>not</em> in the cascade, align the
     *       root POM's parent ref and manifest-declared property to the
     *       upstream's current on-disk version. This rescues stale
     *       properties without expanding the release set.</li>
     * </ol>
     *
     * <p>All bumps for a single subproject batch into one git commit.
     * If any POM rewrite, write, or git command fails, this method
     * throws {@link MojoException} so the release loop halts — silent
     * partial alignment is never acceptable (#192).
     *
     * @param plan             the pre-computed release plan
     * @param rc               the subproject being prepared for release
     * @param releasedVersions this-cycle release map (for catch-up logging)
     * @param root             workspace root (for reading upstream POMs
     *                         that aren't in the plan)
     * @throws MojoException if POM I/O, git add, or git commit fails
     */
    private void updateParentVersions(ReleasePlan plan,
                                       ReleaseCandidate rc,
                                       Map<String, String> releasedVersions,
                                       File root) throws MojoException {
        Path rcDir = rc.dir.toPath().toAbsolutePath().normalize();
        Map<Path, String> pomContent = new LinkedHashMap<>();

        try {
            // ── 1. Plan-driven: in-cascade property updates ─────────
            for (PropertyReleasePlan pp : plan.properties()) {
                Path decl = pp.declaringPomPath();
                if (!decl.startsWith(rcDir)) continue;
                String before = pomContent.computeIfAbsent(decl, this::readPomContent);
                String after = PomRewriter.updateProperty(
                        before, pp.propertyName(), pp.releaseValue());
                if (!after.equals(before)) {
                    pomContent.put(decl, after);
                    getLog().info("    " + rc.name + " ("
                            + rcDir.relativize(decl) + "): "
                            + pp.propertyName() + " → " + pp.releaseValue()
                            + " (plan)");
                }
            }

            // ── 2. Plan-driven: in-cascade parent updates ───────────
            for (ArtifactReleasePlan ap : plan.artifacts().values()) {
                for (ReferenceSite site : ap.referenceSites()) {
                    if (site.kind() != ReferenceKind.PARENT) continue;
                    Path pomPath = site.pomPath();
                    if (!pomPath.startsWith(rcDir)) continue;
                    String before = pomContent.computeIfAbsent(pomPath, this::readPomContent);
                    String after = PomRewriter.updateParentVersion(
                            before, ap.ga().groupId(), ap.ga().artifactId(),
                            ap.releaseValue());
                    if (!after.equals(before)) {
                        pomContent.put(pomPath, after);
                        getLog().info("    " + rc.name + " ("
                                + rcDir.relativize(pomPath) + "): "
                                + "parent " + ap.ga().artifactId()
                                + " → " + ap.releaseValue() + " (plan)");
                    }
                }
            }

            // ── 3. Out-of-cascade catch-up ──────────────────────────
            // Read the RC's root <parent> block once so the catch-up
            // match can require BOTH groupId and artifactId (#241).
            Set<String> inPlan = plan.artifacts().values().stream()
                    .map(ArtifactReleasePlan::producingSubproject)
                    .collect(Collectors.toSet());
            Path rootPom = rcDir.resolve("pom.xml");
            PomParentSupport.ParentInfo rootPomParent;
            try {
                rootPomParent = PomParentSupport.readParent(rootPom);
            } catch (IOException e) {
                getLog().warn("    " + rc.name + ": cannot read root"
                        + " <parent> for catch-up — " + e.getMessage());
                rootPomParent = null;
            }
            for (network.ike.workspace.Dependency dep : rc.subproject.dependsOn()) {
                if (inPlan.contains(dep.subproject())) continue;
                String target = upstreamTargetVersion(
                        dep.subproject(), releasedVersions, root);
                if (target == null) {
                    getLog().debug("    " + rc.name + ": no target for "
                            + dep.subproject() + " (not in plan, not on disk)");
                    continue;
                }
                String before = pomContent.computeIfAbsent(rootPom, this::readPomContent);
                // Only update the root <parent> when the dep's
                // subproject name matches the root parent's artifactId.
                // The full GA is then used for the rewrite so unrelated
                // groupIds with the same artifactId aren't touched (#241).
                if (rootPomParent != null
                        && dep.subproject().equals(rootPomParent.artifactId())) {
                    String after = PomRewriter.updateParentVersion(
                            before, rootPomParent.groupId(),
                            rootPomParent.artifactId(), target);
                    if (!after.equals(before)) {
                        pomContent.put(rootPom, after);
                        getLog().info("    " + rc.name + ": parent "
                                + rootPomParent.groupId() + ":"
                                + rootPomParent.artifactId() + " → "
                                + target + " (out-of-cascade catch-up)");
                        before = after;
                    }
                }
                if (dep.versionProperty() != null) {
                    String after = PomRewriter.updateProperty(
                            before, dep.versionProperty(), target);
                    if (!after.equals(before)) {
                        pomContent.put(rootPom, after);
                        getLog().info("    " + rc.name + ": "
                                + dep.versionProperty() + " → " + target
                                + " (out-of-cascade catch-up)");
                    }
                }
            }
        } catch (UncheckedIOException e) {
            throw new MojoException(
                    "Catch-up alignment for " + rc.name
                            + " failed reading POM: " + e.getMessage(),
                    e.getCause());
        }

        // ── 4. Write modified POMs ──────────────────────────────────
        List<Path> changedPoms = new ArrayList<>();
        for (Map.Entry<Path, String> entry : pomContent.entrySet()) {
            Path path = entry.getKey();
            String content = entry.getValue();
            String original;
            try {
                original = Files.readString(path, StandardCharsets.UTF_8);
            } catch (IOException e) {
                throw new MojoException(
                        "Catch-up alignment for " + rc.name
                                + " failed: cannot read " + path + ": "
                                + e.getMessage(), e);
            }
            if (!content.equals(original)) {
                try {
                    Files.writeString(path, content, StandardCharsets.UTF_8);
                } catch (IOException e) {
                    throw new MojoException(
                            "Catch-up alignment for " + rc.name
                                    + " failed: cannot write " + path
                                    + ": " + e.getMessage(), e);
                }
                changedPoms.add(path);
            }
        }
        if (!changedPoms.isEmpty()) {
            getLog().info("  Updated " + changedPoms.size()
                    + " POM(s) in " + rc.name);
        }

        if (changedPoms.isEmpty()) return;

        // ── 5. Stage + commit in one batch ──────────────────────────
        try {
            for (Path p : changedPoms) {
                ReleaseSupport.exec(rc.dir, getLog(),
                        "git", "add", rcDir.relativize(p).toString());
            }
            ReleaseSupport.exec(rc.dir, getLog(),
                    "git", "commit", "-m",
                    "chore: align upstream versions before release");
        } catch (MojoException e) {
            throw new MojoException(
                    "Catch-up alignment for " + rc.name
                            + " failed at git commit: " + e.getMessage(), e);
        }
    }

    /**
     * Read POM content as UTF-8. Used inside
     * {@link Map#computeIfAbsent}; wraps {@link IOException} as
     * {@link UncheckedIOException} which the caller re-throws as
     * {@link MojoException}.
     */
    private String readPomContent(Path path) {
        try {
            return Files.readString(path, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    /**
     * Build the full cascade's release plan from the release order and
     * the set of releasable candidates. Each subproject becomes an
     * {@link ArtifactReleaseIntent} whose pre/release/post values are
     * derived from the subproject's current {@code <version>} (must end
     * in {@code -SNAPSHOT}).
     *
     * <p>The reactor scan walks each released subproject's root POM to
     * collect every property declaration and reference site across the
     * cascade. Out-of-cascade subprojects do not participate; their
     * POMs are not scanned and their properties do not appear in the
     * plan.
     *
     * @param releaseOrder the topologically-sorted subproject names
     * @param releasable   the candidates indexed by name
     * @return the immutable release plan
     * @throws IOException if any POM cannot be read
     */
    private ReleasePlan buildReleasePlan(
            List<String> releaseOrder,
            Map<String, ReleaseCandidate> releasable) throws IOException {
        List<ArtifactReleaseIntent> intents = new ArrayList<>();
        List<SubprojectRoot> subprojectRoots = new ArrayList<>();
        List<Path> reactorRoots = new ArrayList<>();

        for (String name : releaseOrder) {
            ReleaseCandidate rc = releasable.get(name);
            Path rootPom = rc.dir.toPath().resolve("pom.xml")
                    .toAbsolutePath().normalize();
            PomModel pom = PomModel.parse(rootPom);

            String groupId = pom.groupId();
            String artifactId = pom.artifactId();
            String preReleaseValue = pom.version();
            if (preReleaseValue == null
                    || !preReleaseValue.endsWith("-SNAPSHOT")) {
                throw new IOException(
                        "Subproject " + name + " (" + rootPom + ") version "
                                + "must end in -SNAPSHOT; got "
                                + preReleaseValue);
            }
            String releaseValue = preReleaseValue.substring(
                    0, preReleaseValue.length() - "-SNAPSHOT".length());
            String postReleaseValue = nextSnapshotVersion(releaseValue);

            intents.add(new ArtifactReleaseIntent(
                    new GA(groupId, artifactId),
                    name,
                    rootPom,
                    preReleaseValue,
                    releaseValue,
                    postReleaseValue));
            subprojectRoots.add(new SubprojectRoot(name, rootPom));
            reactorRoots.add(rootPom);
        }

        ReactorWalker.ReactorScan scan = ReactorWalker.walkAll(reactorRoots);
        return ReleasePlanCompute.compute(scan, subprojectRoots, intents);
    }

    /**
     * Derive the post-release next-snapshot from a release value by
     * incrementing the trailing numeric segment. Matches the IKE
     * single-segment convention: {@code 110} → {@code 111-SNAPSHOT}.
     * If the release value has no trailing digits, appends
     * {@code .1-SNAPSHOT}.
     *
     * @param releaseValue release version (must not end in -SNAPSHOT)
     * @return next-snapshot version
     */
    static String nextSnapshotVersion(String releaseValue) {
        int i = releaseValue.length();
        while (i > 0 && Character.isDigit(releaseValue.charAt(i - 1))) i--;
        if (i == releaseValue.length()) {
            return releaseValue + ".1-SNAPSHOT";
        }
        String prefix = releaseValue.substring(0, i);
        long n = Long.parseLong(releaseValue.substring(i));
        return prefix + (n + 1) + "-SNAPSHOT";
    }

    /**
     * Log the release plan at INFO: one line per artifact and per
     * property. This is the pre-mutation audit view; the same data is
     * also persisted to {@code plan.yaml} at the workspace root for
     * later inspection (#212).
     */
    private void logReleasePlan(ReleasePlan plan) {
        getLog().info("Release plan:");
        for (ArtifactReleasePlan ap : plan.artifacts().values()) {
            getLog().info("  artifact " + ap.ga() + ": "
                    + ap.preReleaseValue() + " → "
                    + ap.releaseValue() + " → "
                    + ap.postReleaseValue()
                    + " (" + ap.referenceSites().size() + " reference"
                    + (ap.referenceSites().size() == 1 ? "" : "s") + ")");
        }
        for (PropertyReleasePlan pp : plan.properties()) {
            getLog().info("  property " + pp.propertyName()
                    + " in " + pp.declaringSubproject() + ": "
                    + pp.preReleaseValue() + " → " + pp.releaseValue()
                    + " (" + pp.referenceSites().size() + " reference"
                    + (pp.referenceSites().size() == 1 ? "" : "s") + ")");
        }
    }

    /**
     * Persist the release plan to {@code plan.yaml} at the workspace
     * root. Written before any mutation so the audit artifact reflects
     * the plan that will drive the cascade. In draft mode, the file is
     * still emitted — it's the point of draft mode.
     *
     * <p>Write failures are logged as warnings and do not abort the
     * release; the plan.yaml is an audit artifact, not a gate.
     *
     * @param root the workspace root directory
     * @param plan the release plan to serialize
     */
    private void writeReleasePlan(File root, ReleasePlan plan) {
        Path file = root.toPath().resolve("plan.yaml");
        String timestamp = ISO_UTC.format(Instant.now());
        String yaml = buildReleasePlanYaml(timestamp, root.toPath(), plan);
        try {
            Files.writeString(file, yaml, StandardCharsets.UTF_8);
            getLog().info("Release plan written: " + file);
        } catch (IOException e) {
            getLog().warn("Could not write plan.yaml: " + e.getMessage());
        }
    }

    /**
     * Build the {@code plan.yaml} audit content from a release plan.
     *
     * <p>Pure function: no I/O, no git — suitable for unit testing.
     * Paths are emitted relative to {@code workspaceRoot} when possible;
     * absolute otherwise.
     *
     * @param timestamp     ISO-8601 UTC timestamp
     * @param workspaceRoot workspace root, used to relativize POM paths
     * @param plan          the release plan
     * @return YAML content
     */
    static String buildReleasePlanYaml(
            String timestamp, Path workspaceRoot, ReleasePlan plan) {
        Path rootAbs = workspaceRoot.toAbsolutePath().normalize();
        StringBuilder y = new StringBuilder();
        y.append("# Workspace release plan (pre-mutation audit)\n");
        y.append("# Generated: ").append(timestamp).append("\n");
        y.append("timestamp: ").append(timestamp).append("\n");

        y.append("artifacts:\n");
        if (plan.artifacts().isEmpty()) {
            y.append("  []\n");
        }
        for (ArtifactReleasePlan ap : plan.artifacts().values()) {
            y.append("  - groupId: ").append(ap.ga().groupId()).append("\n");
            y.append("    artifactId: ").append(ap.ga().artifactId()).append("\n");
            y.append("    producingSubproject: ")
                    .append(ap.producingSubproject()).append("\n");
            y.append("    rootPomPath: ")
                    .append(relPath(rootAbs, ap.rootPomPath())).append("\n");
            y.append("    preReleaseValue: ").append(ap.preReleaseValue()).append("\n");
            y.append("    releaseValue: ").append(ap.releaseValue()).append("\n");
            y.append("    postReleaseValue: ").append(ap.postReleaseValue()).append("\n");
            appendSites(y, "    ", rootAbs, ap.referenceSites());
        }

        y.append("properties:\n");
        if (plan.properties().isEmpty()) {
            y.append("  []\n");
        }
        for (PropertyReleasePlan pp : plan.properties()) {
            y.append("  - propertyName: ").append(pp.propertyName()).append("\n");
            y.append("    declaringPomPath: ")
                    .append(relPath(rootAbs, pp.declaringPomPath())).append("\n");
            y.append("    declaringSubproject: ")
                    .append(pp.declaringSubproject().isEmpty()
                            ? "\"\"" : pp.declaringSubproject()).append("\n");
            y.append("    preReleaseValue: ").append(pp.preReleaseValue()).append("\n");
            y.append("    releaseValue: ").append(pp.releaseValue()).append("\n");
            y.append("    postReleaseValue: ").append(pp.postReleaseValue()).append("\n");
            appendSites(y, "    ", rootAbs, pp.referenceSites());
        }

        return y.toString();
    }

    private static void appendSites(
            StringBuilder y, String indent, Path rootAbs,
            List<ReferenceSite> sites) {
        if (sites.isEmpty()) {
            y.append(indent).append("referenceSites: []\n");
            return;
        }
        y.append(indent).append("referenceSites:\n");
        for (ReferenceSite s : sites) {
            y.append(indent).append("  - pomPath: ")
                    .append(relPath(rootAbs, s.pomPath())).append("\n");
            y.append(indent).append("    kind: ").append(s.kind()).append("\n");
            y.append(indent).append("    targetGa: ").append(s.targetGa()).append("\n");
            y.append(indent).append("    textAtSite: ")
                    .append(s.textAtSite() == null
                            ? "null"
                            : "\"" + s.textAtSite().replace("\"", "\\\"") + "\"")
                    .append("\n");
        }
    }

    private static String relPath(Path rootAbs, Path p) {
        Path abs = p.toAbsolutePath().normalize();
        if (abs.startsWith(rootAbs)) {
            Path rel = rootAbs.relativize(abs);
            String s = rel.toString();
            return s.isEmpty() ? "." : s;
        }
        return abs.toString();
    }

    /**
     * Resolve the catch-up target version for a single upstream.
     *
     * <p>If the upstream released earlier in this cycle, returns the
     * <em>released</em> version (e.g., release 105 → downstream
     * references become {@code 105}). Downstream POMs must reference
     * artifacts that actually exist in the remote repository; the
     * post-release next-snapshot bump (e.g., {@code 106-SNAPSHOT})
     * sits on the upstream's main branch but is not yet deployed and
     * would produce an unresolvable reference.
     *
     * <p>Otherwise reads the upstream's current pom.xml version from
     * disk. If the upstream is neither in this cycle nor checked out,
     * returns {@code null} — there's no value to align to.
     */
    String upstreamTargetVersion(String upstreamName,
                                  Map<String, String> releasedVersions,
                                  File root) {
        if (releasedVersions.containsKey(upstreamName)) {
            return releasedVersions.get(upstreamName);
        }
        File upstreamDir = new File(root, upstreamName);
        if (!upstreamDir.isDirectory()
                || !new File(upstreamDir, "pom.xml").exists()) {
            return null;
        }
        String version = currentVersion(upstreamDir);
        return "unknown".equals(version) ? null : version;
    }

    // ── Helper: write checkpoint YAML ────────────────────────────────

    private void writeCheckpoint(File root, WorkspaceGraph graph, String name)
            throws MojoException {
        Path checkpointsDir = root.toPath().resolve("checkpoints");
        try {
            Files.createDirectories(checkpointsDir);
            Path file = checkpointsDir.resolve("checkpoint-" + name + ".yaml");

            // Gather subproject data for the pure function
            String timestamp = ISO_UTC.format(Instant.now());
            List<String[]> componentData = new ArrayList<>();
            for (String subName : graph.topologicalSort()) {
                File subDir = new File(root, subName);
                if (!subDir.isDirectory()) continue;
                componentData.add(new String[]{
                        subName, gitBranch(subDir), gitShortSha(subDir),
                        currentVersion(subDir),
                        String.valueOf(!gitStatus(subDir).isEmpty())
                });
            }

            String yaml = buildPreReleaseCheckpointYaml(name, timestamp, componentData);
            Files.writeString(file, yaml, StandardCharsets.UTF_8);
            getLog().info("Checkpoint written: " + file);
        } catch (IOException e) {
            getLog().warn("Could not write checkpoint: " + e.getMessage());
        }
    }

    /**
     * Build pre-release checkpoint YAML content from pre-gathered
     * subproject data.
     *
     * <p>This is a pure function with no git or I/O dependencies,
     * suitable for direct unit testing.
     *
     * @param name          checkpoint name
     * @param timestamp     ISO-8601 UTC timestamp
     * @param componentData list of {@code [name, branch, sha, version, modified]}
     *                      arrays for each present subproject
     * @return YAML checkpoint content
     */
    public static String buildPreReleaseCheckpointYaml(
            String name, String timestamp, List<String[]> componentData) {
        StringBuilder yaml = new StringBuilder();
        yaml.append("# Workspace checkpoint: ").append(name).append("\n");
        yaml.append("# Generated: ").append(timestamp).append("\n");
        yaml.append("checkpoint: ").append(name).append("\n");
        yaml.append("timestamp: ").append(timestamp).append("\n");
        yaml.append("subprojects:\n");

        for (String[] sub : componentData) {
            yaml.append("  ").append(sub[0]).append(":\n");
            yaml.append("    branch: ").append(sub[1]).append("\n");
            yaml.append("    sha: ").append(sub[2]).append("\n");
            yaml.append("    version: ").append(sub[3]).append("\n");
            yaml.append("    modified: ").append(sub[4]).append("\n");
        }

        return yaml.toString();
    }

    // ── Helper: find mvn or mvnw ─────────────────────────────────────

    private String findMvn(File subDir) {
        return resolveMvnCommand(subDir);
    }

    /**
     * Build the {@code ike:release-publish} command line for a
     * subproject, threading the workspace-level {@code push} and
     * {@code ignoreWarnings} flags through to the subprocess. Without
     * the {@code ignoreWarnings} pass-through, a workspace release
     * could never clear a subproject whose history predates the
     * issue-trailer convention.
     *
     * @param mvn the resolved Maven executable
     * @param extra any goal-specific arguments to append
     * @return the full command, ready for {@link ReleaseSupport#exec}
     */
    private String[] releaseCommand(String mvn, String... extra) {
        List<String> cmd = new ArrayList<>();
        cmd.add(mvn);
        cmd.add("ike:release-publish");
        cmd.add("-DpushRelease=" + push);
        if (ignoreWarnings) {
            cmd.add("-Dike.release.ignoreWarnings=true");
        }
        for (String arg : extra) {
            cmd.add(arg);
        }
        cmd.add("-B");
        return cmd.toArray(new String[0]);
    }

    /**
     * Resolve the Maven executable for a subproject directory.
     *
     * <p>Checks for {@code mvnw} (executable) and {@code mvnw.cmd} in
     * the given directory. Falls back to {@code "mvn"} from the system
     * PATH if no wrapper is found.
     *
     * @param subDir the subproject directory to check
     * @return absolute path to mvnw/mvnw.cmd, or {@code "mvn"}
     */
    public static String resolveMvnCommand(File subDir) {
        File mvnw = new File(subDir, "mvnw");
        if (mvnw.exists() && mvnw.canExecute()) {
            return mvnw.getAbsolutePath();
        }
        File mvnwCmd = new File(subDir, "mvnw.cmd");
        if (mvnwCmd.exists()) {
            return mvnwCmd.getAbsolutePath();
        }
        return "mvn";
    }

    // ── Record for candidate tracking ────────────────────────────────

    private record ReleaseCandidate(
            String name,
            Subproject subproject,
            File dir,
            String lastTag,
            String reason) {}
}