GeneratedBomSwap.java

package network.ike.plugin.release.central;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
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.List;
import java.util.stream.Stream;

/**
 * Swap {@code ike:generate-bom} output into the Maven Central staging
 * bundle (IKE-Network/ike-issues#853).
 *
 * <p>{@code generate-bom} writes a fully resolved BOM to
 * {@code target/generated-bom.xml}, but Maven 4's immutable
 * {@code Project} offers no way to make install/deploy publish it in
 * place of the stub POM — so every {@code ike-bom} version on Central
 * (72–132) shipped the stub: no {@code <dependencyManagement>} at all,
 * plus leaked {@code <build>} and {@code <distributionManagement>}
 * sections. This class closes the gap at the staging step of
 * {@code ike:release-publish}: after the signed deploy into
 * {@code target/staging-deploy}, each module's generated BOM replaces
 * its staged stub POM.
 *
 * <p>The stub's signature and checksums are deleted along with its
 * content — they were produced over the stub bytes and must be
 * regenerated over the swapped file (the caller re-signs via
 * {@code gpg:sign-and-deploy-file} under the same {@code signArtifacts}
 * profile the staging deploy used).
 */
final class GeneratedBomSwap {

    private GeneratedBomSwap() {}

    /** Sidecars invalidated by a content swap. */
    private static final List<String> SIDECARS = List.of(
            ".asc", ".md5", ".sha1", ".sha256", ".sha512");

    /**
     * One planned swap: a module's generated BOM and the staged stub
     * POM it replaces.
     */
    record Swap(Path generatedBom, Path stagedPom,
                String groupId, String artifactId, String version) {}

    /**
     * Find every {@code target/generated-bom.xml} under
     * {@code gitRoot} and pair it with its staged POM.
     *
     * @param gitRoot    the release worktree root
     * @param stagingDir the {@code staging-deploy} directory
     * @return planned swaps for generated BOMs whose staged POM exists;
     *         a generated BOM without a staged counterpart is skipped
     * @throws IOException when the tree walk or an XML read fails
     */
    static List<Swap> plan(Path gitRoot, Path stagingDir)
            throws IOException {
        List<Swap> swaps = new ArrayList<>();
        try (Stream<Path> paths = Files.walk(gitRoot, 6)) {
            List<Path> generated = paths
                    .filter(p -> p.getFileName().toString()
                            .equals("generated-bom.xml"))
                    .filter(p -> p.getParent() != null
                            && p.getParent().getFileName().toString()
                                    .equals("target"))
                    .toList();
            for (Path bom : generated) {
                String[] gav = readGav(bom);
                if (gav == null) continue;
                Path stagedPom = stagingDir
                        .resolve(gav[0].replace('.', '/'))
                        .resolve(gav[1])
                        .resolve(gav[2])
                        .resolve(gav[1] + "-" + gav[2] + ".pom");
                if (!Files.isRegularFile(stagedPom)) continue;
                swaps.add(new Swap(bom, stagedPom, gav[0], gav[1], gav[2]));
            }
        }
        return swaps;
    }

    /**
     * Replace the staged POM's content with the generated BOM and
     * delete the now-stale signature and checksum sidecars.
     *
     * @param swap the planned swap
     * @return the deleted sidecar paths
     * @throws IOException when a write or delete fails
     */
    static List<Path> apply(Swap swap) throws IOException {
        Files.writeString(swap.stagedPom(),
                Files.readString(swap.generatedBom(), StandardCharsets.UTF_8),
                StandardCharsets.UTF_8);
        List<Path> deleted = new ArrayList<>();
        for (String suffix : SIDECARS) {
            Path sidecar = swap.stagedPom().resolveSibling(
                    swap.stagedPom().getFileName() + suffix);
            if (Files.deleteIfExists(sidecar)) {
                deleted.add(sidecar);
            }
        }
        return deleted;
    }

    /**
     * Assert the staged POM now carries the resolved BOM payload and
     * none of the stub's build machinery — the #853 failure was
     * silent, so the swap's success must not be assumed.
     *
     * @param swap the applied swap
     * @return null when valid, else a description of what is wrong
     * @throws IOException when the staged POM cannot be read
     */
    static String verify(Swap swap) throws IOException {
        String pom = Files.readString(swap.stagedPom(),
                StandardCharsets.UTF_8);
        if (!pom.contains("<dependencyManagement>")) {
            return swap.stagedPom() + " has no <dependencyManagement> "
                    + "after the generated-BOM swap";
        }
        if (pom.contains("<build>") || pom.contains("<distributionManagement>")) {
            return swap.stagedPom() + " still carries stub build machinery "
                    + "(<build>/<distributionManagement>) after the swap";
        }
        for (String required : List.of("<licenses>", "<developers>", "<scm>")) {
            if (!pom.contains(required)) {
                return swap.stagedPom() + " lacks " + required
                        + " after the generated-BOM swap — Maven Central "
                        + "requires it (IKE-Network/ike-issues#967)";
            }
        }
        return null;
    }

    /**
     * Read project-level groupId/artifactId/version from a generated
     * BOM, or null when the file is not parseable as a POM.
     */
    private static String[] readGav(Path bomXml) {
        try {
            DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
            dbf.setFeature(
                    XMLConstants.FEATURE_SECURE_PROCESSING, true);
            Document doc = dbf.newDocumentBuilder().parse(bomXml.toFile());
            Element project = doc.getDocumentElement();
            String groupId = directChildText(project, "groupId");
            String artifactId = directChildText(project, "artifactId");
            String version = directChildText(project, "version");
            if (groupId == null || artifactId == null || version == null) {
                return null;
            }
            return new String[] {groupId, artifactId, version};
        } catch (Exception e) {
            return null;
        }
    }

    /** Text of a direct child element, ignoring nested occurrences. */
    private static String directChildText(Element parent, String name) {
        for (Node n = parent.getFirstChild(); n != null;
                n = n.getNextSibling()) {
            if (n instanceof Element el && name.equals(el.getTagName())) {
                return el.getTextContent().trim();
            }
        }
        return null;
    }
}