WorkspaceReleaseMission.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.ReleaseRecord;
import network.ike.workspace.ReleaseRecordFile;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Executes one checkpoint-shaped release mission of a working set — the
 * reactor-pass release model (IKE-Network/ike-issues#997, settled
 * 2026-08-11/12): one operation from the workspace root, with the
 * reactor as the coherence mechanism, replacing the per-member
 * delegation loop that imported standalone-repository release
 * assumptions into working sets.
 *
 * <p>The phases, in order:
 * <ol>
 *   <li><b>Version pass</b> — every releasing member's own
 *       {@code <version>} de-qualifies to its release value, and every
 *       plan-tracked reference site (version properties, parent blocks,
 *       literal dependency versions) moves to the referenced artifact's
 *       release value — all committed per repository as
 *       {@code release: set version to <V>} (a release-cadence subject,
 *       so retries never count these commits as source changes). The
 *       workspace root's release commit also carries the mission's
 *       {@code releases/release-<mission>.yaml} record, so the tagged
 *       root tree contains the mission record.</li>
 *   <li><b>Reactor verify</b> — one build of the whole working set at
 *       release versions ({@code clean install -P release}), replacing
 *       N per-member subprocess builds.</li>
 *   <li><b>Scoped deploy</b> — {@code deploy} limited to the releasing
 *       members plus the root ({@code -pl} selectors matching the
 *       version pass exactly; the verify pass installed everything, so
 *       no {@code -am} is needed and bystanders are never deployed).</li>
 *   <li><b>Tags</b> — an annotated {@code v<version>} tag per releasing
 *       repository and the root, at the release commits.</li>
 *   <li><b>Post-bump</b> — own versions move to the next development
 *       {@code -SNAPSHOT}s ({@code post-release: bump to <V>}); reference
 *       sites settle at the released values (the plan's post semantics —
 *       development builds resolve released upstreams from Nexus until
 *       alignment deliberately moves them forward).</li>
 *   <li><b>Push</b> — every touched repository's branch and tag.</li>
 * </ol>
 *
 * <p><b>First-mission-complete scope:</b> references from releasing
 * members to members OUTSIDE the release set are not rewritten by this
 * class; the release-set-aware SNAPSHOT preflight (ike-issues#981)
 * refuses such missions with remediation. The first mission — where every
 * member is release-pending — has no such references by construction;
 * bystander backward-pinning for later partial missions is the named
 * second increment on the design issue.
 *
 * <p>Subprocess Maven invocations go through the {@link Runner} seam so
 * tests can assert the exact command lines without executing builds
 * (the house pattern from the bare-mode release tests); git operations
 * run for real against the working set's repositories.
 */
final class WorkspaceReleaseMission {

    /**
     * One repository releasing in this mission.
     *
     * @param name           the working-set member name; the root uses
     *                       its artifactId
     * @param dir            the repository directory
     * @param artifactId     the Maven artifactId used for the
     *                       {@code -pl :artifactId} deploy selector
     * @param preVersion     the development version before the mission
     *                       (must end in {@code -SNAPSHOT})
     * @param releaseVersion the version this mission releases
     * @param postVersion    the next development version after the mission
     */
    record ReleasingRepo(String name, File dir, String artifactId,
                         String preVersion, String releaseVersion,
                         String postVersion) {}

    /**
     * Subprocess seam: runs one command in a directory, failing the
     * mission on a non-zero exit.
     */
    interface Runner {
        /**
         * Run the command, streaming output to the build log.
         *
         * @param dir     working directory
         * @param command the command and its arguments
         * @throws MojoException on non-zero exit
         */
        void run(File dir, String... command) throws MojoException;
    }

    private final File root;
    private final List<ReleasingRepo> members;
    private final ReleasingRepo rootRepo;
    private final ReleasePlan plan;
    private final String missionLabel;
    private final String recordDate;
    private final ReleaseTagStyle tagStyle;
    private final Log log;
    private final Runner runner;

    /**
     * Create a mission over the given releasing set.
     *
     * @param root       the workspace root directory
     * @param members    the releasing members, dependency order
     * @param rootRepo   the workspace root's own release identity
     * @param plan       the computed release plan (version truth for
     *                   every artifact and tracking property)
     * @param missionLabel the mission label naming the record file
     * @param recordDate the date recorded on the mission rows (caller
     *                   supplies; typically today's ISO date)
     * @param log        Maven logger
     * @param runner     subprocess seam for the verify/deploy builds
     */
    WorkspaceReleaseMission(File root, List<ReleasingRepo> members,
                          ReleasingRepo rootRepo, ReleasePlan plan,
                          String missionLabel, String recordDate,
                          ReleaseTagStyle tagStyle,
                          Log log, Runner runner) {
        this.root = root;
        this.tagStyle = tagStyle;
        this.members = List.copyOf(members);
        this.rootRepo = rootRepo;
        this.plan = plan;
        this.missionLabel = missionLabel;
        this.recordDate = recordDate;
        this.log = log;
        this.runner = runner;
    }

    /**
     * Execute the full mission.
     *
     * @param mvn       the Maven launcher for the reactor builds
     * @param skipTests whether the verify pass skips tests
     * @return released name → release version, in execution order,
     *         including the workspace root
     * @throws MojoException on any phase failure; earlier phases'
     *                       commits are release-cadence subjects, so a
     *                       re-run resumes rather than double-counting
     */
    Map<String, String> execute(String mvn, boolean skipTests)
            throws MojoException {
        guardNotInFlight();
        applyReleaseVersions();
        verifyReactor(mvn, skipTests);
        deployReleasingSet(mvn);
        tagReleases();
        postBump();
        pushAll();

        Map<String, String> released = new LinkedHashMap<>();
        for (ReleasingRepo m : members) {
            released.put(m.name(), m.releaseVersion());
        }
        released.put(rootRepo.name(), rootRepo.releaseVersion());
        return released;
    }

    /**
     * Describe the mission's phases for the draft report, without
     * mutating anything.
     *
     * @param mvn the Maven launcher the publish would use
     * @return human-readable phase lines
     */
    List<String> describe(String mvn) {
        List<String> lines = new ArrayList<>();
        lines.add("Reactor-pass mission `" + missionLabel + "` — one version"
                + " pass, one reactor verify, deploy scoped to the"
                + " releasing set:");
        for (ReleasingRepo m : allRepos()) {
            lines.add("  " + m.name() + ": " + m.preVersion() + " → "
                    + m.releaseVersion() + " (tag "
                    + tagStyle.tagFor(m.releaseVersion())
                    + "), then " + m.postVersion());
        }
        lines.add("  verify: " + String.join(" ", verifyCommand(mvn, false)));
        lines.add("  deploy: " + String.join(" ", deployCommand(mvn)));
        lines.add("  record: " + rootRelativeRecordPath());
        lines.add("  notes:  " + rootRelativeNotesPath());
        return lines;
    }

    // ── Phases ───────────────────────────────────────────────────────

    /**
     * Refuse to start when a prior mission is in flight: a releasing
     * repository whose POM already carries a non-SNAPSHOT version was
     * left mid-mission (crash between version pass and post-bump).
     */
    private void guardNotInFlight() {
        for (ReleasingRepo m : allRepos()) {
            if (!m.preVersion().endsWith("-SNAPSHOT")) {
                throw new MojoException("In-flight release mission detected: "
                        + m.name() + " is at " + m.preVersion()
                        + " (not a -SNAPSHOT). Recover the interrupted mission"
                        + " first — roll forward per IKE-RELEASE-RECOVERY.md"
                        + " — then re-run.");
            }
            if (tagExists(m.dir(), tagStyle.tagFor(m.releaseVersion()))) {
                throw new MojoException("Tag v" + m.releaseVersion()
                        + " already exists in " + m.name()
                        + " — its version was not bumped after the last"
                        + " release. Fix the version line, then re-run.");
            }
        }
    }

    /**
     * The version pass: self-versions to release values, plan-tracked
     * reference sites to the referenced artifacts' release values, the
     * mission record into the root — one release-cadence commit per
     * repository.
     */
    private void applyReleaseVersions() {
        log.info("");
        log.info("  Version pass — " + allRepos().size()
                + " repositories de-qualify together");

        // Self-versions — the repository root POM and every sub-module
        // beneath it that names the repository's own version.
        for (ReleasingRepo m : allRepos()) {
            File pom = new File(m.dir(), "pom.xml");
            try {
                ReleaseSupport.setPomVersion(pom, m.preVersion(),
                        m.releaseVersion());
            } catch (Exception e) {
                throw new MojoException("Version pass failed setting "
                        + m.name() + " to " + m.releaseVersion() + ": "
                        + e.getMessage(), e);
            }
            retargetOwnCoordinates(m, m.preVersion(), m.releaseVersion());
        }

        // Tracking properties settle at the referenced release values.
        for (ReleasePlan.PropertyReleasePlan p : plan.properties()) {
            rewrite(p.declaringPomPath(), content -> PomModel.updateProperty(
                    content, p.propertyName(), p.releaseValue()),
                    "property " + p.propertyName());
        }

        // Parent blocks and literal dependency sites (properties cover
        // the ${…}-routed sites).
        for (ReleasePlan.ArtifactReleasePlan a : plan.artifacts().values()) {
            for (ReleasePlan.ReferenceSite site : a.referenceSites()) {
                if (site.textAtSite() == null
                        || site.textAtSite().startsWith("${")) {
                    continue;
                }
                switch (site.kind()) {
                    case PARENT -> rewrite(site.pomPath(),
                            content -> PomModel.updateParentVersion(content,
                                    site.targetGa().groupId(),
                                    site.targetGa().artifactId(),
                                    a.releaseValue()),
                            "parent " + site.targetGa());
                    case DEPENDENCY -> rewrite(site.pomPath(),
                            content -> PomModel.updateDependencyVersion(content,
                                    site.targetGa().groupId(),
                                    site.targetGa().artifactId(),
                                    a.releaseValue()),
                            "dependency " + site.targetGa());
                    case PLUGIN -> rewrite(site.pomPath(),
                            content -> PomModel.updatePluginVersion(content,
                                    site.targetGa().groupId(),
                                    site.targetGa().artifactId(),
                                    a.releaseValue()),
                            "plugin " + site.targetGa());
                }
            }
        }

        // Members commit first, so their release commits exist to be
        // pinned and recorded; the root commits last, carrying both the
        // record and the pins into the tree its tag will name.
        for (ReleasingRepo m : members) {
            commitRepo(m.dir(), "release: set version to "
                    + m.releaseVersion());
            log.info(Ansi.green("  ✓ ") + m.name() + " → "
                    + m.releaseVersion());
        }

        pinWorkingSetState(workingSetDirs());
        writeMissionRecord();
        writeMissionNotes();

        commitRepo(rootRepo.dir(), "release: set version to "
                + rootRepo.releaseVersion());
        log.info(Ansi.green("  ✓ ") + rootRepo.name() + " → "
                + rootRepo.releaseVersion());
    }

    /**
     * Every member's directory by name — the whole working set, not
     * only what is releasing, since the manifest speaks for all of it.
     *
     * @return member name → directory, for each checked-out member
     */
    private Map<String, File> workingSetDirs() {
        Map<String, File> dirs = new LinkedHashMap<>();
        File[] entries = root.listFiles();
        if (entries == null) return dirs;
        for (File entry : entries) {
            if (entry.isDirectory() && new File(entry, ".git").exists()) {
                dirs.put(entry.getName(), entry);
            }
        }
        return dirs;
    }

    /** One reactor build of the whole working set at release versions. */
    private void verifyReactor(String mvn, boolean skipTests) {
        log.info("");
        log.info("  Reactor verify — one build, everything at release"
                + " versions");
        runner.run(root, verifyCommand(mvn, skipTests));
    }

    /** Deploy exactly the releasing set from the verified reactor. */
    private void deployReleasingSet(String mvn) {
        log.info("");
        log.info("  Deploy — scoped to the releasing set");
        runner.run(root, deployCommand(mvn));
    }

    /** Annotated v-tags at the release commits, one per repository. */
    private void tagReleases() {
        for (ReleasingRepo m : allRepos()) {
            String tag = tagStyle.tagFor(m.releaseVersion());
            ReleaseSupport.exec(m.dir(), log, "git", "tag", "-a", tag,
                    "-m", "release: " + m.name() + " " + m.releaseVersion()
                            + " (mission " + missionLabel + ")");
            log.info(Ansi.green("  ✓ ") + "tagged " + m.name() + " " + tag);
        }
    }

    /**
     * Own versions move to the next development SNAPSHOTs; reference
     * sites stay at the released values (the plan's settle semantics).
     */
    private void postBump() {
        for (ReleasingRepo m : allRepos()) {
            File pom = new File(m.dir(), "pom.xml");
            try {
                ReleaseSupport.setPomVersion(pom, m.releaseVersion(),
                        m.postVersion());
            } catch (Exception e) {
                throw new MojoException("Post-bump failed for " + m.name()
                        + ": " + e.getMessage(), e);
            }
            retargetOwnCoordinates(m, m.releaseVersion(), m.postVersion());
            commitRepo(m.dir(), "post-release: bump to " + m.postVersion());
        }
    }

    /** Push every repository's branch and release tag. */
    private void pushAll() {
        for (ReleasingRepo m : allRepos()) {
            String branch = ReleaseSupport.currentBranch(m.dir());
            ReleaseSupport.exec(m.dir(), log, "git", "push", "origin",
                    branch);
            ReleaseSupport.exec(m.dir(), log, "git", "push", "origin",
                    tagStyle.tagFor(m.releaseVersion()));
            log.info(Ansi.green("  ✓ ") + "pushed " + m.name());
        }
    }

    // ── Helpers ──────────────────────────────────────────────────────

    private List<ReleasingRepo> allRepos() {
        List<ReleasingRepo> all = new ArrayList<>(members);
        all.add(rootRepo);
        return all;
    }

    /** Content-transform one POM file in place. */
    /**
     * Move a releasing repository's own version wherever its sub-modules
     * name it: a module declaring its own {@code <version>}, and a
     * module whose {@code <parent>} names an aggregator inside the same
     * repository. Maven carries the rest by inheritance, but a POM that
     * spells the version out is left behind by a root-only rewrite —
     * and one straggler at the old version breaks the whole reactor,
     * since its siblings' release-version dependencies on it resolve
     * nowhere (IKE-Network/ike-issues#1011).
     *
     * <p>Only the repository's own coordinates move. The match is the
     * version string itself: a {@code <parent>} or self {@code <version>}
     * equal to this repository's {@code from} value is by construction
     * this repository's own, since an external parent carries an
     * unrelated version. Dependency versions are never touched here —
     * cross-repository references belong to the release plan, which
     * knows the target's release value.
     *
     * @param repo the releasing repository whose modules to retarget
     * @param from the version to move away from
     * @param to   the version to move to
     */
    private void retargetOwnCoordinates(ReleasingRepo repo, String from,
                                        String to) {
        Path repoRoot = repo.dir().toPath();
        List<Path> modulePoms;
        try (java.util.stream.Stream<Path> tree = Files.walk(repoRoot)) {
            modulePoms = tree
                    .filter(p -> p.getFileName().toString().equals("pom.xml"))
                    .filter(p -> !p.equals(repoRoot.resolve("pom.xml")))
                    .filter(p -> !p.toString().contains(File.separator
                            + "target" + File.separator))
                    // Never cross into another repository. A working
                    // set's members live INSIDE the root's directory,
                    // so walking the root's tree reaches every member's
                    // POM — and a member whose version string happened
                    // to match the root's was rewritten as if it were
                    // one of the root's own modules. It is not: it is a
                    // separate repository with its own release line.
                    .filter(p -> !crossesRepositoryBoundary(repoRoot, p))
                    .toList();
        } catch (IOException e) {
            throw new MojoException("Version pass could not walk "
                    + repo.name() + ": " + e.getMessage(), e);
        }
        int retargeted = 0;
        for (Path pom : modulePoms) {
            boolean changed = false;

            // A <parent> naming an aggregator inside this repository.
            try {
                PomParentSupport.ParentInfo parent =
                        PomParentSupport.readParent(pom);
                if (parent != null && from.equals(parent.version())) {
                    rewrite(pom, content -> PomModel.updateParentVersion(
                            content, parent.groupId(), parent.artifactId(),
                            to), "parent " + parent.artifactId());
                    changed = true;
                }
            } catch (IOException e) {
                throw new MojoException("Version pass could not read the"
                        + " parent block of " + pom + ": " + e.getMessage(), e);
            }

            // A module spelling out its own version.
            if (declaresOwnVersion(pom, from)) {
                ReleaseSupport.setPomVersion(pom.toFile(), from, to);
                changed = true;
            }
            if (changed) retargeted++;
        }
        if (retargeted > 0) {
            log.info("    " + repo.name() + ": " + retargeted
                    + " sub-module POM(s) retargeted to " + to);
        }
    }

    /**
     * Whether this POM declares its own {@code <version>} at the given
     * value — the first {@code <version>} after any {@code <parent>}
     * block, which is the same element
     * {@link ReleaseSupport#setPomVersion} moves. Modules that inherit
     * their version declare none, and must be left alone.
     *
     * @param pom     the POM to inspect
     * @param version the version to look for
     * @return {@code true} when the POM spells out that version itself
     */
    /**
     * Whether a POM lies inside a repository nested under this one —
     * a working-set member, which owns its own versions and must not be
     * touched by another repository's version pass.
     *
     * @param repoRoot the repository whose tree is being walked
     * @param pom      a POM found beneath it
     * @return {@code true} when some directory between the two is
     *         itself a repository
     */
    private static boolean crossesRepositoryBoundary(Path repoRoot, Path pom) {
        Path dir = pom.getParent();
        while (dir != null && !dir.equals(repoRoot)) {
            if (Files.exists(dir.resolve(".git"))) return true;
            dir = dir.getParent();
        }
        return false;
    }

    private static boolean declaresOwnVersion(Path pom, String version) {
        try {
            // The model's own version is null when the module inherits
            // it. Asking the model rather than the text matters: an
            // inheriting module whose dependency happens to carry the
            // same version string would otherwise look like a
            // declaration, and the rewrite would land on that
            // dependency — an unrelated coordinate.
            return version.equals(PomModel.parse(pom).model().getVersion());
        } catch (IOException e) {
            return false;
        }
    }

    private void rewrite(Path pomPath,
                         java.util.function.UnaryOperator<String> transform,
                         String what) {
        try {
            String before = Files.readString(pomPath, StandardCharsets.UTF_8);
            String after = transform.apply(before);
            if (!after.equals(before)) {
                Files.writeString(pomPath, after, StandardCharsets.UTF_8);
            }
        } catch (IOException e) {
            throw new MojoException("Version pass failed rewriting " + what
                    + " in " + pomPath + ": " + e.getMessage(), e);
        }
    }

    /**
     * Whether an exact tag exists in the repository.
     *
     * @param dir the repository directory
     * @param tag the tag name
     * @return true when the tag exists
     */
    private static boolean tagExists(File dir, String tag) {
        try {
            String out = ReleaseSupport.execCapture(dir,
                    "git", "tag", "-l", tag);
            return out != null && !out.isBlank();
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * Commit a mission repository's pending changes. The version pass and
     * post-bump author every tracked change (preflight guaranteed clean
     * trees), staged with {@code git add -u}; the root additionally
     * stages the mission's (new, untracked) record file by exact path.
     */
    private void commitRepo(File dir, String message) {
        ReleaseSupport.exec(dir, log, "git", "add", "-u");
        if (dir.equals(root)) {
            ReleaseSupport.exec(dir, log, "git", "add", "--",
                    rootRelativeRecordPath(), rootRelativeNotesPath());
        }
        ReleaseSupport.exec(dir, log, "git", "commit", "-m", message);
    }

    private String rootRelativeRecordPath() {
        return root.toPath().relativize(
                ReleaseRecordFile.pathFor(root.toPath(), missionLabel))
                .toString();
    }

    /**
     * The mission's what-changed notes file, beside the record
     * ({@code releases/release-<mission>-notes.md}).
     */
    private Path notesPath() {
        Path record = ReleaseRecordFile.pathFor(root.toPath(), missionLabel);
        return record.resolveSibling(record.getFileName().toString()
                .replace(".yaml", "-notes.md"));
    }

    private String rootRelativeNotesPath() {
        return root.toPath().relativize(notesPath()).toString();
    }

    /**
     * Pin every member of the working set at the commit it holds right
     * now — the state being released — so the manifest carried in the
     * tagged root tree describes a coherent, buildable working set.
     *
     * <p>All members, not only the releasing ones. The mission record
     * names what was released, which is a smaller set by design
     * (#997); the installers are built from the whole working set, so
     * something has to say what the whole set was. Before this, a
     * release tag carried released {@code version:} fields beside
     * {@code sha:} pins left over from the last checkpoint — versions
     * from the release, commits from days earlier — and anything that
     * materialised from those pins built a state that never existed as
     * a release (IKE-Network/ike-issues#1017).
     *
     * @param memberDirs every member's directory, by member name
     */
    private void pinWorkingSetState(Map<String, File> memberDirs) {
        Map<String, String> pins = new LinkedHashMap<>();
        for (Map.Entry<String, File> member : memberDirs.entrySet()) {
            try {
                pins.put(member.getKey(), ReleaseSupport.execCapture(
                        member.getValue(), "git", "rev-parse", "HEAD").trim());
            } catch (Exception e) {
                throw new MojoException("Cannot read the released commit of "
                        + member.getKey() + ": " + e.getMessage(), e);
            }
        }
        try {
            ManifestWriter.updateShas(root.toPath().resolve("workspace.yaml"),
                    pins);
        } catch (Exception e) {
            throw new MojoException("Cannot pin the working set state: "
                    + e.getMessage(), e);
        }
        log.info("    pinned " + pins.size()
                + " member(s) at their released commits");
    }

    /** Write the mission's record with a row per releasing repository. */
    private void writeMissionRecord() {
        try {
            Path recordPath = ReleaseRecordFile.pathFor(root.toPath(),
                    missionLabel);
            ReleaseRecord record = Files.exists(recordPath)
                    ? ReleaseRecordFile.read(recordPath)
                    : ReleaseRecord.start(missionLabel, recordDate);
            for (ReleasingRepo m : allRepos()) {
                String sha = ReleaseSupport.execCapture(m.dir(),
                        "git", "rev-parse", "HEAD").trim();
                record = record.withMember(m.name(),
                        new ReleaseRecord.MemberRelease(m.releaseVersion(),
                                tagStyle.tagFor(m.releaseVersion()), sha, recordDate));
            }
            ReleaseRecordFile.write(recordPath, record);
        } catch (Exception e) {
            throw new MojoException("Mission record write failed: "
                    + e.getMessage(), e);
        }
    }

    /**
     * Write the mission's what-changed notes beside the record
     * (IKE-Network/ike-issues#1016): for each releasing repository, the
     * non-mechanical commits between its previous release tag and the
     * release commit — subjects with their bodies, goal-authored cadence
     * and hygiene commits filtered — so the delivery chain publishes a
     * narrative from the tagged tree instead of a bare member table.
     * Repositories whose only movement is version alignment are listed
     * on one summary line; a repository with no previous release tag is
     * noted as a first release rather than dumping its whole history.
     */
    private void writeMissionNotes() {
        StringBuilder md = new StringBuilder();
        md.append("# What changed — mission ").append(missionLabel)
                .append(" (").append(recordDate).append(")\n");
        List<String> alignmentOnly = new ArrayList<>();
        for (ReleasingRepo m : allRepos()) {
            String previousTag =
                    WsReleaseDraftMojo.latestReleaseTag(m.dir(), tagStyle);
            if (previousTag == null) {
                md.append("\n## ").append(m.name()).append(' ')
                        .append(m.releaseVersion()).append("\n\n")
                        .append("- _First release of this member._\n");
                continue;
            }
            List<String> entries = notesEntriesFor(m.dir(), previousTag);
            if (entries.isEmpty()) {
                alignmentOnly.add(m.name() + " " + m.releaseVersion());
            } else {
                md.append("\n## ").append(m.name()).append(' ')
                        .append(m.releaseVersion()).append("  ·  ")
                        .append(previousTag).append(" → ")
                        .append(tagStyle.tagFor(m.releaseVersion()))
                        .append("\n\n");
                for (String entry : entries) {
                    md.append(entry).append('\n');
                }
            }
        }
        if (!alignmentOnly.isEmpty()) {
            md.append("\nMoved for version alignment only: ")
                    .append(String.join(", ", alignmentOnly)).append(".\n");
        }
        try {
            Files.writeString(notesPath(), md.toString(),
                    StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException("Mission notes write failed: "
                    + e.getMessage(), e);
        }
    }

    /**
     * The narrative entries for one repository: commits between the
     * previous release tag and HEAD, formatted by
     * {@link #formatNotesEntries(String)}.
     *
     * @param dir         the repository directory
     * @param previousTag the repository's previous release tag
     * @return formatted entries, oldest last; empty when every commit
     *         since the tag is mechanical
     */
    private List<String> notesEntriesFor(File dir, String previousTag) {
        String raw;
        try {
            raw = ReleaseSupport.execCapture(dir, "git", "log",
                    previousTag + "..HEAD", "--no-merges",
                    "--pretty=format:%s%x1f%b%x1e");
        } catch (Exception e) {
            throw new MojoException("Mission notes: cannot read "
                    + dir.getName() + " history since " + previousTag
                    + ": " + e.getMessage(), e);
        }
        return formatNotesEntries(raw);
    }

    /**
     * Goal-authored hygiene subjects filtered from the notes on top of
     * the release-cadence patterns: checkpoints, scaffold
     * reconciliation, workspace alignment, and post-release manifest
     * sync say nothing about what a release means to its consumers.
     */
    private static final java.util.regex.Pattern NOTES_MECHANICAL_PATTERN =
            java.util.regex.Pattern.compile(
                    "^(checkpoint: .*"
                            + "|scaffold: .*"
                            + "|ws:scaffold.*"
                            + "|workspace: .*"
                            + "|chore: align upstream versions.*"
                            + "|post-release: .*)$");

    /** Trailer lines dropped from commit bodies in the notes. */
    private static final java.util.regex.Pattern NOTES_TRAILER_PATTERN =
            java.util.regex.Pattern.compile(
                    "^(Refs:|Fixes:|Co-[Aa]uthored-[Bb]y:|Signed-off-by:).*");

    /**
     * Format raw {@code git log} output (subject {@code US} body
     * {@code RS}, as produced with
     * {@code --pretty=format:%s%x1f%b%x1e}) into Markdown notes
     * entries: one {@code - **subject**} bullet per non-mechanical
     * commit, body lines indented beneath it with trailers and blank
     * lines dropped. Static and pure for direct unit testing.
     *
     * @param raw the raw log output; {@code null} or blank yields no
     *            entries
     * @return formatted entries in log order
     */
    static List<String> formatNotesEntries(String raw) {
        List<String> entries = new ArrayList<>();
        if (raw == null || raw.isBlank()) {
            return entries;
        }
        for (String commit : raw.split("\u001e")) {
            String[] parts = commit.split("\u001f", 2);
            String subject = parts[0].strip();
            if (subject.isEmpty()
                    || WsReleaseDraftMojo.isReleaseCadenceCommit(subject)
                    || NOTES_MECHANICAL_PATTERN.matcher(subject).matches()) {
                continue;
            }
            StringBuilder entry = new StringBuilder("- **")
                    .append(subject).append("**");
            if (parts.length > 1) {
                for (String line : parts[1].split("\n")) {
                    String stripped = line.strip();
                    if (stripped.isEmpty()
                            || NOTES_TRAILER_PATTERN.matcher(stripped)
                                    .matches()) {
                        continue;
                    }
                    entry.append("\n  ").append(stripped);
                }
            }
            entries.add(entry.toString());
        }
        return entries;
    }

    private String[] verifyCommand(String mvn, boolean skipTests) {
        // The release-mode signal for the workspace extension's
        // intra-set version resolution (ike-issues#1019): bystanders
        // bind to their released versions, version-passed members to
        // reactor-current.
        List<String> cmd = new ArrayList<>(List.of(mvn, "-B", "-ntp",
                "clean", "install", "-P", "release",
                "-Dike.workspace.release=true", "-T", "1"));
        if (skipTests) {
            cmd.add("-DskipTests");
        }
        return cmd.toArray(new String[0]);
    }

    private String[] deployCommand(String mvn) {
        Set<String> selectors = new LinkedHashSet<>();
        for (ReleasingRepo m : allRepos()) {
            selectors.add(":" + m.artifactId());
        }
        return new String[] {mvn, "-B", "-ntp", "deploy",
                "-pl", String.join(",", selectors),
                "-P", "release,signArtifacts",
                "-Dike.workspace.release=true", "-T", "1", "-DskipTests"};
    }
}