CentralStaging.java

package network.ike.plugin.release.central;

import network.ike.plugin.ReleaseSupport;
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.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;

/**
 * Finalizes a Maven Central staging directory between the signed
 * staging deploy and the JReleaser upload: prunes the Maven 4
 * {@code -build.pom} artifacts, then swaps each module's generated
 * BOM over its staged stub POM, re-signs it, and verifies the result
 * (IKE-Network/ike-issues#853, IKE-Network/ike-issues#966).
 *
 * <p>Both Central deploy paths run this exact code. The sync path
 * ({@link CentralPhase}) calls it in-process; the detached async
 * sentinel script (IKE-Network/ike-issues#484) invokes it through
 * {@code ike:central-stage}, pinned to the plugin version that
 * rendered the script. This class exists because the swap originally
 * lived only on the sync path while releases default to async — so
 * ike-bom 145 and 146 shipped to Central as stubs with green builds
 * (#966).
 */
public final class CentralStaging {

    private CentralStaging() {}

    /**
     * Deletes the Maven 4 {@code -build.pom} artifacts — and their
     * signatures and checksums — from the staging directory. The
     * build POM carries the 4.1.0 model and is build-time only;
     * Maven Central publishes the consumer POM (the main
     * {@code .pom}). One build POM is produced per reactor module,
     * so the directory is walked recursively.
     *
     * @param log        Maven logger for progress output
     * @param stagingDir the {@code staging-deploy} directory to prune
     * @throws MojoException when the walk or a delete fails
     */
    public static void pruneBuildPoms(Log log, Path stagingDir) {
        try (Stream<Path> paths = Files.walk(stagingDir)) {
            List<Path> buildPoms = paths
                    .filter(Files::isRegularFile)
                    .filter(p -> p.getFileName().toString()
                            .contains("-build.pom"))
                    .toList();
            for (Path p : buildPoms) {
                Files.delete(p);
                log.info("  pruned " + stagingDir.relativize(p));
            }
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to prune -build.pom artifacts from "
                            + stagingDir, e);
        }
    }

    /**
     * Replaces each staged stub POM with its module's
     * {@code target/generated-bom.xml}, re-signs it, and verifies the
     * result (IKE-Network/ike-issues#853). The stub's signature and
     * checksums are invalidated by the content swap, so the swapped
     * file is re-deployed into the staging directory through
     * {@code gpg:sign-and-deploy-file} under the same
     * {@code signArtifacts} profile as the original staging deploy —
     * producing a fresh signature and fresh checksums over the real
     * BOM bytes before JReleaser uploads the bundle.
     *
     * <p>Finding zero generated BOMs is normal for repos without a
     * BOM module and is logged rather than failed; a swap whose
     * verification fails throws, so a stub can never replace a real
     * BOM silently.
     *
     * @param log        Maven logger for progress output
     * @param buildRoot  the build root that produced the staged
     *                   artifacts (walked for
     *                   {@code target/generated-bom.xml} outputs)
     * @param mvnw       the Maven wrapper used for the re-sign deploy
     * @param stagingDir the {@code staging-deploy} directory holding
     *                   the staged stub POMs
     * @return the number of BOMs swapped
     * @throws MojoException when the swap I/O fails, the re-sign
     *         subprocess exits non-zero, or a swapped POM does not
     *         verify
     */
    public static int swapGeneratedBoms(Log log, File buildRoot,
                                        File mvnw, Path stagingDir) {
        int swapped = 0;
        try {
            for (GeneratedBomSwap.Swap swap
                    : GeneratedBomSwap.plan(buildRoot.toPath(), stagingDir)) {
                GeneratedBomSwap.apply(swap);
                log.info("  swapped generated BOM into "
                        + stagingDir.relativize(swap.stagedPom()));
                ReleaseSupport.exec(buildRoot, log,
                        mvnw.getAbsolutePath(),
                        "gpg:sign-and-deploy-file", "-N", "-B",
                        "-P", "signArtifacts",
                        "-Durl=file://" + stagingDir.toAbsolutePath(),
                        "-Dfile=" + swap.generatedBom().toAbsolutePath(),
                        "-DpomFile=" + swap.generatedBom().toAbsolutePath(),
                        "-Dpackaging=pom");
                String problem = GeneratedBomSwap.verify(swap);
                if (problem != null) {
                    throw new MojoException(
                            "Generated-BOM swap verification failed: "
                                    + problem);
                }
                swapped++;
            }
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to swap generated BOMs into " + stagingDir, e);
        }
        if (swapped == 0) {
            log.info("  no generated BOMs under " + buildRoot
                    + " — nothing to swap");
        } else {
            log.info("  swapped " + swapped + " generated BOM(s)");
        }
        return swapped;
    }
}