DeployedSiteReconciler.java
package network.ike.plugin.reconcile;
import network.ike.plugin.ReleaseSupport;
import org.apache.maven.api.plugin.MojoException;
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.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Reconciler that keeps the project's deployed Maven site in sync with
* the current POM version.
*
* <p>Subsumes the retired {@code ike:deploy-site-{draft,publish}} goals
* (IKE-Network/ike-issues#398). The deployed site lives at
* {@code https://ike.network/<artifactId>/} (served from the project's
* own {@code gh-pages} branch) with a versioned mirror at
* {@code .../<artifactId>/<version>/} and a {@code .../<artifactId>/latest/}
* alias.
*
* <p><b>Detect</b>: probe {@code https://ike.network/<artifactId>/} and
* inspect the rendered HTML for the deployed version. If the version
* tag is missing or differs from {@link SiteContext#projectVersion},
* report drift.
*
* <p><b>Apply</b>: run {@code mvnw site site:stage} and force-push the
* resulting {@code target/staging/} to the project repo's
* {@code gh-pages} branch via
* {@link ReleaseSupport#publishProjectSiteToGhPages}. This is the same
* publish path the retired {@code DeploySiteDraftMojo} used for
* {@code siteType=release} after #304 retired the scpexe mirror.
*
* <p><b>Uninstall</b>: this reconciler does not invert. Removing a
* deployed site is handled by the gh-pages branch being deleted at the
* GitHub repo level — not something the build can do safely. The
* paired {@link StaleSiteCleanupReconciler} handles legacy scpexe
* cleanup.
*/
public class DeployedSiteReconciler implements SiteReconciler {
/** Creates this reconciler instance. */
public DeployedSiteReconciler() {}
/** Pattern to extract the deployed version from the rendered site HTML. */
private static final Pattern DEPLOYED_VERSION_PATTERN = Pattern.compile(
"(?:Version|version)\\s*[:|]\\s*<[^>]*>?\\s*([0-9][^<\\s]*)");
/** HTTP timeout for drift probes. Kept tight to keep draft snappy. */
private static final Duration PROBE_TIMEOUT = Duration.ofSeconds(5);
@Override
public String dimension() {
return "Deployed site version";
}
@Override
public String optOutFlag() {
return "updateSite";
}
@Override
public SiteDriftReport detect(SiteContext ctx) {
String currentVersion = ctx.projectVersion();
String deployedUrl = ctx.projectSiteUrl();
if (deployedUrl == null || deployedUrl.isBlank()) {
return SiteDriftReport.noDrift(dimension());
}
Probe probe = probeDeployedVersion(deployedUrl);
if (!probe.reachable) {
// Treat unreachable as drift — apply will deploy fresh.
List<String> detail = List.of(
"Deployed URL: " + deployedUrl,
"Status: unreachable (" + probe.detail + ")");
String optOut = "mvn ike:site-publish -D" + optOutFlag() + "=false";
return new SiteDriftReport(
dimension(), true,
"Site at " + deployedUrl + " is unreachable",
detail,
"deploy current version " + currentVersion + " on site-publish",
optOut);
}
if (probe.version != null && probe.version.equals(currentVersion)) {
return SiteDriftReport.noDrift(dimension());
}
String deployed = probe.version != null ? probe.version : "(unknown)";
List<String> detail = List.of(
"Current: " + deployedUrl + " serves " + deployed,
" → project is at " + currentVersion);
String optOut = "mvn ike:site-publish -D" + optOutFlag() + "=false";
return new SiteDriftReport(
dimension(), true,
deployed + " deployed vs " + currentVersion + " in project",
detail,
"deploy " + currentVersion + " on site-publish",
optOut);
}
@Override
public void apply(SiteContext ctx) {
if (ctx.options().isOptedOut(optOutFlag())) {
ctx.log().info(" " + dimension() + ": skipped (opted out via -D"
+ optOutFlag() + "=false)");
return;
}
File gitRoot = ctx.gitRoot();
File mvnw = ReleaseSupport.resolveMavenWrapper(gitRoot, ctx.log());
// Build + stage in a single mvnw invocation. Mirrors the pre-#398
// DeploySiteDraftMojo path: `mvn site site:stage` produces
// target/staging/ from which we force-push to gh-pages.
ReleaseSupport.exec(gitRoot, ctx.log(),
mvnw.getAbsolutePath(), "site", "site:stage", "-B");
String remoteUrl = ReleaseSupport.getRemoteUrl(gitRoot, "origin");
if (remoteUrl == null) {
ctx.log().info(" " + dimension()
+ ": skipped (no 'origin' remote)");
return;
}
Path stagingDir = gitRoot.toPath()
.resolve("target").resolve("staging");
try {
ReleaseSupport.publishProjectSiteToGhPages(
stagingDir, remoteUrl, ctx.log(),
ctx.projectId(), ctx.projectVersion());
ctx.log().info(" " + dimension()
+ ": deployed " + ctx.projectVersion()
+ " to " + ctx.projectSiteUrl());
} catch (MojoException e) {
ctx.log().warn(" ⚠ " + dimension()
+ ": gh-pages publish failed (non-fatal): "
+ e.getMessage());
}
}
// ── Drift probe ─────────────────────────────────────────────────
/** Result of a deployed-site HTTP probe. */
private record Probe(boolean reachable, String version, String detail) {}
/**
* Probe the deployed site root and try to extract the current
* deployed version from the rendered HTML. Returns {@code null}
* version when the page is reachable but no version marker can be
* found (common for non-IKE-rendered sites).
*/
private static Probe probeDeployedVersion(String url) {
try (HttpClient client = HttpClient.newBuilder()
.connectTimeout(PROBE_TIMEOUT)
.followRedirects(HttpClient.Redirect.NORMAL)
.build()) {
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(PROBE_TIMEOUT)
.GET()
.build();
HttpResponse<String> resp = client.send(req,
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() / 100 != 2) {
return new Probe(false, null, "HTTP " + resp.statusCode());
}
String body = resp.body();
Matcher m = DEPLOYED_VERSION_PATTERN.matcher(body);
if (m.find()) {
return new Probe(true, m.group(1).trim(), "");
}
// Reachable but no version pattern matched — treat as
// probe-inconclusive (no drift reported).
return new Probe(true, null, "no version marker found");
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
return new Probe(false, null,
e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
/**
* Visible for testing — exposes the version-extraction regex so a
* fixture HTML body can be parsed in isolation.
*
* @param html the rendered HTML body
* @return the detected version string, or {@code null}
*/
static String extractDeployedVersion(String html) {
Matcher m = DEPLOYED_VERSION_PATTERN.matcher(html);
return m.find() ? m.group(1).trim() : null;
}
}