VerifyReleasePublishedMojo.java
package network.ike.plugin;
import org.apache.maven.api.di.Inject;
import org.apache.maven.api.plugin.Log;
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.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Verify all post-release publication targets are reachable for a given
* project + version. Runs six read-only HTTP checks against the canonical
* post-release landing spots documented in {@code cutting-a-release.adoc}.
*
* <p>Replaces the operator's manual curl-around-six-URLs sequence with
* a single goal that hits everything in parallel and reports green/red
* for each. Composes into release scripts and CI: exits non-zero when
* any check fails so a shell pipeline can branch on success.
*
* <p>All checks are HTTP HEAD or GET against public URLs — no auth, no
* subprocess to {@code gh}, no Nexus credentials required. The goal is
* deliberately read-only and safe to run any time.
*
* <p>ike-issues#374.
*
* <p>Usage:
* <pre>
* mvn ike:verify-release-published
* mvn ike:verify-release-published -DprojectId=ike-tooling -Dversion=163
* </pre>
*/
@Mojo(name = "verify-release-published", projectRequired = false)
public class VerifyReleasePublishedMojo implements org.apache.maven.api.plugin.Mojo {
@Inject
Log log;
/** Access the Maven logger. */
Log getLog() { return log; }
/**
* Project artifactId being verified. Defaults to the current
* directory's pom.xml artifactId.
*/
@Parameter(property = "projectId")
String projectId;
/**
* Release version being verified. Defaults to the current pom.xml
* version with any {@code -SNAPSHOT} suffix stripped. For
* post-release worktrees (where pom.xml is already bumped to the
* next SNAPSHOT), pass the just-released version explicitly via
* {@code -Dversion=N}.
*/
@Parameter(property = "version")
String version;
/**
* Site base URL. The released site is expected at
* {@code <siteBase><projectId>/} and friends.
*/
@Parameter(property = "siteBase", defaultValue = "https://ike.network/")
String siteBase;
/**
* GitHub org slug. Used to look up the GitHub release and the
* source repo for the org-site landing page.
*/
@Parameter(property = "githubOrg", defaultValue = "IKE-Network")
String githubOrg;
/**
* Nexus repository base URL. The released artifact is expected at
* {@code <nexusBase><groupPath>/<projectId>/<version>/}.
*/
@Parameter(property = "nexusBase",
defaultValue = "https://nexus.tinkar.org/repository/ike-public/")
String nexusBase;
/**
* Filesystem path to the pom.xml whose coordinates default the
* other parameters. Override only when verifying a release from
* outside its own checkout.
*/
@Parameter(property = "pomFile", defaultValue = "${project.basedir}/pom.xml")
File pomFile;
/**
* Skip the GitHub release check. Useful when the release tag is
* already validated by another mechanism or when running offline.
*/
@Parameter(property = "skipGithubRelease", defaultValue = "false")
boolean skipGithubRelease;
/**
* Skip the gh-pages tree walk. The walk asks the GitHub API for
* the project's published {@code gh-pages} tree, enumerates every
* {@code index.html}, and HEAD-checks the corresponding URL. This
* is what catches submodule-publish gaps and depth-mismatch bugs
* the fixed-six-URL checks miss (e.g. ike-issues#358 #363
* regressions where some subsite under a multi-module reactor
* lands at the wrong depth and 404s). On by default.
*/
@Parameter(property = "skipGhPagesTreeWalk", defaultValue = "false")
boolean skipGhPagesTreeWalk;
/**
* Path-prefix patterns to skip during the gh-pages tree walk.
* Comma-separated. Defaults skip the auto-generated javadoc and
* source-xref trees (huge, page-by-page checking adds little
* value) and version dirs that aren't the current release
* (avoids N×N checks across release history).
*
* <p>Each entry is matched as a literal prefix against the
* gh-pages relative path of the index.html's containing
* directory. {@code "*"} matches any single path segment.
*/
@Parameter(property = "ghPagesTreeSkip",
defaultValue = "apidocs/,*/apidocs/,xref/,xref-test/,"
+ "*/xref/,*/xref-test/")
String ghPagesTreeSkip;
/**
* Skip the org-site landing page check. The org site updates
* asynchronously after the release tag is pushed; use this flag
* to verify a release before the org-site sync has run.
*/
@Parameter(property = "skipOrgSite", defaultValue = "false")
boolean skipOrgSite;
/**
* Skip the subproject topology cross-reference. The cross-
* reference reads the reactor pom's {@code <subprojects>} or
* legacy {@code <modules>}, and for each subproject HEAD-checks
* three canonical publish URLs: {@code <site-base><projectId>/<sub>/},
* {@code <site-base><projectId>/<version>/<sub>/}, and
* {@code <site-base><projectId>/latest/<sub>/}. Catches missing-
* from-tree paths the gh-pages tree walk can't see (the walk
* only verifies paths that DO exist; this verifies paths that
* SHOULD exist per the reactor topology). ike-issues#382.
*/
@Parameter(property = "skipSubprojectTopology", defaultValue = "false")
boolean skipSubprojectTopology;
/**
* Submodule names to skip in the subproject topology check.
* Comma-separated. Use when a declared subproject doesn't
* publish a site (rare in IKE; flag for build-only modules).
*/
@Parameter(property = "subprojectTopologySkip", defaultValue = "")
String subprojectTopologySkip;
/**
* HTTP request timeout per check, in seconds.
*/
@Parameter(property = "timeoutSeconds", defaultValue = "10")
int timeoutSeconds;
/** Creates this goal instance. */
public VerifyReleasePublishedMojo() {}
@Override
public void execute() throws MojoException {
resolveDefaults();
getLog().info("");
getLog().info("Verifying release " + projectId + " " + version);
getLog().info("");
List<CheckResult> results = runChecks();
getLog().info(" " + padRight("Target", 32)
+ padRight("Result", 10) + "URL");
getLog().info(" " + "─".repeat(32 + 10 + 40));
int failures = 0;
for (CheckResult r : results) {
String marker = r.ok ? "✓ ok" : (r.skipped ? "— skip" : "✗ FAIL");
getLog().info(" " + padRight(r.target, 32)
+ padRight(marker, 10) + r.url);
if (!r.ok && !r.skipped) failures++;
}
getLog().info("");
if (failures == 0) {
getLog().info("All checks passed.");
} else {
throw new MojoException(failures
+ " release verification check(s) failed. "
+ "See output above for details. ike-issues#374.");
}
}
/**
* Fill in unset parameters from {@link #pomFile}. Called once at
* the start of {@link #execute()}.
*
* @throws MojoException when the pom cannot be read and required
* parameters are not explicitly set
*/
void resolveDefaults() throws MojoException {
if (projectId == null || projectId.isBlank()) {
projectId = readPomField(pomFile, "artifactId");
}
if (version == null || version.isBlank()) {
String pomVersion = readPomField(pomFile, "version");
if (pomVersion != null) {
version = pomVersion.replaceFirst("-SNAPSHOT$", "");
}
}
if (projectId == null || projectId.isBlank()) {
throw new MojoException(
"Could not determine projectId. Pass -DprojectId=<id>.");
}
if (version == null || version.isBlank()) {
throw new MojoException(
"Could not determine version. Pass -Dversion=<N>.");
}
}
/**
* Run all six verification checks. Returns one result per check in
* the order they appear in the output table. Skipped checks (per
* {@link #skipGithubRelease}, {@link #skipOrgSite}) appear with
* {@code skipped=true} so the table is complete.
*/
List<CheckResult> runChecks() {
List<CheckResult> results = new ArrayList<>();
// Site: current at root
results.add(httpCheck("Site (current)",
siteBase + projectId + "/"));
// Site: versioned
results.add(httpCheck("Site (version " + version + ")",
siteBase + projectId + "/" + version + "/"));
// Site: latest alias
results.add(httpCheck("Site (latest)",
siteBase + projectId + "/latest/"));
// Org-site landing
if (skipOrgSite) {
results.add(CheckResult.skipped("Org-site landing", siteBase));
} else {
results.add(httpCheck("Org-site landing", siteBase));
}
// Nexus artifact
String groupPath = readPomGroupPath(pomFile);
String nexusUrl;
if (groupPath == null) {
nexusUrl = nexusBase;
results.add(CheckResult.fail("Nexus artifact",
nexusUrl,
"Could not read groupId from pom.xml — pass "
+ "-DnexusBase=<full URL to artifact dir>."));
} else {
nexusUrl = nexusBase + groupPath + "/" + projectId
+ "/" + version + "/" + projectId + "-"
+ version + ".pom";
results.add(httpCheck("Nexus artifact", nexusUrl));
}
// GitHub release
if (skipGithubRelease) {
results.add(CheckResult.skipped("GitHub release v" + version,
"(skipped)"));
} else {
String ghUrl = "https://api.github.com/repos/"
+ githubOrg + "/" + projectId
+ "/releases/tags/v" + version;
results.add(httpCheck("GitHub release v" + version, ghUrl));
}
// Subproject topology checks — cross-reference the reactor's
// <subprojects>/<modules> declarations against gh-pages.
// Asserts every (submodule × {root, /<version>/, /latest/})
// URL is reachable. Catches missing-from-tree paths that the
// gh-pages tree walk can't see (it only HEAD-checks paths
// already in the tree). ike-issues#382.
if (skipSubprojectTopology) {
results.add(CheckResult.skipped("Subproject topology",
"(skipped)"));
} else {
results.addAll(runSubprojectChecks());
}
// gh-pages tree walk — verify every published index.html is
// actually reachable. Catches submodule-publish gaps and
// depth-mismatch bugs that the fixed checks above miss.
if (skipGhPagesTreeWalk) {
results.add(CheckResult.skipped("gh-pages tree walk",
"(skipped)"));
} else {
results.addAll(runGhPagesTreeChecks());
}
return results;
}
/**
* Cross-reference the reactor's declared subprojects against
* gh-pages. For each subproject, HEAD-check the three canonical
* publish URLs:
* <ul>
* <li>{@code <site-base><projectId>/<sub>/} — current alias</li>
* <li>{@code <site-base><projectId>/<version>/<sub>/} — versioned</li>
* <li>{@code <site-base><projectId>/latest/<sub>/} — latest alias</li>
* </ul>
*
* <p>The submodule directory name is used as both the path
* segment and (by IKE convention) the artifactId. When a submodule
* doesn't match this convention or doesn't publish a site, list
* it in {@link #subprojectTopologySkip} to exclude.
*
* <p>Returns a header CheckResult followed by 3×N entries (one
* per submodule × canonical URL). Empty header-only result when
* the pom declares no subprojects (single-module project).
*
* <p>ike-issues#382.
*/
List<CheckResult> runSubprojectChecks() {
List<String> subs = readPomSubprojects(pomFile);
List<String> skipExplicit = parseSkipPatterns(subprojectTopologySkip);
String reactorUrlPrefix = siteBase + projectId + "/";
List<String> reactorSubs = new ArrayList<>();
List<String> independentSubs = new ArrayList<>();
File pomDir = pomFile == null ? null : pomFile.getParentFile();
for (String sub : subs) {
if (skipExplicit.contains(sub)) continue;
// Read the submodule's declared <site><url> to decide
// whether it publishes UNDER the reactor's gh-pages
// (aggregator-pom case: ike-platform/ike-parent) or as
// its OWN top-level gh-pages branch (workspace case:
// ike-example-ws/doc-example, where doc-example has its
// own repo and gh-pages branch under
// https://ike.network/doc-example/).
String subSiteUrl = null;
if (pomDir != null) {
File subPom = new File(new File(pomDir, sub), "pom.xml");
if (subPom.isFile()) {
subSiteUrl = readPomSiteUrlInterpolated(subPom, sub);
}
}
// Three cases:
// (1) Submodule declares its OWN <site> under reactor's
// URL prefix → in-reactor (ike-parent, ike-workspace-
// maven-plugin in ike-platform).
// (2) Submodule declares its OWN <site> NOT under
// reactor's prefix → independent (doc-example,
// example-project, its in ike-example-ws).
// (3) Submodule declares NO <site>, inherits from parent
// with default append-path=true → effectively
// under reactor's prefix (ike-bom in ike-platform).
if (subSiteUrl == null
|| subSiteUrl.startsWith(reactorUrlPrefix)) {
reactorSubs.add(sub);
} else {
independentSubs.add(sub);
}
}
List<CheckResult> results = new ArrayList<>();
String header = reactorSubs.size() + " in-reactor"
+ (independentSubs.isEmpty()
? ""
: ", " + independentSubs.size()
+ " independent-skip")
+ " × 3 URL(s)";
results.add(CheckResult.ok("Subproject topology", header));
for (String sub : reactorSubs) {
results.add(httpCheck(" " + sub + "/",
reactorUrlPrefix + sub + "/"));
results.add(httpCheck(" " + version + "/" + sub + "/",
reactorUrlPrefix + version + "/" + sub + "/"));
results.add(httpCheck(" latest/" + sub + "/",
reactorUrlPrefix + "latest/" + sub + "/"));
}
return results;
}
/**
* Read a submodule pom's {@code <distributionManagement><site><url>}
* with the two most-common Maven placeholders interpolated:
* {@code ${project.artifactId}} → the submodule's artifactId (or
* its directory name as a fallback) and {@code ${project.version}}
* → the {@link #version} parameter. Returns {@code null} when the
* pom doesn't declare a site URL.
*
* <p>Used by {@link #runSubprojectChecks()} to decide whether a
* declared subproject publishes under the reactor's gh-pages or
* as its own top-level branch.
*/
String readPomSiteUrlInterpolated(File subPom, String dirName) {
String content;
try {
content = Files.readString(subPom.toPath(),
StandardCharsets.UTF_8);
} catch (IOException e) {
return null;
}
int distOpen = content.indexOf("<distributionManagement>");
if (distOpen < 0) return null;
int distClose = content.indexOf("</distributionManagement>",
distOpen);
if (distClose < 0) return null;
String distBlock = content.substring(distOpen, distClose);
int siteOpen = distBlock.indexOf("<site");
if (siteOpen < 0) return null;
int siteClose = distBlock.indexOf("</site>", siteOpen);
if (siteClose < 0) return null;
String siteBlock = distBlock.substring(siteOpen, siteClose);
int urlOpen = siteBlock.indexOf("<url>");
if (urlOpen < 0) return null;
int urlClose = siteBlock.indexOf("</url>", urlOpen);
if (urlClose < 0) return null;
String raw = siteBlock.substring(urlOpen + "<url>".length(),
urlClose).trim();
String artifactId = readPomField(subPom, "artifactId");
if (artifactId == null || artifactId.isBlank()) {
artifactId = dirName;
}
return raw
.replace("${project.artifactId}", artifactId)
.replace("${project.version}", version);
}
/**
* Fetch the project's gh-pages tree from the GitHub API, find
* every {@code index.html}, derive the corresponding ike.network
* URL, and HEAD-check each. Skips paths matching
* {@link #ghPagesTreeSkip} prefixes and skips version-prefixed
* paths other than the current release (avoids N×N history
* checks).
*
* <p>Returns one {@code CheckResult} per checked URL — labeled by
* the gh-pages path so failures point at the specific file.
*/
List<CheckResult> runGhPagesTreeChecks() {
String apiUrl = "https://api.github.com/repos/"
+ githubOrg + "/" + projectId
+ "/git/trees/gh-pages?recursive=true";
String body;
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(timeoutSeconds))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(apiUrl))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("Accept", "application/vnd.github+json")
.GET()
.build();
HttpResponse<String> resp = client.send(req,
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
return List.of(CheckResult.fail("gh-pages tree walk",
apiUrl, "GitHub API HTTP " + resp.statusCode()));
}
body = resp.body();
} catch (Exception e) {
return List.of(CheckResult.fail("gh-pages tree walk",
apiUrl,
e.getClass().getSimpleName()
+ (e.getMessage() != null
? ": " + e.getMessage() : "")));
}
List<String> skipPrefixes = parseSkipPatterns(ghPagesTreeSkip);
List<String> indexHtmlPaths = extractIndexHtmlPaths(body);
List<String> urlsToCheck = new ArrayList<>();
for (String path : indexHtmlPaths) {
String containingDir = path.substring(0,
path.length() - "index.html".length());
// Strip trailing slash for prefix matching
String normalized = containingDir.endsWith("/")
? containingDir.substring(0, containingDir.length() - 1)
: containingDir;
if (shouldSkipPath(normalized, version, skipPrefixes)) continue;
String url = siteBase + projectId + "/" + containingDir;
urlsToCheck.add(url);
}
List<CheckResult> walkResults = new ArrayList<>();
// Summary first so the table has a marker even when 0 paths
// were eligible (e.g. fresh repo with no index.html outside
// the skipped trees).
walkResults.add(CheckResult.ok("gh-pages tree walk",
urlsToCheck.size() + " path(s) to check"));
for (String url : urlsToCheck) {
// Use short path-derived label for clarity in the table.
String relPath = url.substring(siteBase.length()
+ projectId.length() + 1);
if (relPath.isEmpty()) relPath = "/";
walkResults.add(httpCheck(" " + relPath, url));
}
return walkResults;
}
/**
* Pure-string extract of every {@code "path": "..."} value from
* GitHub's git/trees JSON response where the path ends with
* {@code index.html}. Inlined regex; the alternative is a JSON
* parser dependency the rest of the goal doesn't need.
*/
static List<String> extractIndexHtmlPaths(String body) {
if (body == null) return List.of();
List<String> result = new ArrayList<>();
// Match paths that are EXACTLY "index.html" at the root OR
// end with "/index.html" — exclude javadoc-style filenames
// like "apidocs/allclasses-index.html" that just happen to
// contain "index.html" as a suffix without a directory-
// boundary slash.
java.util.regex.Pattern p = java.util.regex.Pattern.compile(
"\"path\"\\s*:\\s*\"((?:[^\"]+?/)?index\\.html)\"");
java.util.regex.Matcher m = p.matcher(body);
while (m.find()) {
result.add(m.group(1));
}
return result;
}
/**
* Parse the comma-separated skip patterns into a list. Trims
* whitespace; drops empty entries.
*/
static List<String> parseSkipPatterns(String patterns) {
if (patterns == null || patterns.isBlank()) return List.of();
List<String> result = new ArrayList<>();
for (String entry : patterns.split(",")) {
String trimmed = entry.trim();
if (!trimmed.isEmpty()) result.add(trimmed);
}
return result;
}
/**
* Return {@code true} when the given path should be skipped per
* the rules: matches a literal or {@code *}-prefixed skip
* pattern, OR starts with a numeric version dir other than the
* current release (avoids N×N checks across release history).
*
* @param path the gh-pages path (without trailing slash)
* @param currentVersion the release version being verified
* @param skipPrefixes parsed prefix patterns from
* {@link #ghPagesTreeSkip}
*/
static boolean shouldSkipPath(String path, String currentVersion,
List<String> skipPrefixes) {
// Skip numeric-prefixed version dirs that aren't this release.
// Recognize a leading path segment that's all digits (or
// ends in "-checkpoint.<stuff>") as a version-snapshot dir.
int firstSlash = path.indexOf('/');
String firstSegment = firstSlash < 0 ? path
: path.substring(0, firstSlash);
if (looksLikeVersionSegment(firstSegment)
&& !firstSegment.equals(currentVersion)
&& !firstSegment.equals("latest")) {
return true;
}
// Apply user-configurable prefix patterns.
for (String pattern : skipPrefixes) {
if (matchesPrefix(pattern, path)) return true;
}
return false;
}
/**
* Check whether a path segment looks like a version snapshot
* directory. Pre-release / post-release IKE versions are
* single-segment integers (e.g. {@code 21}, {@code 165}) or
* checkpoint forms ({@code 7-checkpoint.20260228.1}).
*/
static boolean looksLikeVersionSegment(String segment) {
if (segment == null || segment.isEmpty()) return false;
if (!Character.isDigit(segment.charAt(0))) return false;
// Numeric prefix is enough — checkpoint suffixes start with a
// digit too. Anything starting with a digit at the gh-pages
// top level is a snapshot dir, not a sub-site.
return true;
}
/**
* Match a literal prefix pattern (or one beginning with
* {@code *}/) against a path. The {@code *} matches any single
* path segment.
*/
static boolean matchesPrefix(String pattern, String path) {
if (pattern.startsWith("*/")) {
// *<rest> matches any first segment followed by /<rest>
String rest = pattern.substring(2);
int firstSlash = path.indexOf('/');
if (firstSlash < 0) return false;
String afterFirst = path.substring(firstSlash + 1);
return afterFirst.startsWith(rest)
|| afterFirst.equals(rest.endsWith("/")
? rest.substring(0, rest.length() - 1)
: rest);
}
// Literal prefix
String normalizedPattern = pattern.endsWith("/")
? pattern.substring(0, pattern.length() - 1)
: pattern;
return path.startsWith(pattern)
|| path.equals(normalizedPattern);
}
/**
* Perform one HEAD-or-GET check against {@code url}. Returns
* {@code ok=true} for HTTP 2xx, {@code ok=false} otherwise (including
* timeouts, DNS failures, and connection refused). Tries HEAD first;
* if the server doesn't support HEAD (some Nexus / GitHub Pages
* configurations return 405), retries with GET.
*/
CheckResult httpCheck(String target, String url) {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(timeoutSeconds))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
for (String method : new String[]{"HEAD", "GET"}) {
try {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(timeoutSeconds))
.method(method, HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<Void> resp = client.send(req,
HttpResponse.BodyHandlers.discarding());
int code = resp.statusCode();
if (code >= 200 && code < 300) {
return CheckResult.ok(target, url);
}
if (code == 405 && "HEAD".equals(method)) {
continue; // try GET
}
return CheckResult.fail(target, url,
"HTTP " + code);
} catch (Exception e) {
if ("HEAD".equals(method)) continue; // try GET on transport error
return CheckResult.fail(target, url, e.getClass().getSimpleName()
+ (e.getMessage() != null ? ": " + e.getMessage() : ""));
}
}
// Both methods failed without producing a definitive result.
return CheckResult.fail(target, url, "no response");
}
/**
* Extract a top-level POM field value by name. Skips any preceding
* {@code <parent>} block so we don't return parent coordinates.
* Returns {@code null} when the field is absent or the file
* cannot be read.
*/
static String readPomField(File pom, String fieldName) {
if (pom == null || !pom.isFile()) return null;
try {
String content = Files.readString(pom.toPath(),
StandardCharsets.UTF_8);
int searchFrom = 0;
int parentOpen = content.indexOf("<parent>");
if (parentOpen >= 0) {
int parentClose = content.indexOf("</parent>", parentOpen);
if (parentClose > parentOpen) {
searchFrom = parentClose + "</parent>".length();
}
}
String openTag = "<" + fieldName + ">";
String closeTag = "</" + fieldName + ">";
int open = content.indexOf(openTag, searchFrom);
if (open < 0) return null;
int valueStart = open + openTag.length();
int close = content.indexOf(closeTag, valueStart);
if (close < 0) return null;
return content.substring(valueStart, close).trim();
} catch (IOException e) {
return null;
}
}
/**
* Read the pom's declared subprojects — both top-level
* {@code <subprojects><subproject>...</subproject></subprojects>}
* (Maven 4) and legacy {@code <modules><module>...</module></modules>}
* and including subprojects declared inside any
* {@code <profile>} block (file-activated submodule includes
* are the standard IKE workspace pattern).
*
* <p>Returns the declared directory names in source order, with
* duplicates removed. Empty list when the pom declares none
* (single-module project) or cannot be read.
*
* <p>Used by {@link #runSubprojectChecks()} for the #382 topology
* cross-reference.
*/
static List<String> readPomSubprojects(File pom) {
if (pom == null || !pom.isFile()) return List.of();
String content;
try {
content = Files.readString(pom.toPath(),
StandardCharsets.UTF_8);
} catch (IOException e) {
return List.of();
}
java.util.LinkedHashSet<String> result =
new java.util.LinkedHashSet<>();
for (String tag : new String[]{"subproject", "module"}) {
java.util.regex.Pattern p = java.util.regex.Pattern.compile(
"<" + tag + ">\\s*([^<\\s][^<]*?)\\s*</" + tag + ">");
java.util.regex.Matcher m = p.matcher(content);
while (m.find()) {
String name = m.group(1).trim();
if (!name.isEmpty()) result.add(name);
}
}
return new ArrayList<>(result);
}
/**
* Read the pom's groupId and convert it to a Nexus repo path
* (dots → slashes). Falls back to the parent groupId when the
* project itself doesn't declare one (the common case).
*/
static String readPomGroupPath(File pom) {
if (pom == null || !pom.isFile()) return null;
String groupId = readPomField(pom, "groupId");
if (groupId == null) {
// Try parent groupId
groupId = readParentField(pom, "groupId");
}
return groupId == null ? null : groupId.replace('.', '/');
}
/**
* Extract a field from the {@code <parent>} block of a POM, if
* present. Used as the groupId fallback when the project doesn't
* declare its own.
*/
static String readParentField(File pom, String fieldName) {
if (pom == null || !pom.isFile()) return null;
try {
String content = Files.readString(pom.toPath(),
StandardCharsets.UTF_8);
int parentOpen = content.indexOf("<parent>");
if (parentOpen < 0) return null;
int parentClose = content.indexOf("</parent>", parentOpen);
if (parentClose < 0) return null;
String parentBlock = content.substring(parentOpen, parentClose);
String openTag = "<" + fieldName + ">";
String closeTag = "</" + fieldName + ">";
int open = parentBlock.indexOf(openTag);
if (open < 0) return null;
int valueStart = open + openTag.length();
int close = parentBlock.indexOf(closeTag, valueStart);
if (close < 0) return null;
return parentBlock.substring(valueStart, close).trim();
} catch (IOException e) {
return null;
}
}
static String padRight(String s, int width) {
if (s.length() >= width) return s;
return s + " ".repeat(width - s.length());
}
/**
* One verification check's outcome.
*
* @param target short name shown in the output table
* @param url the URL that was checked
* @param ok {@code true} when the check passed
* @param skipped {@code true} when the check was skipped per a flag
* @param reason failure detail (HTTP status, exception class),
* {@code null} on success
*/
record CheckResult(String target, String url, boolean ok,
boolean skipped, String reason) {
static CheckResult ok(String target, String url) {
return new CheckResult(target, url, true, false, null);
}
static CheckResult fail(String target, String url, String reason) {
return new CheckResult(target, url, false, false, reason);
}
static CheckResult skipped(String target, String url) {
return new CheckResult(target, url, false, true, null);
}
}
}