ReleaseSupport.java
package network.ike.plugin;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.Log;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.nio.file.FileVisitResult;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
/**
* Shared utilities for release mojos.
*
* <p>All subprocess invocations use {@link ProcessBuilder} — no
* library dependencies beyond the JDK and maven-plugin-api.
*/
public class ReleaseSupport {
private static final Pattern VERSION_PATTERN =
Pattern.compile("<version>([^<]+)</version>");
private ReleaseSupport() {}
/**
* Check if the current platform is macOS.
*
* @return {@code true} if running on macOS or Darwin
*/
public static boolean isMacOS() {
String osName = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT);
return osName.contains("mac") || osName.contains("darwin");
}
/**
* Run a command, inherit IO so output streams to the Maven console.
* Throws on non-zero exit code.
*
* @param workDir working directory for the subprocess
* @param log Maven logger for output routing
* @param command the command and arguments to execute
* @throws MojoException if the command exits non-zero or cannot be started
*/
public static void exec(File workDir, Log log, String... command)
throws MojoException {
log.debug("» " + String.join(" ", command));
try {
Process proc = new ProcessBuilder(command)
.directory(workDir)
.redirectErrorStream(true)
.start();
// Route subprocess output through Maven's logger, stripping
// Maven log prefixes to avoid redundant [INFO] [stdout] [INFO].
// Maps subprocess [WARNING]/[ERROR] to the correct parent level.
try (var reader = new java.io.BufferedReader(
new java.io.InputStreamReader(proc.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
routeSubprocessLine(log, line);
}
}
int exit = proc.waitFor();
if (exit != 0) {
throw new MojoException(
"Command failed (exit " + exit + "): " +
String.join(" ", command));
}
} catch (IOException | InterruptedException e) {
throw new MojoException(
"Failed to execute: " + String.join(" ", command), e);
}
}
/**
* Route a subprocess output line through Maven's logger at the
* correct level. Strips Maven log prefixes ([INFO], [WARNING],
* [ERROR]) from the line to avoid redundant nesting.
*
* @param log Maven logger
* @param line raw subprocess output line
*/
public static void routeSubprocessLine(Log log, String line) {
routeSubprocessLine(log, line, "");
}
/**
* Route a subprocess output line through Maven's logger with a prefix.
*
* <p>Recognized prefixes are stripped and routed to the matching
* Maven log level. Unrecognized lines are routed to {@code info}
* so that subprocess activity (especially {@code git} operations
* during release) is visible in the build log.
*
* <p>Specific git error patterns ({@code fatal:}, {@code error:},
* {@code remote: error:}, {@code ! [rejected]}, {@code ! [remote rejected]})
* are detected explicitly and routed to {@code error} so they
* cannot be missed in the log. Earlier behavior routed all
* unprefixed output to {@code debug}, which silently hid
* gh-pages push failures (see {@code IKE-Network/ike-issues#329}).
*
* @param log Maven logger
* @param line raw subprocess output line
* @param prefix string prepended to each routed line
*/
public static void routeSubprocessLine(Log log, String line, String prefix) {
if (line.startsWith("[ERROR] ")) {
log.error(prefix + line.substring(8));
} else if (line.startsWith("[WARNING] ")) {
log.warn(prefix + line.substring(10));
} else if (line.startsWith("[INFO] ")) {
log.info(prefix + line.substring(7));
} else if (line.startsWith("[DEBUG] ")) {
log.debug(prefix + line.substring(8));
} else if (line.startsWith("WARNING: ")) {
// JVM-style warnings (e.g., sun.misc.Unsafe deprecation)
log.warn(prefix + line.substring(9));
} else if (line.startsWith("ERROR: ")) {
// JVM-style errors
log.error(prefix + line.substring(7));
} else if (line.startsWith("fatal: ")
|| line.startsWith("error: ")
|| line.startsWith("remote: error:")
|| line.startsWith("remote: fatal:")
|| line.startsWith("! [rejected]")
|| line.startsWith("! [remote rejected]")) {
// Git error patterns — must be visible. Without these,
// a failed `git push` was effectively silent because the
// exit-code-only signal got swallowed by the catching
// wrapper that logged only e.getMessage().
log.error(prefix + line);
} else {
// Plain subprocess output (e.g., git push success indicators
// "remote: ...", "To <url>", "* [new branch] X -> Y").
// Earlier behavior was log.debug — hid both successes and
// any unrecognized failures. Route to info for visibility.
log.info(prefix + line);
}
}
/**
* A command paired with a display label for parallel execution.
*
* @param label human-readable name shown in log output
* @param command the command and arguments to execute
*/
public record LabeledTask(String label, String[] command) {}
/**
* Run multiple commands concurrently, prefixing each line of output
* with the task's label (e.g., {@code [nexus] ...}).
*
* <p>Spawns virtual threads to read stdout/stderr from each process.
* All processes run to completion even if one fails — the exception
* reports which task(s) failed.
*
* @param workDir working directory for each subprocess
* @param log Maven logger for output routing
* @param tasks the labeled tasks to run concurrently
* @throws MojoException if any task fails or execution is interrupted
*/
public static void execParallel(File workDir, Log log, LabeledTask... tasks)
throws MojoException {
for (LabeledTask task : tasks) {
log.debug("» [" + task.label() + "] " + String.join(" ", task.command()));
}
List<String> failures = new CopyOnWriteArrayList<>();
List<Thread> threads = new ArrayList<>();
for (LabeledTask task : tasks) {
Thread thread = Thread.ofVirtual()
.name("exec-" + task.label())
.start(() -> {
try {
Process process = new ProcessBuilder(task.command())
.directory(workDir)
.redirectErrorStream(true)
.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String prefix = "[" + task.label() + "] ";
synchronized (log) {
routeSubprocessLine(log, line, prefix);
}
}
}
int exit = process.waitFor();
if (exit != 0) {
failures.add(task.label() + " (exit " + exit + ")");
}
} catch (IOException | InterruptedException e) {
failures.add(task.label() + " (" + e.getMessage() + ")");
}
});
threads.add(thread);
}
try {
for (Thread thread : threads) {
thread.join();
}
} catch (InterruptedException e) {
throw new MojoException("Parallel execution interrupted", e);
}
if (!failures.isEmpty()) {
throw new MojoException(
"Parallel tasks failed: " + String.join(", ", failures));
}
}
/**
* Run a command and capture stdout as a trimmed String.
* Throws on non-zero exit code.
*
* @param workDir working directory for the subprocess
* @param command the command and arguments to execute
* @return trimmed stdout output
* @throws MojoException if the command exits non-zero or cannot be started
*/
public static String execCapture(File workDir, String... command)
throws MojoException {
try {
Process process = new ProcessBuilder(command)
.directory(workDir)
.redirectErrorStream(false)
.start();
String output;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(),
StandardCharsets.UTF_8))) {
output = reader.lines().collect(Collectors.joining("\n")).trim();
}
int exit = process.waitFor();
if (exit != 0) {
throw new MojoException(
"Command failed (exit " + exit + "): " +
String.join(" ", command));
}
return output;
} catch (IOException | InterruptedException e) {
throw new MojoException(
"Failed to execute: " + String.join(" ", command), e);
}
}
/**
* Run a command, streaming output through Maven's logger AND
* capturing the full output as a String. Throws on non-zero exit.
*
* @param workDir working directory for the subprocess
* @param log Maven logger for real-time output
* @param command the command and arguments to execute
* @return the complete stdout+stderr output as a trimmed string
* @throws MojoException if the command exits non-zero
*/
public static String execCaptureAndLog(File workDir, Log log, String... command)
throws MojoException {
log.debug("» " + String.join(" ", command));
try {
Process proc = new ProcessBuilder(command)
.directory(workDir)
.redirectErrorStream(true)
.start();
StringBuilder captured = new StringBuilder();
try (var reader = new BufferedReader(
new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
routeSubprocessLine(log, line);
captured.append(line).append('\n');
}
}
int exit = proc.waitFor();
if (exit != 0) {
String output = captured.toString().trim();
String detail = output.isEmpty()
? ""
: "\nOutput:\n" + output;
throw new MojoException(
"Command failed (exit " + exit + "): "
+ String.join(" ", command) + detail);
}
return captured.toString().trim();
} catch (IOException | InterruptedException e) {
throw new MojoException(
"Failed to execute: " + String.join(" ", command), e);
}
}
/**
* Read the project's own {@code <version>} from a POM file,
* skipping any {@code <version>} inside the {@code <parent>} block.
*
* @param pomFile the POM file to read
* @return the version string
* @throws MojoException if the file cannot be read or has no version
*/
public static String readPomVersion(File pomFile) throws MojoException {
try {
String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
// Strip the <parent>...</parent> block so we don't match
// the parent version instead of the project version.
String stripped = content.replaceFirst(
"(?s)<parent>.*?</parent>", "");
Matcher matcher = VERSION_PATTERN.matcher(stripped);
if (matcher.find()) {
return matcher.group(1);
}
throw new MojoException(
"Could not extract <version> from " + pomFile);
} catch (IOException e) {
throw new MojoException("Failed to read " + pomFile, e);
}
}
/**
* Stamp {@code <project.build.outputTimestamp>} in the root POM to
* {@code newTimestamp}, enabling reproducible builds for the release.
*
* <p>The property must already exist in the POM (inherited from
* ike-parent). If it is absent this method is a no-op with a warning.
*
* @param pomFile the root POM to update
* @param newTimestamp ISO-8601 UTC timestamp, e.g. {@code 2026-03-30T12:00:00Z}
* @param log Maven log (used for warnings only)
* @throws MojoException if the file cannot be read or written
*/
public static void stampOutputTimestamp(File pomFile, String newTimestamp, Log log)
throws MojoException {
try {
String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
java.util.regex.Pattern pat = java.util.regex.Pattern.compile(
"(<project\\.build\\.outputTimestamp>)[^<]*(</project\\.build\\.outputTimestamp>)");
java.util.regex.Matcher m = pat.matcher(content);
if (!m.find()) {
log.warn("project.build.outputTimestamp not found in " + pomFile
+ " — reproducible build stamp skipped");
return;
}
String updated = m.replaceFirst("$1" + newTimestamp + "$2");
Files.writeString(pomFile.toPath(), updated, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException(
"Failed to stamp outputTimestamp in " + pomFile, e);
}
}
/**
* Replace the project's own {@code <version>old</version>} with
* {@code <version>new</version>}, skipping any version inside
* the {@code <parent>} block.
*
* @param pomFile the POM file to update
* @param oldVersion the current version string to replace
* @param newVersion the new version string
* @throws MojoException if the version is not found or the file cannot be updated
*/
public static void setPomVersion(File pomFile, String oldVersion, String newVersion)
throws MojoException {
try {
String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
String oldTag = "<version>" + oldVersion + "</version>";
String newTag = "<version>" + newVersion + "</version>";
// Find the end of the <parent> block (if any) so we skip it
int searchStart = 0;
Matcher parentEnd = Pattern.compile("</parent>").matcher(content);
if (parentEnd.find()) {
searchStart = parentEnd.end();
}
int idx = content.indexOf(oldTag, searchStart);
if (idx < 0) {
throw new MojoException(
"POM does not contain " + oldTag +
" (outside <parent> block)");
}
String updated = content.substring(0, idx) + newTag +
content.substring(idx + oldTag.length());
Files.writeString(pomFile.toPath(), updated, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException("Failed to update " + pomFile, e);
}
}
/**
* Check if the current platform is Windows.
*
* @return {@code true} if {@code os.name} contains "win"
*/
public static boolean isWindows() {
return System.getProperty("os.name", "")
.toLowerCase(java.util.Locale.ROOT).contains("win");
}
/**
* Resolve the Maven executable. Prefers the Maven wrapper
* ({@code mvnw} on Unix, {@code mvnw.cmd} on Windows) at the
* git root; falls back to system Maven located via {@code which}
* on Unix or {@code where} on Windows.
*
* @param gitRoot the git repository root directory
* @param log Maven logger
* @return the resolved Maven executable
* @throws MojoException if neither wrapper nor system Maven is found
*/
public static File resolveMavenWrapper(File gitRoot, Log log) throws MojoException {
return resolveMavenWrapperFor(gitRoot, log, isWindows());
}
/**
* OS-injected variant of {@link #resolveMavenWrapper(File, Log)} for testing.
* Production callers should use the two-argument overload.
*
* @param gitRoot the git repository root directory
* @param log Maven logger
* @param windows {@code true} to use Windows wrapper/lookup conventions,
* {@code false} for Unix conventions
* @return the resolved Maven executable
* @throws MojoException if neither wrapper nor system Maven is found
*/
static File resolveMavenWrapperFor(File gitRoot, Log log, boolean windows)
throws MojoException {
String wrapperName = windows ? "mvnw.cmd" : "mvnw";
File wrapper = new File(gitRoot, wrapperName);
if (wrapper.exists()) {
return wrapper;
}
// Fall back to system mvn — resolve via PATH
String systemName = windows ? "mvn.cmd" : "mvn";
String lookupTool = windows ? "where" : "which";
try {
String output = execCapture(gitRoot, lookupTool, systemName);
String path = firstNonEmptyLine(output);
log.info("No Maven wrapper found; using system '" + path + "'");
return new File(path);
} catch (MojoException _) {
throw new MojoException(
"Neither Maven wrapper (" + wrapper.getAbsolutePath() +
") nor system '" + systemName + "' found on PATH.");
}
}
/**
* Return the first non-empty line of {@code output}, trimmed.
* Handles the Windows {@code where} command, which may emit multiple
* matches separated by newlines (e.g. {@code mvn.cmd} from a wrapper
* shim and from a system install).
*
* @param output multi-line command output
* @return first non-empty line trimmed, or the trimmed full output
* if no non-empty line exists
*/
static String firstNonEmptyLine(String output) {
return output.lines()
.map(String::trim)
.filter(s -> !s.isEmpty())
.findFirst()
.orElse(output.trim());
}
/**
* Get the git repository root directory.
*
* @param startDir any directory inside the repository
* @return the repository root directory
* @throws MojoException if git rev-parse fails
*/
public static File gitRoot(File startDir) throws MojoException {
try {
String root = execCapture(startDir,
"git", "rev-parse", "--show-toplevel");
return new File(root);
} catch (MojoException e) {
String absPath;
try {
absPath = startDir.getCanonicalPath();
} catch (IOException io) {
absPath = startDir.getAbsolutePath();
}
throw new MojoException(
"Could not resolve git repository root from "
+ absPath
+ ". `git rev-parse --show-toplevel` failed."
+ " If you invoked this goal with `-pl <module>`"
+ " from a parent that is itself a git repo and"
+ " the subproject was newly synced via Syncthing,"
+ " its `.git/` directory may not yet exist; run"
+ " the repo's `_git-init.sh` (or `git init &&"
+ " git remote add origin ...`) first. Or `cd`"
+ " into the subproject and run the goal directly."
+ " ike-issues#357.", e);
}
}
/**
* Assert that the git working tree is clean (no staged or unstaged changes).
*
* @param workDir any directory inside the repository
* @throws MojoException if the working tree has uncommitted changes
*/
public static void requireCleanWorktree(File workDir) throws MojoException {
try {
execCapture(workDir, "git", "diff", "--quiet");
} catch (MojoException _) {
throw new MojoException(
"Working tree has unstaged changes. Commit or stash before proceeding.");
}
try {
execCapture(workDir, "git", "diff", "--cached", "--quiet");
} catch (MojoException _) {
throw new MojoException(
"Working tree has staged changes. Commit or stash before proceeding.");
}
}
/**
* Get the current git branch name.
*
* @param workDir any directory inside the repository
* @return the current branch name
* @throws MojoException if git rev-parse fails
*/
public static String currentBranch(File workDir) throws MojoException {
return execCapture(workDir, "git", "rev-parse", "--abbrev-ref", "HEAD");
}
/**
* Check whether a named git remote exists.
*
* @param workDir any directory inside the repository
* @param remoteName the remote name to check (e.g., "origin")
* @return {@code true} if the remote exists
*/
public static boolean hasRemote(File workDir, String remoteName) {
try {
String remotes = execCapture(workDir, "git", "remote");
return remotes.lines().anyMatch(line -> line.trim().equals(remoteName));
} catch (MojoException _) {
return false;
}
}
/**
* Return the URL of a named git remote, or null if the remote does
* not exist.
*
* @param workDir any directory inside the repository
* @param remoteName the remote name (typically {@code "origin"})
* @return the remote URL, or null if the remote is absent
*/
public static String getRemoteUrl(File workDir, String remoteName) {
try {
String url = execCapture(workDir,
"git", "remote", "get-url", remoteName);
return url.isBlank() ? null : url.trim();
} catch (MojoException _) {
return null;
}
}
/**
* Derive the release version from a SNAPSHOT version.
* {@code "2-SNAPSHOT"} becomes {@code "2"};
* {@code "1.1.0-SNAPSHOT"} becomes {@code "1.1.0"}.
*
* @param snapshotVersion the SNAPSHOT version string
* @return the release version without the -SNAPSHOT suffix
*/
public static String deriveReleaseVersion(String snapshotVersion) {
return snapshotVersion.replace("-SNAPSHOT", "");
}
/**
* Derive the next SNAPSHOT version by incrementing the last numeric
* segment. {@code "2"} becomes {@code "3-SNAPSHOT"};
* {@code "1.1.0"} becomes {@code "1.1.1-SNAPSHOT"}.
*
* @param releaseVersion the release version to increment
* @return the next SNAPSHOT version
*/
public static String deriveNextSnapshot(String releaseVersion) {
String base = releaseVersion.replace("-SNAPSHOT", "");
int lastDot = base.lastIndexOf('.');
if (lastDot >= 0) {
String prefix = base.substring(0, lastDot + 1);
String last = base.substring(lastDot + 1);
return prefix + (Integer.parseInt(last) + 1) + "-SNAPSHOT";
}
// Simple integer version (e.g., "2" -> "3-SNAPSHOT")
return (Integer.parseInt(base) + 1) + "-SNAPSHOT";
}
/**
* Update a named Maven property in POM content.
* Replaces {@code <propertyName>oldValue</propertyName>} with
* {@code <propertyName>newVersion</propertyName>}.
*
* @param pomContent the POM file content as a string
* @param propertyName the Maven property name (e.g., "ike-bom.version")
* @param newVersion the new version value
* @return the updated POM content (unchanged if property not found)
*/
public static String updateVersionProperty(String pomContent,
String propertyName,
String newVersion) {
String propPattern = "<" + java.util.regex.Pattern.quote(propertyName)
+ ">[^<]+</" + java.util.regex.Pattern.quote(propertyName) + ">";
return pomContent.replaceAll(propPattern,
"<" + propertyName + ">" + newVersion + "</" + propertyName + ">");
}
private static final String PROJECT_VERSION_EXPR = "${project.version}";
private static final String BACKUP_SUFFIX = ".ike-backup";
/**
* Find all {@code pom.xml} files under the git root, excluding
* {@code target/} directories and the {@code .mvn/} directory.
*
* @param gitRoot the git repository root directory
* @return list of discovered POM files
* @throws MojoException if the file tree cannot be walked
*/
public static List<File> findPomFiles(File gitRoot) throws MojoException {
try (Stream<Path> walk = Files.walk(gitRoot.toPath())) {
return walk
.filter(p -> p.getFileName().toString().equals("pom.xml"))
.filter(p -> {
String rel = gitRoot.toPath().relativize(p).toString();
return !rel.contains("target" + File.separator)
&& !rel.startsWith(".mvn" + File.separator);
})
.map(Path::toFile)
.collect(Collectors.toList());
} catch (IOException e) {
throw new MojoException("Failed to scan for POM files", e);
}
}
/**
* Replace all occurrences of {@code ${project.version}} with a
* literal version string in every POM file under the git root.
* Before replacing, each affected file is saved as
* {@code pom.xml.ike-backup} so it can be restored later.
*
* @param gitRoot the git repository root directory
* @param version the literal version to substitute
* @param log Maven logger
* @return the list of POM files that were modified
* @throws MojoException if a file cannot be read or written
*/
public static List<File> replaceProjectVersionRefs(File gitRoot, String version,
Log log)
throws MojoException {
List<File> pomFiles = findPomFiles(gitRoot);
List<File> modified = new ArrayList<>();
for (File pom : pomFiles) {
try {
String content = Files.readString(pom.toPath(), StandardCharsets.UTF_8);
if (!content.contains(PROJECT_VERSION_EXPR)) {
continue;
}
// Save backup before modifying
Path backup = pom.toPath().resolveSibling(pom.getName() + BACKUP_SUFFIX);
Files.copy(pom.toPath(), backup, StandardCopyOption.REPLACE_EXISTING);
// Replace all occurrences
String updated = content.replace(PROJECT_VERSION_EXPR, version);
Files.writeString(pom.toPath(), updated, StandardCharsets.UTF_8);
String rel = gitRoot.toPath().relativize(pom.toPath()).toString();
log.info(" Resolved ${project.version} -> " + version +
" in " + rel);
modified.add(pom);
} catch (IOException e) {
throw new MojoException(
"Failed to process " + pom, e);
}
}
return modified;
}
/**
* Restore all POM files from their {@code .ike-backup} copies and
* delete the backup files. This reverses
* {@link #replaceProjectVersionRefs}.
*
* @param gitRoot the git repository root directory
* @param log Maven logger
* @return the list of POM files that were restored
* @throws MojoException if a backup cannot be restored
*/
public static List<File> restoreBackups(File gitRoot, Log log)
throws MojoException {
List<File> pomFiles = findPomFiles(gitRoot);
List<File> restored = new ArrayList<>();
for (File pom : pomFiles) {
Path backup = pom.toPath().resolveSibling(pom.getName() + BACKUP_SUFFIX);
if (!Files.exists(backup)) {
continue;
}
try {
Files.copy(backup, pom.toPath(), StandardCopyOption.REPLACE_EXISTING);
Files.delete(backup);
String rel = gitRoot.toPath().relativize(pom.toPath()).toString();
log.info(" Restored ${project.version} in " + rel);
restored.add(pom);
} catch (IOException e) {
throw new MojoException(
"Failed to restore backup for " + pom, e);
}
}
return restored;
}
/**
* Stage a list of files with {@code git add}.
*
* @param gitRoot the git repository root directory
* @param log Maven logger
* @param files the files to stage
* @throws MojoException if the git add command fails
*/
public static void gitAddFiles(File gitRoot, Log log, List<File> files)
throws MojoException {
if (files.isEmpty()) return;
List<String> command = new ArrayList<>();
command.add("git");
command.add("add");
for (File f : files) {
command.add(gitRoot.toPath().relativize(f.toPath()).toString());
}
exec(gitRoot, log, command.toArray(new String[0]));
}
private static final DateTimeFormatter CHECKPOINT_DATE_FMT =
DateTimeFormatter.ofPattern("yyyyMMdd");
/**
* Derive a checkpoint version from the current POM version.
*
* <p>Format: {@code {base}-checkpoint.{yyyyMMdd}.{shortSha}} where
* {@code base} is the POM version minus {@code -SNAPSHOT}, and
* {@code shortSha} is the abbreviated SHA of the current HEAD commit.
*
* <p>This scheme is fully deterministic — the same commit on any
* machine always produces the same version string. No tag-sequence
* coordination across machines is required.
*
* @param pomVersion current POM version (may include -SNAPSHOT)
* @param gitRoot git repository root (for HEAD SHA lookup)
* @return the checkpoint version string
* @throws MojoException if the HEAD SHA cannot be resolved
*/
public static String deriveCheckpointVersion(String pomVersion, File gitRoot)
throws MojoException {
String base = pomVersion.replace("-SNAPSHOT", "");
String date = LocalDate.now().format(CHECKPOINT_DATE_FMT);
String shortSha = execCapture(gitRoot, "git", "rev-parse", "--short", "HEAD");
return base + "-checkpoint." + date + "." + shortSha;
}
/**
* Check whether a git tag exists (locally).
*
* @param gitRoot the git repository root directory
* @param tagName the tag name to check
* @return {@code true} if the tag exists locally
*/
public static boolean tagExists(File gitRoot, String tagName) {
try {
execCapture(gitRoot, "git", "rev-parse", "--verify", "refs/tags/" + tagName);
return true;
} catch (MojoException _) {
return false;
}
}
/** Base path on the site server. */
public static final String SITE_DISK_BASE = "/srv/ike-site/";
/** SSH host alias used by wagon-ssh-external. */
public static final String SITE_SSH_HOST = "proxy";
/**
* Remove a directory tree on the site server via SSH.
*
* <p>Used to clean up snapshot sites after release or feature-finish.
*
* <p>Safety: validates the path starts with {@link #SITE_DISK_BASE}
* and contains at least two path components after the base to
* prevent accidental deletion of the entire site root.
*
* @param workDir local directory for process execution
* @param log Maven log
* @param remotePath absolute path on the server (e.g.,
* {@code /srv/ike-site/ike-pipeline/snapshot/main})
* @throws MojoException if the path is unsafe or SSH fails
*/
public static void cleanRemoteSiteDir(File workDir, Log log, String remotePath)
throws MojoException {
cleanRemoteSiteDir(workDir, log, remotePath, "ssh", SITE_SSH_HOST);
}
/**
* Overload accepting an explicit SSH command prefix — package-private
* for testing against containers.
*
* @param workDir local directory for process execution
* @param log Maven log
* @param remotePath absolute path on the server to remove
* @param sshPrefix the SSH command tokens (e.g., "ssh", "-i", "key",
* "-p", "2222", "user@localhost")
* @throws MojoException if the path is unsafe or SSH fails
*/
public static void cleanRemoteSiteDir(File workDir, Log log, String remotePath,
String... sshPrefix)
throws MojoException {
validateRemotePath(remotePath);
log.debug("Cleaning remote site: " + remotePath);
String[] cmd = new String[sshPrefix.length + 3];
System.arraycopy(sshPrefix, 0, cmd, 0, sshPrefix.length);
cmd[sshPrefix.length] = "rm";
cmd[sshPrefix.length + 1] = "-rf";
cmd[sshPrefix.length + 2] = remotePath;
exec(workDir, log, cmd);
}
/**
* Atomically swap a newly deployed site into place on the server.
*
* <p>The deployment flow is:
* <ol>
* <li>SCP deploys to a staging path ({@code <target>.staging})</li>
* <li>This method renames the old directory to {@code <target>.old}</li>
* <li>Renames the staging directory to the final target</li>
* <li>Removes the old directory</li>
* </ol>
*
* <p>This avoids a window where the site is missing (rm + deploy)
* and ensures the site always serves either the old or new version.
*
* @param workDir local directory for process execution
* @param log Maven log
* @param remotePath final target path on the server
* @throws MojoException if SSH commands fail
*/
public static void swapRemoteSiteDir(File workDir, Log log, String remotePath)
throws MojoException {
swapRemoteSiteDir(workDir, log, remotePath, "ssh", SITE_SSH_HOST);
}
/**
* Overload accepting an explicit SSH command prefix — package-private
* for testing against containers.
*
* @param workDir local directory for process execution
* @param log Maven log
* @param remotePath final target path on the server
* @param sshPrefix the SSH command tokens (e.g., "ssh", "-i", "key",
* "-p", "2222", "user@localhost")
* @throws MojoException if the path is unsafe or SSH fails
*/
public static void swapRemoteSiteDir(File workDir, Log log, String remotePath,
String... sshPrefix)
throws MojoException {
validateRemotePath(remotePath);
String staging = remotePath + ".staging";
String old = remotePath + ".old";
log.info("Swapping site: " + staging + " → " + remotePath);
String[] cmd = new String[sshPrefix.length + 1];
System.arraycopy(sshPrefix, 0, cmd, 0, sshPrefix.length);
cmd[sshPrefix.length] = "rm -rf " + old
+ " && (mv " + remotePath + " " + old + " 2>/dev/null || true)"
+ " && mv " + staging + " " + remotePath
+ " && rm -rf " + old;
exec(workDir, log, cmd);
}
/**
* Return the staging path for a site deploy (final path + ".staging").
*
* @param diskPath the final on-disk site path
* @return {@code diskPath} with {@code .staging} appended
*/
public static String siteStagingPath(String diskPath) {
return diskPath + ".staging";
}
/**
* Update the {@code latest} symlink alongside a version-prefixed
* site deploy so that {@code <site-base>/latest/} always points at
* the most recent release (ike-issues#303).
*
* <p>For a release deployed to
* {@code /srv/ike-site/ike-platform/17/}, this issues
* {@code cd /srv/ike-site/ike-platform && ln -snf 17 latest} on the
* site host. Idempotent — the {@code -f} flag replaces any prior
* symlink target.
*
* <p>Uses the same SSH host as {@link #swapRemoteSiteDir}.
* Best-effort: callers should catch {@link MojoException} and
* surface as a warning rather than failing the release — the
* version-prefixed site is reachable at its own URL even if the
* alias update fails.
*
* @param workDir local directory for process execution
* @param log Maven log
* @param remotePath the version-prefixed final site path
* (e.g. {@code /srv/ike-site/ike-platform/17})
* @throws MojoException if the path is unsafe or SSH fails
*/
public static void updateLatestSymlink(File workDir, Log log,
String remotePath)
throws MojoException {
updateLatestSymlink(workDir, log, remotePath, "ssh", SITE_SSH_HOST);
}
/**
* Overload accepting an explicit SSH command prefix —
* package-private for testing against containers.
*
* @param workDir local directory for process execution
* @param log Maven log
* @param remotePath the version-prefixed final site path
* @param sshPrefix the SSH command tokens
* @throws MojoException if the path is unsafe or SSH fails
*/
public static void updateLatestSymlink(File workDir, Log log,
String remotePath,
String... sshPrefix)
throws MojoException {
validateRemotePath(remotePath);
String parent = parentDir(remotePath);
String leaf = leafName(remotePath);
if (parent == null || leaf == null || leaf.isEmpty()) {
throw new MojoException(
"Cannot derive parent/leaf from site path: " + remotePath);
}
log.info("Updating latest symlink: " + parent + "/latest -> " + leaf);
String[] cmd = new String[sshPrefix.length + 1];
System.arraycopy(sshPrefix, 0, cmd, 0, sshPrefix.length);
cmd[sshPrefix.length] = "cd " + parent
+ " && ln -snf " + leaf + " latest";
exec(workDir, log, cmd);
}
/**
* Compute the parent directory of a Unix-style absolute path
* without crossing the {@link #SITE_DISK_BASE} boundary. Returns
* {@code null} when the input is at or above the base.
*
* <p>Package-private for testing.
*/
static String parentDir(String absPath) {
int lastSlash = absPath.lastIndexOf('/');
if (lastSlash <= 0) return null;
String parent = absPath.substring(0, lastSlash);
return parent.startsWith(SITE_DISK_BASE.replaceAll("/$", ""))
? parent : null;
}
/**
* Last path segment of a Unix-style absolute path — the basename.
* Trailing slashes are tolerated.
*
* <p>Package-private for testing.
*/
static String leafName(String absPath) {
String trimmed = absPath.endsWith("/")
? absPath.substring(0, absPath.length() - 1) : absPath;
int lastSlash = trimmed.lastIndexOf('/');
return lastSlash < 0 ? trimmed : trimmed.substring(lastSlash + 1);
}
/**
* Return the scpexe URL for the staging directory.
*
* @param targetUrl the final site URL
* @return {@code targetUrl} with {@code .staging} appended
*/
public static String siteStagingUrl(String targetUrl) {
return targetUrl + ".staging";
}
/**
* Publish a project's rendered site to its repo's {@code gh-pages}
* branch using the hybrid structure (ike-issues#312, #332).
*
* <p>Layout produced after each release:
* <ul>
* <li>{@code /} — the just-released version's site at the root
* (so {@code https://ike.network/<repo>/} serves the current
* release, the same as before).</li>
* <li>{@code /<version>/} — the just-released version preserved
* under a versioned subdirectory for citations and
* reproducibility.</li>
* <li>{@code /latest/} — a copy of the just-released version
* under the canonical "latest" path. Not a git symlink:
* GitHub Pages doesn't follow them reliably.</li>
* <li>Earlier {@code /<version>/} subdirectories from prior
* releases are preserved unchanged.</li>
* </ul>
*
* <p>Mechanics: the existing {@code gh-pages} branch is cloned
* (preserving full history including all prior {@code <version>/}
* subdirs); root-level files and non-version root subdirs are
* wiped (stale assets from the previous release shouldn't linger);
* staging is copied to root, to {@code <version>/}, and to
* {@code latest/}; an additive commit is pushed (no
* {@code --force}). On first-time publish (no {@code gh-pages}
* branch yet) the bootstrap path uses {@code git checkout
* --orphan} and force-push.
*
* <p>Adds a {@code .nojekyll} marker so GitHub Pages skips Jekyll
* processing — the content is already rendered HTML and we don't
* want underscore-prefixed directories to be stripped.
*
* <p>Does NOT write a {@code CNAME} file: the org-level
* {@code IKE-Network.github.io/CNAME} (set to {@code ike.network})
* extends to all project pages under the org automatically. A
* per-project CNAME would either be ignored or conflict.
*
* <p>Patterned on {@code OrgSiteSupport.publishToGhPages} (in
* the ike-maven-plugin module) but generalized to any project's
* staging dir + remote. ike-workspace-model can't {@code @link}
* directly to ike-maven-plugin classes — it sits below in the
* dependency stack, so they're not on its javadoc classpath.
*
* @param stagingDir directory containing the rendered site
* (typically {@code target/staging/})
* @param repoUrl git URL of the project repo to push to
* @param log Maven logger
* @param projectId project artifact ID, used in the commit message
* @param version release version, used in the commit message
* @throws MojoException if any step fails
*/
public static void publishProjectSiteToGhPages(Path stagingDir,
String repoUrl,
Log log,
String projectId,
String version)
throws MojoException {
if (!Files.isDirectory(stagingDir)) {
throw new MojoException(
"Staging directory does not exist: " + stagingDir
+ ". Site build may have failed.");
}
// Empty-staging guard (ike-issues#334). Earlier behavior: an
// empty-but-existing dir passed isDirectory() and shipped a
// .nojekyll-only tree to gh-pages — silent publication of an
// empty site. Now we fail loud so the caller (or an upstream
// fallback to target/site/) gets a clear signal.
if (isEmptyDirectory(stagingDir)) {
throw new MojoException(
"Staging directory is empty: " + stagingDir
+ ". Site build produced no content. "
+ "For single-module projects, mvn site:stage "
+ "is a no-op (it's designed to aggregate "
+ "child module sites in a multi-module "
+ "reactor); the rendered content lives at "
+ "target/site/. The release flow's empty-"
+ "staging fallback (ReleaseDraftMojo) should "
+ "have detected this and substituted "
+ "target/site/ before reaching this point. "
+ "If you're calling publishProjectSiteToGhPages "
+ "directly, pass target/site/ instead of "
+ "target/staging/ for single-module projects.");
}
// Detect version-nested staging (ike-issues#337). When a
// project's site.deploy.url contains the version segment
// (e.g., scpexe://...//ike-platform/${project.version}/),
// mvn site:stage produces target/staging/<version>/<actual
// content> rather than target/staging/<actual content>.
// Unwrap whenever the version subdir exists, regardless of
// whether the staging dir has stale prior-release subdirs
// (which it usually does on ike-platform because
// maven-clean-plugin preserves target/staging across clean).
// Earlier behavior required exactly-one-entry, which failed
// the second time the same project released (count >1 due
// to leftovers); the over-narrow check stripped both the
// current version and the stale ones via the
// copyDirectoryExcludingTopLevelVersionDirs filter, leaving
// the gh-pages /<version>/ subdir empty. The fix here is
// simpler: if the per-version subdir exists, that's
// unambiguously the source.
Path effectiveStagingSource = stagingDir;
// Detect URL-as-path staging (ike-issues#359). When a
// multi-module reactor declares an https:// <site><url>,
// maven-site-plugin's site:stage stages content at
// target/staging/https:/<host>/<projectId>/[<version>/]
// — scheme, host, and every path segment become literal
// directory names. Pre-#304 the foundation repos used
// scpexe:// URLs which produced clean target/staging/
// <path-segments>/ trees; post-#304 the https:// URLs came
// with this URL-as-path side effect. Detect the pattern
// first so the existing version-nested (#337) and
// parent-artifactId (#342) unwraps run against the
// already-narrowed effective source.
Path urlAsPathUnwrap = detectHttpsUrlStaging(stagingDir, projectId);
if (urlAsPathUnwrap != null) {
log.info(" Detected https://-URL-as-path staging at "
+ urlAsPathUnwrap
+ " — using it as the gh-pages source. (Project's "
+ "<site><url> is an https:// URL and maven-site-plugin "
+ "stages content at target/staging/https:/<host>/"
+ "<projectId>/; ike-issues#359.)");
effectiveStagingSource = urlAsPathUnwrap;
}
// Capture the layer at which sibling submodule sites live
// BEFORE the version-nested unwrap narrows further. For
// ike-platform the URL is .../ike-platform/<version>/ so
// siblings (ike-workspace-maven-plugin/, ike-parent/) live
// alongside the version dir, one level up from the final
// narrowed source. For ike-tooling/ike-docs (no version in
// URL) siblings live at the same layer. ike-issues#363.
Path siblingSubmoduleLayer = effectiveStagingSource;
Path nestedVersionDir = effectiveStagingSource.resolve(version);
if (Files.isDirectory(nestedVersionDir)
&& !isEmptyDirectory(nestedVersionDir)) {
log.info(" Detected version-nested staging at "
+ nestedVersionDir
+ " — using it as the gh-pages source. "
+ "(Project's site.deploy.url contains the "
+ "version segment so site:stage nested "
+ "content under it; ike-issues#337.)");
effectiveStagingSource = nestedVersionDir;
}
// Detect parent-artifactId staging nesting (ike-issues#342).
// When a project has a <parent> block, maven-site-plugin's
// site:stage derives the staging directory from the parent
// artifactId, producing target/staging/<parentArtifactId>/
// <projectId>/<actual content>. This is orthogonal to the
// version-nested case above (#337) — they can compound, but
// the version-nested unwrap runs first; this check is then
// applied to whatever effective source survived.
// Detection: effective staging has exactly one entry that's
// a directory, and that directory contains a non-empty
// subdirectory whose name matches projectId. Unwrap to
// <singleEntry>/<projectId>/.
Path parentArtifactUnwrap =
detectParentArtifactNesting(effectiveStagingSource, projectId);
if (parentArtifactUnwrap != null) {
log.info(" Detected parent-artifactId staging nesting at "
+ parentArtifactUnwrap
+ " — using it as the gh-pages source. (Project "
+ "has a <parent> block so site:stage nested "
+ "content under <parentArtifactId>/<artifactId>/; "
+ "ike-issues#342.)");
effectiveStagingSource = parentArtifactUnwrap;
}
// Detect aggregated-reactor staging where the project's own
// site is one of several top-level entries (ike-issues#351).
// When a workspace pom inherits a <site> URL via property
// indirection that produces no common ancestor with its
// subprojects (e.g. https://ike.network/${project.artifactId}/
// per project), site:stage flattens each module at its own
// top-level path under target/staging/. The workspace's own
// content lives in staging/<projectId>/ and the subprojects
// are siblings. Without this unwrap, publishProjectSiteToGhPages
// would copy the whole flattened tree as the gh-pages root —
// workspace's own pages end up in /<projectId>/, the
// subprojects' aggregated sites take over the actual root,
// and the wrong index.html ships. Unwrap to <projectId>/
// pulls just the workspace's own content to the gh-pages root.
// Orthogonal to #337 (version-nested) and #342 (parent-artifactId);
// applies whenever the project's own subdir exists alongside
// other top-level entries.
Path aggregatedUnwrap =
detectAggregatedStaging(effectiveStagingSource, projectId);
if (aggregatedUnwrap != null) {
log.info(" Detected aggregated-reactor staging at "
+ effectiveStagingSource
+ " — using " + aggregatedUnwrap
+ " as the gh-pages source. (Reactor site:stage "
+ "flattened module sites at the staging root; "
+ "ike-issues#351.)");
effectiveStagingSource = aggregatedUnwrap;
}
log.info("Publishing " + projectId + " site to gh-pages...");
Path tempDir;
try {
tempDir = Files.createTempDirectory("ike-project-gh-pages-");
} catch (IOException e) {
throw new MojoException(
"Could not create temp directory for gh-pages publish", e);
}
try {
File tempRoot = tempDir.toFile();
// Try cloning the existing gh-pages branch so we preserve
// history and any prior <version>/ subdirs. If the branch
// doesn't yet exist on the remote (first publish), the
// clone fails and we bootstrap with an orphan branch.
boolean firstTimeBootstrap = false;
try {
exec(tempRoot, log, "git", "clone",
"--branch", "gh-pages",
"--single-branch",
repoUrl, ".");
log.info(" Cloned existing gh-pages branch (additive publish)");
} catch (MojoException cloneFailed) {
// Most likely: the branch doesn't exist yet on this repo.
// Bootstrap: init + orphan checkout, then force-push at
// the end.
log.info(" No existing gh-pages branch — bootstrapping "
+ "with orphan checkout (first-time publish)");
firstTimeBootstrap = true;
// Clear any partial state from the failed clone attempt.
try (Stream<Path> entries = Files.list(tempDir)) {
entries.forEach(p -> {
if (Files.isDirectory(p)) {
deleteDirectory(p);
} else {
try { Files.delete(p); } catch (IOException ignore) {
// best effort
}
}
});
} catch (IOException ignore) {
// best effort
}
exec(tempRoot, log, "git", "init");
exec(tempRoot, log, "git", "checkout", "--orphan", "gh-pages");
}
// Wipe root-level files and non-version subdirs before
// overlaying the new release. Preserves .git/, the latest/
// alias (which we'll repopulate), and any directory whose
// name starts with a digit (a versioned snapshot from a
// prior release).
try {
wipeGhPagesRootForRepublish(tempDir, log);
} catch (IOException e) {
throw new MojoException(
"Failed to wipe root for republish: " + e.getMessage(), e);
}
// (1) Copy staging to root — current release at /<projectId>/.
// Filter top-level version-prefixed entries (#337):
// they're either pollution from earlier release cycles
// preserved by maven-clean's exclude-staging rule, or
// the same content already preserved at root by the
// wipe step above. In either case we don't want them
// coming through staging — version subdirs are managed
// explicitly by the caller (steps 2 and 3 below).
try {
copyDirectoryExcludingTopLevelVersionDirs(
effectiveStagingSource, tempDir);
} catch (IOException e) {
throw new MojoException(
"Failed to copy staging dir to root: " + e.getMessage(), e);
}
// (2) Copy staging to /<version>/ — preserved snapshot.
// Same filter: don't recursively nest prior version
// subdirs inside this release's versioned snapshot
// (the bug surfaced by ike-issues#337).
Path versionDir = tempDir.resolve(version);
deleteDirectory(versionDir);
try {
Files.createDirectories(versionDir);
copyDirectoryExcludingTopLevelVersionDirs(
effectiveStagingSource, versionDir);
} catch (IOException e) {
throw new MojoException(
"Failed to copy staging dir to versioned subdir "
+ versionDir + ": " + e.getMessage(), e);
}
// (3) Replace /latest/ with the just-released content.
// Directory copy (not symlink) — GitHub Pages does not
// follow git symlinks reliably. Same version-dir
// filter as above.
Path latestDir = tempDir.resolve("latest");
deleteDirectory(latestDir);
try {
Files.createDirectories(latestDir);
copyDirectoryExcludingTopLevelVersionDirs(
effectiveStagingSource, latestDir);
} catch (IOException e) {
throw new MojoException(
"Failed to copy staging dir to latest/: "
+ e.getMessage(), e);
}
// (4) Publish sibling submodule subtrees (ike-issues#363).
// In a multi-module reactor with a per-artifactId or
// per-version <site> URL, each child module renders
// its own site into target/staging/.../<moduleId>/.
// Without this step those subtrees never reach
// gh-pages — the workspace site links to them but
// they 404. We walk siblingSubmoduleLayer for
// subdirs that:
// (a) aren't the version-nested target we already
// published (which IS the workspace's own),
// (b) contain an index.html (signal of a rendered
// module site, not a CSS/fonts resource dir).
// Each matching subtree gets copied to both
// <root>/<moduleId>/ and <root>/<version>/<moduleId>/.
// Walk THREE candidate layers for sibling submodules.
// Different submodule <site><url> shapes land at
// different staging depths in the same reactor — we have
// to cover all of them or some submodules ship with 404.
//
// (a) siblingSubmoduleLayer — the post-URL-unwrap
// layer (typically target/staging/https:/<host>/<projectId>/).
// Submodules with URL https://ike.network/<projectId>/<sub>/
// (same form as reactor top) land as siblings of
// the reactor's own site here.
//
// (b) stagingDir — the bare staging root. Submodules
// whose <site><url> resolves to a top-level path
// (e.g. https://ike.network/<sub>/) stage as
// 1-level-deep dirs in stagingDir. Pre-#380, this
// is where ike-parent landed.
//
// (c) stagingDir/<projectId>/ — when a submodule's URL
// AND the reactor's URL share a common middle
// segment (https://ike.network/<projectId>/...),
// Maven Site can stage the submodule's content
// *under* a directory named after the reactor's
// projectId. Post-#380, ike-parent's URL is
// https://ike.network/ike-platform/ike-parent/ and
// its content lands at
// target/staging/ike-platform/ike-parent/. The
// depth-1 walks in (a) and (b) miss this — the
// projectId-named container is rejected by the
// moduleId==projectId guard below, dropping its
// children with it.
//
// ike-issues#380 followup: ike-platform v51 shipped
// without /ike-platform/ike-parent/ because layer (c)
// wasn't being walked.
//
// Use a LinkedHashMap keyed by directory name to merge
// hits and dedupe — the same sibling could theoretically
// appear in multiple layers if a reactor mixes shapes.
java.util.Map<String, Path> siblingByName =
new java.util.LinkedHashMap<>();
for (Path candidate : findSubmoduleSiteDirs(
siblingSubmoduleLayer, effectiveStagingSource)) {
siblingByName.putIfAbsent(
candidate.getFileName().toString(), candidate);
}
if (!stagingDir.equals(siblingSubmoduleLayer)) {
for (Path candidate : findSubmoduleSiteDirs(
stagingDir, effectiveStagingSource)) {
siblingByName.putIfAbsent(
candidate.getFileName().toString(), candidate);
}
}
// Layer (c): stagingDir/<projectId>/ — projectId-named
// container that holds submodule sites as children.
Path projectIdContainer = stagingDir.resolve(projectId);
if (Files.isDirectory(projectIdContainer)
&& !projectIdContainer.toAbsolutePath().normalize()
.equals(siblingSubmoduleLayer
.toAbsolutePath().normalize())) {
for (Path candidate : findSubmoduleSiteDirs(
projectIdContainer, effectiveStagingSource)) {
siblingByName.putIfAbsent(
candidate.getFileName().toString(), candidate);
}
}
List<Path> siblings = new ArrayList<>(siblingByName.values());
for (Path siblingSource : siblings) {
String moduleId = siblingSource.getFileName().toString();
if (moduleId.equals(projectId)) continue;
Path siblingRootDest = tempDir.resolve(moduleId);
Path siblingVersionDest = tempDir.resolve(version)
.resolve(moduleId);
// Submodules also need a latest/ alias so the
// canonical pattern <site-root>/latest/<sub>/ resolves.
// Step (3) above populates latest/ from
// effectiveStagingSource, which catches submodules
// staging UNDER that source (ike-bom,
// ike-workspace-maven-plugin) but NOT submodules from
// the projectId-container shape introduced by #381
// (ike-parent under stagingDir/<projectId>/). Copying
// here covers all three layers uniformly. Redundant
// for siblings already in latest/ from step (3), but
// the second copy is idempotent (same source).
Path siblingLatestDest = tempDir.resolve("latest")
.resolve(moduleId);
deleteDirectory(siblingRootDest);
deleteDirectory(siblingVersionDest);
deleteDirectory(siblingLatestDest);
try {
Files.createDirectories(siblingRootDest);
Files.createDirectories(siblingVersionDest);
Files.createDirectories(siblingLatestDest);
copyDirectory(siblingSource, siblingRootDest);
copyDirectory(siblingSource, siblingVersionDest);
copyDirectory(siblingSource, siblingLatestDest);
} catch (IOException e) {
throw new MojoException(
"Failed to copy submodule subtree "
+ siblingSource + ": "
+ e.getMessage(), e);
}
log.info(" Published submodule site: " + moduleId);
}
// .nojekyll — disable Jekyll preprocessing on rendered HTML.
Path nojekyll = tempDir.resolve(".nojekyll");
try {
Files.writeString(nojekyll, "");
} catch (IOException e) {
throw new MojoException(
"Failed to write .nojekyll marker: " + e.getMessage(), e);
}
// Defensive: never carry a per-repo CNAME — the org CNAME
// (IKE-Network.github.io -> ike.network) extends down.
// If a stray CNAME ended up in the staging dir, drop it
// (root copy only — versioned subdirs are also free of it
// since their source was the same staging dir).
Path strayCname = tempDir.resolve("CNAME");
if (Files.exists(strayCname)) {
try {
Files.delete(strayCname);
log.info(" Dropped stray CNAME from staging "
+ "(per-project CNAMEs conflict with org CNAME)");
} catch (IOException e) {
throw new MojoException(
"Could not delete stray CNAME: " + e.getMessage(), e);
}
}
exec(tempRoot, log, "git", "add", "-A");
exec(tempRoot, log, "git", "commit", "-m",
"site: publish " + projectId + " " + version);
if (firstTimeBootstrap) {
exec(tempRoot, log, "git", "push", "--force",
repoUrl, "gh-pages:gh-pages");
} else {
exec(tempRoot, log, "git", "push",
repoUrl, "gh-pages:gh-pages");
}
log.info(" Published:");
log.info(" Current: https://ike.network/" + projectId + "/");
log.info(" Versioned: https://ike.network/" + projectId
+ "/" + version + "/");
log.info(" Latest: https://ike.network/" + projectId + "/latest/");
} finally {
deleteDirectory(tempDir);
}
}
/**
* Wipe root-level files and non-version subdirs from a freshly
* cloned (or freshly initialized) gh-pages working tree, in
* preparation for overlaying a new release.
*
* <p>Preserves:
* <ul>
* <li>{@code .git/} — git internals</li>
* <li>Any directory whose name starts with a digit — assumed to
* be a versioned snapshot from a prior release. Versions in
* IKE projects are numeric (single-segment integers, semver,
* date-based, etc.) so the digit-prefix heuristic catches all
* three. {@code latest/} starts with a letter and is wiped
* (will be repopulated by the caller).</li>
* </ul>
*
* <p>Wipes everything else: stale {@code index.html}, {@code css/},
* {@code js/}, {@code images/}, {@code latest/}, etc. The caller
* then copies the new staging contents on top.
*
* @param repoDir the cloned/initialized gh-pages working tree
* @param log Maven logger
* @throws IOException if directory listing fails
*/
private static void wipeGhPagesRootForRepublish(Path repoDir, Log log)
throws IOException {
try (Stream<Path> entries = Files.list(repoDir)) {
entries.forEach(entry -> {
String name = entry.getFileName().toString();
if (".git".equals(name)) {
return;
}
if (Files.isDirectory(entry) && isVersionDirName(name)) {
log.debug(" Preserving versioned subdir: " + name + "/");
return;
}
if (Files.isDirectory(entry)) {
deleteDirectory(entry);
} else {
try {
Files.delete(entry);
} catch (IOException e) {
log.warn(" Could not delete root file "
+ name + ": " + e.getMessage());
}
}
});
}
}
/**
* Heuristic: does this directory name look like a release version?
*
* <p>IKE versions are not necessarily semver — they may be
* single-segment integers (e.g., {@code 145}), semver
* ({@code 1.2.3}), or date-based ({@code 2026-04-25}). All three
* forms start with a digit, while non-version directories at the
* gh-pages root (e.g., {@code css}, {@code js}, {@code images},
* {@code latest}) start with a letter.
*
* @param name the directory name
* @return {@code true} if the name looks like a version
*/
static boolean isVersionDirName(String name) {
return name != null
&& !name.isEmpty()
&& Character.isDigit(name.charAt(0));
}
/**
* Validate that a remote path is safe for deletion operations.
*
* <p>Ensures the path starts with {@link #SITE_DISK_BASE} and has
* sufficient depth to prevent accidental deletion of the site root.
*
* @param remotePath absolute path on the server
* @throws MojoException if the path is unsafe
*/
public static void validateRemotePath(String remotePath)
throws MojoException {
if (!remotePath.startsWith(SITE_DISK_BASE)) {
throw new MojoException(
"Refusing to delete — path does not start with "
+ SITE_DISK_BASE + ": " + remotePath);
}
String relative = remotePath.substring(SITE_DISK_BASE.length());
long depth = relative.chars().filter(c -> c == '/').count();
if (relative.isBlank() || depth < 1) {
throw new MojoException(
"Refusing to delete — path too shallow (need project/type): "
+ remotePath);
}
}
/**
* Resolve the on-disk site path for a given project, type, and
* optional subdirectory.
*
* @param projectId Maven artifact ID (e.g., "ike-pipeline")
* @param siteType "release", "snapshot", or "checkpoint"
* @param subPath optional subdirectory (branch name, version);
* null or blank to omit
* @return absolute path on the server
*/
public static String siteDiskPath(String projectId, String siteType,
String subPath) {
String path = SITE_DISK_BASE + projectId + "/" + siteType;
if (subPath != null && !subPath.isBlank()) {
path += "/" + subPath;
}
return path;
}
/**
* Convert a git branch name to a safe site path segment.
* Replaces {@code /} with {@code /} (keeps hierarchy for
* {@code feature/name} structure).
*
* @param branch git branch name
* @return sanitized path segment safe for use in URLs and file paths
*/
public static String branchToSitePath(String branch) {
// Keep forward slashes for directory structure (feature/name → feature/name)
// but sanitize anything dangerous
return branch.replaceAll("[^a-zA-Z0-9/_.-]", "-");
}
private static final Pattern ARTIFACT_ID_PATTERN =
Pattern.compile("<artifactId>([^<]+)</artifactId>");
private static final Pattern GROUP_ID_PATTERN =
Pattern.compile("<groupId>([^<]+)</groupId>");
/**
* Read the project's own {@code <groupId>} from a POM file,
* skipping any {@code <groupId>} inside the {@code <parent>}
* block.
*
* <p>Used by cascade self-identification (IKE-Network/ike-issues#402):
* a reactor-root POM declares its own {@code <groupId>}, which the
* release goals match against {@code release-cascade.yaml} entries.
*
* @param pomFile the POM file to read
* @return the group ID string
* @throws MojoException if the file cannot be read or declares no
* own group ID
*/
public static String readPomGroupId(File pomFile) throws MojoException {
try {
String content = Files.readString(
pomFile.toPath(), StandardCharsets.UTF_8);
String stripped = content.replaceFirst(
"(?s)<parent>.*?</parent>", "");
Matcher matcher = GROUP_ID_PATTERN.matcher(stripped);
if (matcher.find()) {
return matcher.group(1);
}
throw new MojoException(
"Could not extract <groupId> from " + pomFile);
} catch (IOException e) {
throw new MojoException("Failed to read " + pomFile, e);
}
}
/**
* Read the project's own {@code <artifactId>} from a POM file,
* skipping any {@code <artifactId>} inside the {@code <parent>} block.
*
* @param pomFile the POM file to read
* @return the artifact ID string
* @throws MojoException if the file cannot be read or has no artifact ID
*/
public static String readPomArtifactId(File pomFile) throws MojoException {
try {
String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
String stripped = content.replaceFirst(
"(?s)<parent>.*?</parent>", "");
Matcher matcher = ARTIFACT_ID_PATTERN.matcher(stripped);
if (matcher.find()) {
return matcher.group(1);
}
throw new MojoException(
"Could not extract <artifactId> from " + pomFile);
} catch (IOException e) {
throw new MojoException("Failed to read " + pomFile, e);
}
}
/**
* Read a {@code <properties>} value from a POM file by element
* name.
*
* <p>Used by cascade resolution (IKE-Network/ike-issues#404) to
* read {@code ike-tooling.version} — the version at which a
* foundation repo resolves the {@code ike-build-standards}
* {@code cascade} artifact.
*
* @param pomFile the POM file to read
* @param propertyName the property element name (e.g.
* {@code ike-tooling.version})
* @return the property value, or {@code null} if the POM declares
* no such property
* @throws MojoException if the file cannot be read
*/
public static String readPomProperty(File pomFile, String propertyName)
throws MojoException {
try {
String content = Files.readString(
pomFile.toPath(), StandardCharsets.UTF_8);
Matcher matcher = Pattern.compile(
"<" + Pattern.quote(propertyName) + ">([^<]+)</"
+ Pattern.quote(propertyName) + ">").matcher(content);
return matcher.find() ? matcher.group(1) : null;
} catch (IOException e) {
throw new MojoException("Failed to read " + pomFile, e);
}
}
/**
* Check whether a directory has no entries. Returns {@code true}
* if the path is a directory with zero entries; {@code false} if
* it has at least one entry. Throws if the path is not a
* readable directory.
*
* <p>Used by the gh-pages publish flow (ike-issues#334) to
* distinguish "directory exists but is empty" (e.g., {@code
* mvn site:stage} produced nothing for a single-module project)
* from "directory exists and has content" (the normal case).
*
* @param dir the directory to inspect
* @return {@code true} if the directory contains no entries
* @throws MojoException if the directory cannot be listed
*/
public static boolean isEmptyDirectory(Path dir) throws MojoException {
try (Stream<Path> entries = Files.list(dir)) {
return entries.findAny().isEmpty();
} catch (IOException e) {
throw new MojoException(
"Could not list directory " + dir + ": " + e.getMessage(),
e);
}
}
/**
* Detect parent-artifactId staging nesting (ike-issues#342).
*
* <p>When a Maven project has a {@code <parent>} block,
* {@code maven-site-plugin}'s {@code site:stage} writes content
* to {@code target/staging/<parentArtifactId>/<projectId>/}
* rather than {@code target/staging/}. The hybrid gh-pages
* publish path treats {@code stagingDir} as the source of
* truth, so the published tree ends up double-nested at
* {@code /<repo>/<version>/<parentArtifactId>/<projectId>/}
* when it should be at {@code /<repo>/<version>/}.
*
* <p>This helper detects that wrap by checking:
* <ol>
* <li>{@code source} contains exactly one entry that is itself
* a directory, and</li>
* <li>that single directory contains a non-empty subdirectory
* whose name equals {@code projectId}.</li>
* </ol>
*
* <p>Returns the path to {@code <single-entry>/<projectId>}
* when both conditions hold; otherwise returns {@code null}
* to indicate no unwrap is needed.
*
* <p>The check is intentionally conservative — it requires a
* single top-level entry so it does not mis-fire on staging
* trees that legitimately have multiple top-level dirs (the
* normal aggregated-reactor case where each module's site
* lives under its own subdirectory).
*
* @param source the post-#337-unwrap effective staging source
* @param projectId the project's own artifactId
* @return the unwrapped path, or {@code null} if no nesting
* was detected
* @throws MojoException if the directory cannot be inspected
*/
public static Path detectParentArtifactNesting(Path source,
String projectId)
throws MojoException {
if (!Files.isDirectory(source)) {
return null;
}
try (Stream<Path> entries = Files.list(source)) {
List<Path> topLevel = entries.toList();
if (topLevel.size() != 1) {
return null;
}
Path single = topLevel.get(0);
if (!Files.isDirectory(single)) {
return null;
}
Path inner = single.resolve(projectId);
if (!Files.isDirectory(inner) || isEmptyDirectory(inner)) {
return null;
}
return inner;
} catch (IOException e) {
throw new MojoException(
"Could not inspect " + source
+ " for parent-artifactId nesting: "
+ e.getMessage(), e);
}
}
/**
* Detect aggregated-reactor staging where the project's own site
* lives alongside multiple sibling subdirs (ike-issues#351).
*
* <p>When a workspace pom inherits a {@code <site>} URL with no
* common ancestor among its reactor subprojects' URLs (e.g.
* each module gets {@code https://ike.network/<artifactId>/}),
* {@code site:stage} flattens each module's site at its own
* top-level path under {@code target/staging/}. The workspace's
* own content lives in {@code staging/<projectId>/} and the
* subprojects are siblings. Without unwrap, the gh-pages root
* gets the whole flattened tree — wrong index.html, missing
* the workspace's own pages.
*
* <p>Detection criteria (all must hold):
* <ol>
* <li>{@code source} contains at least one top-level entry, AND</li>
* <li>a subdirectory named {@code projectId} exists at the
* top level, AND</li>
* <li>that subdirectory is non-empty.</li>
* </ol>
*
* <p>Single-top-level cases are handled by
* {@link #detectParentArtifactNesting}; this method's value-add
* is the multi-top-level case where {@code projectId} is one of
* several siblings.
*
* <p>Returns the path to {@code source/<projectId>} when the
* detection fires; {@code null} otherwise.
*
* @param source the post-#337/#342-unwrap effective staging source
* @param projectId the project's own artifactId
* @return the unwrapped path, or {@code null}
* @throws MojoException if the directory cannot be inspected
*/
public static Path detectAggregatedStaging(Path source,
String projectId)
throws MojoException {
if (!Files.isDirectory(source)) {
return null;
}
// No multi-entry guard. The earlier "count() < 2 → return null"
// assumed the single-entry case was always parent-artifactId
// nesting (handled by detectParentArtifactNesting). After the
// #358 fix in ike-parent v45+, single-module consumers stage
// their content at staging/<projectId>/ with NO further
// nesting — and that pattern has only one top-level entry too.
// Returning null there caused gh-pages publish to copy the
// whole staging tree (containing <projectId>/<content>) to
// gh-pages root, producing /<version>/<projectId>/<content>
// instead of /<version>/<content> — every consumer URL
// 404'd. ike-issues#358 followup.
//
// The detectParentArtifactNesting check runs FIRST in
// publishProjectSiteToGhPages and takes the deeper (doubled)
// path when present, so this method's shallow check only
// fires in the legitimate "content lives at staging/<projectId>/"
// case.
// Check shallow first: source/<projectId>/
Path shallow = source.resolve(projectId);
if (Files.isDirectory(shallow) && !isEmptyDirectory(shallow)) {
return shallow;
}
// Check one level deeper: source/<anyDir>/<projectId>/. This
// catches the compound case where the workspace's own site is
// nested under parent-artifactId (#342) AND the reactor's
// sibling subprojects flatten at the staging root (#351 v1
// detection didn't fire on the shallow check because the
// projectId subdir lives at staging/<parentArtifactId>/<projectId>/,
// not staging/<projectId>/). The first matching deeper
// candidate wins — staging/<parentArtifactId>/<projectId>/
// is the only configuration that produces this layout in
// practice, so we don't disambiguate.
try (Stream<Path> entries = Files.list(source)) {
for (Path top : entries.toList()) {
if (!Files.isDirectory(top)) continue;
Path deeper = top.resolve(projectId);
if (Files.isDirectory(deeper)
&& !isEmptyDirectory(deeper)) {
return deeper;
}
}
} catch (IOException e) {
throw new MojoException(
"Could not inspect " + source
+ " for aggregated staging (deep): "
+ e.getMessage(), e);
}
return null;
}
/**
* Detect URL-as-path staging where {@code site:stage} has
* mapped an {@code https://}-form {@code <site><url>} to a
* literal directory tree under {@code target/staging/}
* (ike-issues#359).
*
* <p>For a {@code <site><url>https://ike.network/<projectId>/}
* inside a multi-module reactor, maven-site-plugin produces
* {@code target/staging/https:/ike.network/<projectId>/<content>}
* — scheme ({@code https:}), host ({@code ike.network}), and each
* path segment each become a real subdirectory. The pre-#304
* scpexe URLs also went through this transformation, but their
* path segments aligned with where site-deploy actually wrote,
* so the layout was sensible. Post-#304 the {@code https://}
* URLs survive only for relative-path computation and the
* URL-as-path staging is wasted — the actual publish target is
* gh-pages.
*
* <p>Detection: descend into {@code source/https:/} (or
* {@code source/http:/}), then a single host directory, then
* the project's own {@code <projectId>/} subdirectory. When
* all three exist and {@code <projectId>/} is non-empty,
* return it as the effective staging source. The caller then
* applies the remaining unwraps (version-nested #337,
* parent-artifactId #342, aggregated #351) to the narrowed
* source — e.g. ike-platform's
* {@code https://ike.network/ike-platform/${project.version}/}
* resolves to {@code source/https:/ike.network/ike-platform/}
* here, then #337 descends into {@code 40/}.
*
* <p>Returns {@code null} if any layer of the expected
* structure is missing or the project's own directory is
* empty — staying conservative so non-matching projects
* (single-module reactors, scpexe-URL projects, etc.) fall
* through to the existing unwrap chain unchanged.
*
* @param source the staging directory to inspect
* @param projectId the project's artifactId
* @return the unwrap target, or {@code null} if no
* URL-as-path nesting was detected
* @throws MojoException if a directory cannot be listed
*/
public static Path detectHttpsUrlStaging(Path source, String projectId)
throws MojoException {
if (!Files.isDirectory(source)) return null;
Path scheme = source.resolve("https:");
if (!Files.isDirectory(scheme)) {
scheme = source.resolve("http:");
if (!Files.isDirectory(scheme)) return null;
}
Path host;
try (Stream<Path> hosts = Files.list(scheme)) {
List<Path> hostDirs = hosts.filter(Files::isDirectory).toList();
if (hostDirs.size() != 1) return null;
host = hostDirs.get(0);
} catch (IOException e) {
throw new MojoException(
"Could not inspect " + scheme
+ " for URL-as-path staging: "
+ e.getMessage(), e);
}
Path projectDir = host.resolve(projectId);
if (!Files.isDirectory(projectDir)
|| isEmptyDirectory(projectDir)) {
return null;
}
return projectDir;
}
/**
* Find one-level-deep subdirectories under {@code layer} that
* look like rendered Maven-site outputs (have an {@code index.html}
* at the top), excluding {@code exclude} itself (the workspace's
* own subtree, already published at the gh-pages root).
*
* <p>Used by {@link #publishProjectSiteToGhPages} step (4) to
* surface sibling submodule subtrees in a multi-module reactor.
* The conservative {@code index.html}-presence check keeps the
* walk from mis-classifying resource dirs ({@code css/},
* {@code fonts/}, {@code images/}) or version dirs as submodule
* sites.
*
* <p>Returns an empty list if {@code layer} is not a directory
* or has no matching entries. Order is unspecified.
*
* @param layer the directory to scan
* @param exclude a path that, when matched against an entry,
* causes the entry to be skipped (typically the
* workspace's own narrowed staging source)
* @return list of submodule site directories
* @throws MojoException if the directory cannot be listed
*/
public static List<Path> findSubmoduleSiteDirs(Path layer, Path exclude)
throws MojoException {
List<Path> result = new ArrayList<>();
if (!Files.isDirectory(layer)) return result;
Path excludeAbs = exclude == null ? null
: exclude.toAbsolutePath().normalize();
try (Stream<Path> entries = Files.list(layer)) {
for (Path entry : entries.toList()) {
if (!Files.isDirectory(entry)) continue;
if (excludeAbs != null
&& entry.toAbsolutePath().normalize()
.equals(excludeAbs)) {
continue;
}
Path indexHtml = entry.resolve("index.html");
if (Files.isRegularFile(indexHtml)) {
result.add(entry);
}
}
} catch (IOException e) {
throw new MojoException(
"Could not scan for submodule sites at " + layer
+ ": " + e.getMessage(), e);
}
return result;
}
/**
* Recursively delete a directory and all its contents.
* Best-effort — failures are silently ignored.
*
* @param dir the directory to delete
*/
public static void deleteDirectory(Path dir) {
try {
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file,
BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path d,
IOException exc) throws IOException {
Files.delete(d);
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
// Best-effort — log but don't fail
}
}
/**
* Recursively copy a directory tree.
*
* @param source the source directory to copy from
* @param target the target directory to copy to
* @throws IOException if a file cannot be copied
*/
public static void copyDirectory(Path source, Path target) throws IOException {
try (Stream<Path> stream = Files.walk(source)) {
stream.forEach(src -> {
try {
Path dest = target.resolve(source.relativize(src));
if (Files.isDirectory(src)) {
Files.createDirectories(dest);
} else {
Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
}
/**
* Recursively copy a directory tree, excluding top-level
* subdirectories whose names look like release versions.
* Files at the top level and non-version subdirectories at the
* top level are copied normally. Inside any non-filtered
* subdirectory, all entries are copied without further filtering
* — the exclusion applies only at depth 0.
*
* <p>Used by {@link #publishProjectSiteToGhPages} to keep the
* gh-pages source clean of staging-pollution. Three causes of
* top-level version-prefixed entries in the source:
* <ol>
* <li><strong>Stale local mirrors</strong>: when a project's
* {@code site.deploy.url} contains the version segment
* and {@code maven-clean-plugin} preserves
* {@code target/staging/}, prior releases'
* {@code <version>/} subdirs accumulate there.</li>
* <li><strong>Self-nesting</strong>: copying staging that
* already has a {@code <currentVersion>/} subdir into
* a version subdir would produce
* {@code <currentVersion>/<currentVersion>/...}.</li>
* <li><strong>Race with the wipe step</strong>: the wipe
* preserved version dirs in the gh-pages clone; if
* staging happens to also contain those names, the copy
* would clobber the preserved content with potentially
* stale mirror copies.</li>
* </ol>
*
* <p>The filter applies the same digit-prefix heuristic as
* {@link #isVersionDirName}: top-level dirs whose names start
* with a digit are skipped.
*
* @param source the source directory to copy from
* @param target the target directory to copy to
* @throws IOException if a file cannot be copied
* @see #publishProjectSiteToGhPages
* @see #isVersionDirName
*/
public static void copyDirectoryExcludingTopLevelVersionDirs(
Path source, Path target) throws IOException {
try (Stream<Path> entries = Files.list(source)) {
for (Path entry : (Iterable<Path>) entries::iterator) {
String name = entry.getFileName().toString();
if (Files.isDirectory(entry) && isVersionDirName(name)) {
continue;
}
Path dest = target.resolve(name);
if (Files.isDirectory(entry)) {
Files.createDirectories(dest);
copyDirectory(entry, dest);
} else {
Files.copy(entry, dest,
StandardCopyOption.REPLACE_EXISTING);
}
}
}
}
/**
* Read the {@code <name>} element from a POM file.
*
* @param pomFile the POM file to read
* @return the name, or null if not present
* @throws MojoException if the file cannot be read
*/
public static String readPomName(File pomFile) throws MojoException {
return readPomElement(pomFile, "name");
}
/**
* Read the {@code <description>} element from a POM file.
*
* @param pomFile the POM file to read
* @return the description, or null if not present
* @throws MojoException if the file cannot be read
*/
public static String readPomDescription(File pomFile) throws MojoException {
return readPomElement(pomFile, "description");
}
private static String readPomElement(File pomFile, String element)
throws MojoException {
try {
String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
Pattern pattern = Pattern.compile(
"<" + element + ">([^<]+)</" + element + ">");
Matcher matcher = pattern.matcher(content);
return matcher.find() ? matcher.group(1).trim() : null;
} catch (IOException e) {
throw new MojoException("Failed to read " + pomFile, e);
}
}
}