SnapshotScanner.java
package network.ike.plugin;
import org.apache.maven.api.model.Dependency;
import org.apache.maven.api.model.DependencyManagement;
import org.apache.maven.api.model.Model;
import org.apache.maven.api.model.Parent;
import org.apache.maven.api.model.Plugin;
import org.apache.maven.api.model.PluginManagement;
import org.apache.maven.api.model.Profile;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.model.v4.MavenStaxReader;
import javax.xml.stream.XMLStreamException;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Scan POM files for {@code -SNAPSHOT} references that would leak into
* a released artifact via Maven 4's consumer POM flattener.
*
* <p>Maven 4 resolves properties and promotes {@code <pluginManagement>}
* entries into {@code <plugins>} when it writes the <em>consumer POM</em>
* — the POM consumers download from the repository. If a
* {@code <pluginManagement>} entry references a property (e.g.
* {@code <version>${ike-tooling.version}</version>}) whose value ends in
* {@code -SNAPSHOT}, the flattener writes the literal SNAPSHOT string
* into the released artifact. Downstream consumers then see
* {@code <version>117-SNAPSHOT</version>} in the released POM and their
* builds fail with "artifact not found" — even though the release
* passed locally.
*
* <p>This scanner uses Maven 4's own {@link MavenStaxReader} to parse
* POMs into the typed {@link Model} tree, then inspects only the
* contexts that feed the consumer POM:
*
* <ul>
* <li><strong>Source properties scan</strong> via
* {@link #scanSourceProperties(File)} — inspects
* {@code <properties>} and fails if any value ends in
* {@code -SNAPSHOT}. Catches the bug at its source before any
* release mutation runs.</li>
* <li><strong>Post-mutation version scan</strong> via
* {@link #scanForSnapshotVersions(List)} — walks the model's
* {@code <parent>}, {@code <dependencies>}, {@code <dependencyManagement>},
* {@code <build>/<plugins>}, {@code <build>/<pluginManagement>}, and
* every profile's equivalent sections. Any {@code -SNAPSHOT}
* version here is a baked-in reference that would leak through
* the consumer POM.</li>
* </ul>
*
* <p><strong>Not scanned:</strong> the module's own {@code <version>}
* (immediate child of {@code <project>}), because during release the
* module version is handled by
* {@link ReleaseSupport#setPomVersion(java.io.File, String, String)}
* and is not a consumer-POM leakage path. Comments, CDATA, and
* whitespace are natively ignored by the Maven parser.
*/
public final class SnapshotScanner {
/** Suffix that marks a SNAPSHOT version. */
private static final String SNAPSHOT_SUFFIX = "-SNAPSHOT";
private SnapshotScanner() {}
/**
* A single SNAPSHOT reference that would leak into a released POM.
*
* @param pomFile the POM file containing the reference
* @param location descriptor of the element location
* (e.g. {@code "properties/ike-tooling.version"} or
* {@code "pluginManagement/plugin[ike-maven-plugin]"})
* @param value the SNAPSHOT-ending value found
*/
public record Violation(File pomFile, String location, String value) {
/**
* Format this violation as a single indented bullet for an
* aggregated error message.
*
* @param gitRoot the repository root used to relativize the
* POM path; may be {@code null} for absolute paths
* @return a single-line bullet formatted for log output
*/
public String toBullet(File gitRoot) {
String path = (gitRoot != null)
? gitRoot.toPath().relativize(pomFile.toPath()).toString()
: pomFile.getPath();
return " • " + path + ": " + location + " = " + value;
}
}
/**
* Scan the {@code <properties>} sections of a POM (root plus any
* profile properties) for any value ending in {@code -SNAPSHOT}.
*
* <p>This is the primary gate that catches the
* {@code <ike-tooling.version>112-SNAPSHOT</ike-tooling.version>}
* class of bug before any release mutation runs.
*
* @param pomFile the POM file to scan
* @return violations found in properties; empty if clean
* @throws MojoException if the file cannot be read or parsed
*/
public static List<Violation> scanSourceProperties(File pomFile) {
Model model = parseModel(pomFile);
List<Violation> violations = new ArrayList<>();
collectSnapshotProperties(model.getProperties(), "properties",
pomFile, violations);
for (Profile profile : model.getProfiles()) {
collectSnapshotProperties(profile.getProperties(),
"profiles/" + profile.getId() + "/properties",
pomFile, violations);
}
return violations;
}
/**
* Scan a list of POMs for any {@code <version>...-SNAPSHOT</version>}
* in the consumer-POM-relevant contexts: {@code <parent>},
* {@code <dependencies>}, {@code <dependencyManagement>},
* {@code <build>/<plugins>}, {@code <build>/<pluginManagement>},
* and the same sections within every profile.
*
* <p>Intended for use <em>after</em>
* {@link ReleaseSupport#replaceProjectVersionRefs(File, String,
* org.apache.maven.api.plugin.Log)} has resolved
* {@code ${project.version}} to a literal.
*
* <p>Explicitly skips the module's own {@code <version>} element —
* that is handled by {@link ReleaseSupport#setPomVersion} and does
* not leak into the consumer POM as a stale SNAPSHOT.
*
* @param pomFiles POM files to scan
* @return violations found across all POMs; empty if clean
* @throws MojoException if any file cannot be read or parsed
*/
public static List<Violation> scanForSnapshotVersions(List<File> pomFiles) {
List<Violation> violations = new ArrayList<>();
for (File pom : pomFiles) {
Model model = parseModel(pom);
scanModel(pom, model, "", violations);
for (Profile profile : model.getProfiles()) {
scanProfile(pom, profile, violations);
}
}
return violations;
}
/**
* Format a list of violations as an aggregated multi-line message
* suitable for {@code MojoException} or preflight output.
*
* @param violations the violations to format (non-empty)
* @param gitRoot repo root for relative path display; may be null
* @param headline opening sentence (e.g. "Cannot release — ...")
* @param remedyHint closing instruction shown after the bullets
* @return a formatted multi-line error body
*/
public static String formatViolations(List<Violation> violations,
File gitRoot,
String headline,
String remedyHint) {
StringBuilder sb = new StringBuilder();
sb.append(headline).append("\n");
for (Violation v : violations) {
sb.append(v.toBullet(gitRoot)).append("\n");
}
sb.append(remedyHint);
return sb.toString();
}
// ── parser ────────────────────────────────────────────────────────
private static Model parseModel(File pomFile) {
try (Reader reader = Files.newBufferedReader(pomFile.toPath(),
StandardCharsets.UTF_8)) {
return new MavenStaxReader().read(reader);
} catch (IOException | XMLStreamException e) {
throw new MojoException(
"Failed to parse " + pomFile + ": " + e.getMessage(), e);
}
}
// ── properties collection ────────────────────────────────────────
private static void collectSnapshotProperties(Map<String, String> props,
String locationPrefix,
File pomFile,
List<Violation> into) {
if (props == null) return;
for (Map.Entry<String, String> entry : props.entrySet()) {
String value = entry.getValue();
if (value != null && value.endsWith(SNAPSHOT_SUFFIX)) {
into.add(new Violation(pomFile,
locationPrefix + "/" + entry.getKey(), value));
}
}
}
// ── model traversal for version scans ────────────────────────────
private static void scanModel(File pom, Model model, String prefix,
List<Violation> into) {
// <parent><version> — inherited parent reference
Parent parent = model.getParent();
if (parent != null && isSnapshot(parent.getVersion())) {
into.add(new Violation(pom,
prefix + "parent[" + coords(parent.getGroupId(),
parent.getArtifactId()) + "]",
parent.getVersion()));
}
// <dependencies><dependency><version>
scanDependencies(pom, model.getDependencies(),
prefix + "dependencies", into);
// <dependencyManagement><dependencies><dependency><version>
DependencyManagement dm = model.getDependencyManagement();
if (dm != null) {
scanDependencies(pom, dm.getDependencies(),
prefix + "dependencyManagement", into);
}
// <build><plugins><plugin><version>
if (model.getBuild() != null) {
scanPlugins(pom, model.getBuild().getPlugins(),
prefix + "build/plugins", into);
// <build><pluginManagement><plugins><plugin><version>
PluginManagement pm = model.getBuild().getPluginManagement();
if (pm != null) {
scanPlugins(pom, pm.getPlugins(),
prefix + "build/pluginManagement", into);
}
}
}
private static void scanProfile(File pom, Profile profile,
List<Violation> into) {
String prefix = "profiles/" + profile.getId() + "/";
scanDependencies(pom, profile.getDependencies(),
prefix + "dependencies", into);
DependencyManagement dm = profile.getDependencyManagement();
if (dm != null) {
scanDependencies(pom, dm.getDependencies(),
prefix + "dependencyManagement", into);
}
if (profile.getBuild() != null) {
scanPlugins(pom, profile.getBuild().getPlugins(),
prefix + "build/plugins", into);
PluginManagement pm = profile.getBuild().getPluginManagement();
if (pm != null) {
scanPlugins(pom, pm.getPlugins(),
prefix + "build/pluginManagement", into);
}
}
}
private static void scanDependencies(File pom, List<Dependency> deps,
String prefix,
List<Violation> into) {
if (deps == null) return;
for (Dependency dep : deps) {
if (isSnapshot(dep.getVersion())) {
into.add(new Violation(pom,
prefix + "/" + coords(dep.getGroupId(),
dep.getArtifactId()),
dep.getVersion()));
}
}
}
private static void scanPlugins(File pom, List<Plugin> plugins,
String prefix,
List<Violation> into) {
if (plugins == null) return;
for (Plugin plugin : plugins) {
if (isSnapshot(plugin.getVersion())) {
into.add(new Violation(pom,
prefix + "/" + coords(plugin.getGroupId(),
plugin.getArtifactId()),
plugin.getVersion()));
}
}
}
private static boolean isSnapshot(String version) {
return version != null && version.endsWith(SNAPSHOT_SUFFIX);
}
private static String coords(String groupId, String artifactId) {
if (groupId == null || groupId.isBlank()) {
return artifactId == null ? "?" : artifactId;
}
return groupId + ":" + artifactId;
}
}