CoherenceVerifier.java
package network.ike.plugin.release.coherence;
import network.ike.plugin.PomRewriter;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.version.CandidateVersionResolver;
import network.ike.plugin.support.version.MavenVersionComparator;
import network.ike.plugin.support.version.SessionCandidateVersionResolver;
import network.ike.support.enums.ConstantBackedEnum;
import network.ike.support.enums.ReleasePolicy;
import network.ike.workspace.cascade.CascadeEdge;
import network.ike.workspace.cascade.EdgeKind;
import network.ike.workspace.cascade.ProjectCascade;
import network.ike.workspace.cascade.ProjectCascadeIo;
import org.apache.maven.api.ArtifactCoordinates;
import org.apache.maven.api.Repository;
import org.apache.maven.api.RemoteRepository;
import org.apache.maven.api.Session;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* The release-coherence gate (IKE-Network/ike-issues#705): a module's
* release does not complete until its own just-published artifact
* resolves at the demanded {@link ResolutionScope}, and its own upstream
* pins are confirmed current against fresh metadata.
*
* <p>Both checks assert only about <em>this</em> module — never about
* its upstreams or which siblings are mid-cascade. Coherence emerges
* because the TeamCity finish-trigger fires the downstream only on the
* upstream's <em>success</em>, and "success" now includes these checks.
* An un-resolvable artifact (or a pin that silently failed to catch up)
* fails <em>this</em> build, the finish-trigger does not fire, and the
* cascade stops — incoherence is a red build on the responsible module,
* never a silently-wrong downstream.
*
* <p><strong>Cold resolution.</strong> Both checks run against a session
* whose local repository is a fresh, empty temp directory. This is
* essential: the module's own {@code .m2} trivially holds the artifact
* it just {@code install}ed, and caches upstream metadata under a daily
* update policy — so resolving against the normal local repo would
* confirm nothing and could read stale metadata (the exact failure that
* shipped the incoherent ike-platform v110 on 2026-06-18). An empty
* local repo forces a real fetch from the demanded remote, with the
* session's configured credentials preserved.
*/
public final class CoherenceVerifier {
/** Nexus group id consumers resolve released IKE artifacts from. */
private static final String NEXUS_PUBLIC_ID = "ike-public";
/** Fallback URL for {@link ResolutionScope#NEXUS} if not configured in the session. */
private static final String NEXUS_PUBLIC_URL =
"https://nexus.tinkar.org/repository/ike-public/";
/** Fallback URL for {@link ResolutionScope#CENTRAL} if not configured in the session. */
private static final String CENTRAL_URL = "https://repo1.maven.org/maven2/";
private static final Map<String, ReleasePolicy> RELEASE_POLICY_INDEX =
ConstantBackedEnum.index(ReleasePolicy.class);
private final Session session;
private final Log log;
/**
* Creates a verifier bound to the active session and logger.
*
* @param session the active Maven session (provides the resolver
* services, configured remotes, and credentials)
* @param log the release logger
*/
public CoherenceVerifier(Session session, Log log) {
this.session = session;
this.log = log;
}
/**
* The headline gate: confirm the just-released artifact resolves
* cold at the demanded scope, throwing if it does not.
*
* <p>Resolves the artifact's POM (every released artifact has one,
* regardless of packaging) against a fresh, empty local repository,
* so success means a cache-less consumer could genuinely fetch what
* this build published.
*
* @param groupId the released artifact's groupId
* @param artifactId the released artifact's artifactId
* @param version the released version (no {@code -SNAPSHOT})
* @param scope the demanded resolution scope (must be {@code ≥ NEXUS} for publish)
* @throws MojoException if the artifact does not resolve at the demanded scope
*/
public void verifySelfResolves(String groupId, String artifactId,
String version, ResolutionScope scope) throws MojoException {
if (scope == ResolutionScope.LOCAL) {
// Verifies nothing; -publish rejects LOCAL upstream of here.
log.info("Coherence gate: scope=local — skipped (verifies nothing).");
return;
}
RemoteRepository repo = demandedRepository(scope);
String coords = groupId + ":" + artifactId + ":pom:" + version;
try (ColdLocalRepo cold = new ColdLocalRepo(session)) {
ArtifactCoordinates ac = cold.session.createArtifactCoordinates(coords);
cold.session.resolveArtifact(ac, List.of(repo));
log.info("✓ Coherence gate: " + groupId + ":" + artifactId + ":"
+ version + " resolves cold from " + scope.literalName()
+ " (" + repo.getUrl() + ").");
} catch (IOException e) {
throw new MojoException("Coherence gate: could not create a cold "
+ "local repository for self-resolution: " + e.getMessage(), e);
} catch (RuntimeException e) {
throw new MojoException(
"Release coherence gate FAILED — " + groupId + ":"
+ artifactId + ":" + version + " is NOT resolvable from the"
+ " demanded scope '" + scope.literalName() + "' ("
+ repo.getUrl() + ").\n"
+ " A cold, cache-less resolver could not fetch the artifact"
+ " this build just deployed.\n"
+ " Halting before tag-push so the cascade does not fire a"
+ " downstream against a missing upstream (#705).\n"
+ " Likely cause: the deploy did not propagate to the group"
+ " repo, or the demanded scope is wrong.\n"
+ " Resolver error: " + e.getMessage(), e);
}
}
/**
* The post-release coherence assert: fail loudly if any of this
* module's auto-aligned upstream pins did not catch up to the latest
* released upstream, judged against <em>fresh</em> metadata.
*
* <p>This is the safety net for the stale-metadata failure mode: the
* B8 alignment step already raises these pins, but if it read a stale
* metadata cache it could silently leave a pin behind (shipping an
* incoherent build). Re-resolving cold here catches that.
*
* <p>Only edges this module would <em>auto-align</em> are asserted —
* {@link ReleasePolicy#INTEGRATE} and {@link ReleasePolicy#RELEASE}.
* A {@code notify}/{@code verify}/{@code propose} edge is
* intentionally hand-gated and legitimately sits behind latest, so
* asserting it would false-fail. The policy-read mirrors
* {@code ReleasePrep.alignUpstreamProperties} (B8); keep the two in
* sync.
*
* <p>A no-op for a non-cascade member, the cascade head, or a module
* whose pins are all current.
*
* @param gitRoot the release working tree (its committed {@code pom.xml} is read)
* @param scope the demanded scope whose repo supplies the fresh "latest released"
* @throws MojoException if an auto-aligned pin is behind the latest released upstream
*/
public void assertUpstreamPinsCurrent(File gitRoot, ResolutionScope scope)
throws MojoException {
if (scope == ResolutionScope.LOCAL) {
return;
}
Optional<ProjectCascade> loaded = ProjectCascadeIo.load(
gitRoot.toPath().resolve(ProjectCascadeIo.MANIFEST_RELATIVE_PATH));
if (loaded.isEmpty() || loaded.get().upstream().isEmpty()) {
return;
}
File pomFile = new File(gitRoot, "pom.xml");
String content;
try {
content = Files.readString(pomFile.toPath());
} catch (IOException e) {
throw new MojoException("Coherence assert: could not read "
+ pomFile + ": " + e.getMessage(), e);
}
List<String> behind = new ArrayList<>();
try (ColdLocalRepo cold = new ColdLocalRepo(session)) {
CandidateVersionResolver resolver =
new SessionCandidateVersionResolver(cold.session);
for (CascadeEdge up : loaded.get().upstream()) {
if (!autoAligned(pomFile, up)) {
continue; // hand-gated policy — legitimately may sit behind
}
boolean parentEdge = up.kind() == EdgeKind.PARENT;
String property = up.versionProperty();
String current = parentEdge
? PomRewriter.readParentVersion(content,
up.groupId(), up.artifactId()).orElse(null)
: ReleaseSupport.readPomProperty(pomFile, property);
if (!parentEdge && current == null) {
property = up.versionPropertyLegacy();
current = ReleaseSupport.readPomProperty(pomFile, property);
}
if (current == null || current.contains("${")) {
// No local pin, or pinned elsewhere — not this module's
// pin to assert.
continue;
}
String latest;
try {
List<String> candidates = resolver.resolveCandidates(
up.groupId(), up.artifactId(), null);
latest = candidates.isEmpty() ? null
: candidates.get(candidates.size() - 1);
} catch (RuntimeException e) {
// Resolution failure here is itself a coherence problem.
behind.add(up.ga() + ": could not re-resolve latest — "
+ e.getMessage());
continue;
}
if (latest != null && MavenVersionComparator.INSTANCE
.compare(latest, current) > 0) {
behind.add(up.ga() + ": pin " + current
+ " is behind latest released " + latest);
}
}
} catch (IOException e) {
throw new MojoException("Coherence assert: could not create a cold "
+ "local repository: " + e.getMessage(), e);
}
if (!behind.isEmpty()) {
StringBuilder msg = new StringBuilder(
"Release coherence assert FAILED — this module's upstream"
+ " pin(s) did not catch up to the latest released"
+ " upstream (judged against fresh metadata):\n");
for (String b : behind) {
msg.append(" ").append(b).append('\n');
}
msg.append(" The B8 alignment step likely read stale metadata."
+ " Halting before tag-push so the cascade does not ship an"
+ " incoherent build (#705). Re-run the release with a"
+ " refreshed cache.");
throw new MojoException(msg.toString());
}
}
/**
* Whether this module would auto-align the given upstream edge —
* delegating to {@link #autoAligned(File, String, String)} with the
* edge's typed and legacy policy-property names.
*/
private boolean autoAligned(File pomFile, CascadeEdge up) {
return autoAligned(pomFile, up.policyProperty(), up.policyPropertyLegacy());
}
/**
* Whether an upstream edge declared by the given policy properties
* would be <em>auto-aligned</em> — i.e. its effective policy is
* {@code integrate} (the default) or {@code release}, the two rungs
* {@code ReleasePrep} B8 bumps without a human gate.
*
* <p>A {@code notify}/{@code verify}/{@code propose} edge is
* hand-gated and legitimately sits behind latest, so it must NOT be
* asserted current. The read mirrors
* {@code ReleasePrep.alignUpstreamProperties} (B8): typed-marker
* property first, legacy form next, then the {@code integrate}
* default for an absent/blank/unresolved-reference value.
*
* <p>Package-private and static so it is unit-testable against a real
* temp POM with no Maven session (TESTING.md mock-last).
*
* @param pomFile the POM to read the policy from
* @param policyProperty the typed-marker policy property name
* @param policyPropertyLegacy the pre-#525 legacy policy property name
* @return {@code true} for an {@code integrate}/{@code release} edge
*/
static boolean autoAligned(File pomFile, String policyProperty,
String policyPropertyLegacy) {
String policyValue = ReleaseSupport.readPomProperty(pomFile, policyProperty);
if (policyValue == null) {
policyValue = ReleaseSupport.readPomProperty(pomFile, policyPropertyLegacy);
}
if (policyValue != null) {
policyValue = policyValue.trim();
}
if (policyValue == null || policyValue.isEmpty() || policyValue.contains("${")) {
policyValue = ReleasePolicy.INTEGRATE.literalName();
}
ReleasePolicy policy = RELEASE_POLICY_INDEX.get(policyValue);
return policy == ReleasePolicy.INTEGRATE || policy == ReleasePolicy.RELEASE;
}
/**
* The remote repository for the demanded scope — preferring the
* session's own configured repo of that id (so credentials, proxy,
* and mirror settings are honoured), falling back to a synthesized
* one at the well-known URL.
*/
private RemoteRepository demandedRepository(ResolutionScope scope) {
String wantId = scope == ResolutionScope.CENTRAL
? Repository.CENTRAL_ID
: NEXUS_PUBLIC_ID;
for (RemoteRepository r : session.getRemoteRepositories()) {
if (wantId.equals(r.getId())) {
return r;
}
}
String url = scope == ResolutionScope.CENTRAL ? CENTRAL_URL : NEXUS_PUBLIC_URL;
return session.createRemoteRepository(wantId, url);
}
}