From 390758a2ce33c43b9587f9ec0c1efa5797b70f1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 21:43:49 +0000 Subject: [PATCH 1/5] Add post: The Dependency Your Build Downloads That No Maven Tool Will Show You Covers the Surefire+JUnit5 dynamic-provider-resolution case (also affecting Failsafe, maven-compiler-plugin's annotationProcessorPaths, Quarkus, and protobuf-maven-plugin+os-maven-plugin): why dependency:tree, resolve-plugins, go-offline, and trusted checksums are all structurally blind to artifacts a plugin resolves imperatively at execution time, and how maven-lockfile's in-progress DynamicResolutionSpy extension closes that gap for generate/freeze. --- index.md | 1 + maven-hermetic-builds-blind-spot.md | 74 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 maven-hermetic-builds-blind-spot.md diff --git a/index.md b/index.md index b9658cd..9dbd5d5 100644 --- a/index.md +++ b/index.md @@ -94,6 +94,7 @@ See [https://github.com/chains-project/](https://github.com/orgs/chains-project/ ## Posts +- [The Dependency Your Build Downloads That No Maven Tool Will Show You](maven-hermetic-builds-blind-spot.md) - [Avoiding stale dependency declarations via Claude's hook](ai-bump.md) - [Practical Agentic Software Supply Chain Security](agentic-supply-chain-security.md) - [CHAINS contributions to open-source](chains-opensource.md) diff --git a/maven-hermetic-builds-blind-spot.md b/maven-hermetic-builds-blind-spot.md new file mode 100644 index 0000000..d903090 --- /dev/null +++ b/maven-hermetic-builds-blind-spot.md @@ -0,0 +1,74 @@ +--- +title: "The Dependency Your Build Downloads That No Maven Tool Will Show You" +--- + +# The Dependency Your Build Downloads That No Maven Tool Will Show You + +For hermetic and airgapped Maven builds, the first question is always: what do I need to pre-fetch? The obvious answer is "run `dependency:tree`, or `go-offline`, and mirror everything it lists." That answer is wrong, and the gap it leaves is invisible until you actually try to build offline. + +## A two-line reproduction + +```xml + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + +``` + +Nothing exotic: JUnit 5 as a test dependency, Surefire as the runner. Building it, however, pulls down artifacts that appear nowhere in this file: + +| GAV | Why | +|---|---| +| `org.apache.maven.surefire:surefire-junit-platform:3.2.5` | The JUnit-Platform *provider* Surefire selects at test-execution time | +| `org.apache.maven.surefire:common-java5:3.2.5` | The provider's own dependency | +| `org.junit.platform:junit-platform-launcher:1.9.3` | A **second**, older copy — alongside the `1.10.2` already pulled in by `junit-jupiter` | +| `org.junit.platform:junit-platform-engine:1.9.3`, `-commons:1.9.3`, `opentest4j:1.2.0` | Same story: shadow versions, pinned independently by Surefire | + +That's not a rounding error. The build ends up with two different versions of `junit-platform-launcher` — one your dependency graph knows about, one it doesn't. + +## Why: this isn't in the declared graph at all + +Surefire doesn't declare its test-framework provider as a Maven dependency. `maven-surefire-plugin`'s own POM lists exactly three dependencies (`maven-surefire-common`, `maven-core`, `maven-plugin-annotations`) — no provider anywhere. Instead, code inside the plugin inspects the test classpath at *execution time*, detects `org.junit.jupiter`, and makes a live, imperative resolver call for `org.apache.maven.surefire:surefire-junit-platform`, at Surefire's own version, independent of whatever JUnit version you declared. + +That's the root cause, and it's why every tool that only walks the declared POM graph is structurally blind to it — there's no edge in the graph to find. + +## None of the native mechanisms see it + +We checked, directly, against this exact reproduction: + +- **`mvn dependency:tree`** — 0 matches for any of the 7 artifacts above. +- **`mvn dependency:resolve-plugins`** — 0 matches. It resolves the plugin's own *declared* dependencies (`surefire-api`, `surefire-logger-api`, ...), but the provider isn't among them. +- **`mvn dependency:go-offline`** — 0 matches. Its own documentation promises "resolve everything needed to build offline." Then, using the exact repo it just populated: + ``` + mvn --offline test + ... + [ERROR] The following artifacts could not be resolved: + org.apache.maven.surefire:surefire-junit-platform:jar:3.2.5 (absent): + Cannot access central in offline mode and the artifact has not been + downloaded from it before. + ``` + A tool whose entire job is "make this buildable offline" ships a repo that isn't. + +This isn't a Surefire-specific quirk, either. `maven-failsafe-plugin` shares the same provider-selection code and shows the identical blind spot. `maven-compiler-plugin`'s `annotationProcessorPaths` (how tools like Error Prone get attached) resolves outside the main dependency graph too — [MCOMPILER-503](https://issues.apache.org/jira/browse/MCOMPILER-503) is the upstream ticket. `quarkus-maven-plugin` resolves "deployment" extension JARs the same way. `protobuf-maven-plugin`, combined with `os-maven-plugin`, resolves an OS-specific `protoc` binary whose exact coordinate can't even be known without evaluating a Maven extension first. The pattern — a plugin that calls straight into the resolver from its own Mojo code instead of declaring what it needs — is common enough that it has its own [maven-lockfile issue](https://github.com/chains-project/maven-lockfile/issues/1568). + +## Where maven-lockfile comes in + +Today, `mvn lockfile:generate` has the same blind spot as everything above — it also only walks the declared graph. We're changing that: a [draft in progress](https://github.com/chains-project/maven-lockfile/pull/1623) adds a `DynamicResolutionSpy`, a Maven core extension that taps `EventSpy`, the same extension point Maven itself uses to observe every artifact resolution in a session — regardless of which plugin triggered it, with no per-plugin logic required. Attach it via `.mvn/extensions.xml`, and it records what it sees; `generate` merges that recording into `lockfile.json` alongside the normal dependency graph, complete with a real SHA-256 checksum for each artifact — including all 7 GAVs above, verified end-to-end against this exact reproduction. + +That matters for `freeze` too. `lockfile:freeze` takes a generated lockfile and produces `pom.lockfile.xml` — a fully version-pinned POM meant to make a build reproducible without relying on Maven's live dependency resolution. A lockfile that's missing Surefire's provider is a lockfile that can describe a build it cannot actually reproduce offline. Once the dynamically-resolved artifacts are captured with real checksums at generation time, they become exactly the kind of pre-verified, pre-fetchable record an airgapped mirror needs — closing the gap between "the build passed `lockfile:validate`" and "the build actually runs with no network." + +The broader point generalizes past Surefire: `dependency:tree`, `resolve-plugins`, `go-offline`, and trusted-checksums schemes all share this blind spot, because they all read the same declared graph. Anything that resolves imperatively, from inside a plugin's own code, needs a different kind of observation — not a smarter reading of the POM, but watching what the resolver actually does. From 4dd19f25f911e3e1a1196dccc8cf22e84a2a2011 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:33:11 +0000 Subject: [PATCH 2/5] Correct GAV count to 8 and add dependency-chain diagram The parent POM org.apache.maven.surefire:surefire-providers:3.2.5 was missing from the original count - it's needed to resolve surefire-junit-platform's effective model (and is where the unversioned junit-platform-launcher dependency gets pinned to 1.9.3), but is only ever reachable through the same dynamic root, so it's equally invisible to static tools. Also clarifies that junit-platform-launcher resolves to two different versions (1.9.3 via the POM chain, 1.10.2 preferred by Surefire's own classpath assembly at runtime), and adds an ASCII diagram showing the actual parent-POM/dependency edges. --- maven-hermetic-builds-blind-spot.md | 46 +++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/maven-hermetic-builds-blind-spot.md b/maven-hermetic-builds-blind-spot.md index d903090..b43037b 100644 --- a/maven-hermetic-builds-blind-spot.md +++ b/maven-hermetic-builds-blind-spot.md @@ -29,28 +29,50 @@ For hermetic and airgapped Maven builds, the first question is always: what do I ``` -Nothing exotic: JUnit 5 as a test dependency, Surefire as the runner. Building it, however, pulls down artifacts that appear nowhere in this file: +Nothing exotic: JUnit 5 as a test dependency, Surefire as the runner. Building it, however, pulls down 8 artifacts that appear nowhere in this file. There's exactly **one** genuinely dynamic resolution — Surefire picking its test-framework provider — and everything else is that one artifact's own, perfectly ordinary POM ancestry and dependency tree, invisible only because the root of that tree was never in anyone's graph to begin with: -| GAV | Why | -|---|---| -| `org.apache.maven.surefire:surefire-junit-platform:3.2.5` | The JUnit-Platform *provider* Surefire selects at test-execution time | -| `org.apache.maven.surefire:common-java5:3.2.5` | The provider's own dependency | -| `org.junit.platform:junit-platform-launcher:1.9.3` | A **second**, older copy — alongside the `1.10.2` already pulled in by `junit-jupiter` | -| `org.junit.platform:junit-platform-engine:1.9.3`, `-commons:1.9.3`, `opentest4j:1.2.0` | Same story: shadow versions, pinned independently by Surefire | +``` +maven-surefire-plugin:3.2.5 (declared in the POM) + │ + │ at test-execution time: detects org.junit.jupiter on the test + │ classpath, resolves its provider directly - no POM edge for this step + ▼ +org.apache.maven.surefire:surefire-junit-platform:3.2.5 (jar) ← the one dynamic resolution + │ + ├─ parent POM ─▶ org.apache.maven.surefire:surefire-providers:3.2.5 (pom) + │ defines the dependencyManagement that pins the + │ unversioned junit-platform-launcher dependency below + │ + ├─ dependency ─▶ org.apache.maven.surefire:common-java5:3.2.5 (jar) + │ + └─ dependency ─▶ org.junit.platform:junit-platform-launcher:1.9.3 (jar) + │ (no version in surefire-junit-platform's own POM; + │ inherited from surefire-providers above) + │ + ├─ dependency ─▶ org.junit.platform:junit-platform-engine:1.9.3 (jar) + │ ├─ dependency ─▶ org.junit.platform:junit-platform-commons:1.9.3 (jar) + │ └─ dependency ─▶ org.opentest4j:opentest4j:1.2.0 (jar) + │ + └─ superseded at runtime by ─▶ org.junit.platform:junit-platform-launcher:1.10.2 (jar) + Surefire's own classpath assembly prefers this version for the actual + test run - but 1.9.3 is still resolved and downloaded via the POM chain + above, and neither version is ever declared by junit-jupiter, so + dependency:tree shows neither one. +``` -That's not a rounding error. The build ends up with two different versions of `junit-platform-launcher` — one your dependency graph knows about, one it doesn't. +Eight nodes, eight artifacts a hermetic mirror needs on disk: the provider jar, its parent POM, its direct dependency, two versions of `junit-platform-launcher`, and the three artifacts that hang off `1.9.3`. That's not a rounding error — the build ends up with two different versions of the same launcher jar, one your dependency graph knows about, one it doesn't, plus a parent POM that exists purely to serve a dependency the graph never had. ## Why: this isn't in the declared graph at all -Surefire doesn't declare its test-framework provider as a Maven dependency. `maven-surefire-plugin`'s own POM lists exactly three dependencies (`maven-surefire-common`, `maven-core`, `maven-plugin-annotations`) — no provider anywhere. Instead, code inside the plugin inspects the test classpath at *execution time*, detects `org.junit.jupiter`, and makes a live, imperative resolver call for `org.apache.maven.surefire:surefire-junit-platform`, at Surefire's own version, independent of whatever JUnit version you declared. +Surefire doesn't declare its test-framework provider as a Maven dependency. `maven-surefire-plugin`'s own POM lists exactly three dependencies (`maven-surefire-common`, `maven-core`, `maven-plugin-annotations`) — no provider anywhere. Instead, code inside the plugin inspects the test classpath at *execution time*, detects `org.junit.jupiter`, and makes a live, imperative resolver call for `org.apache.maven.surefire:surefire-junit-platform`, at Surefire's own version, independent of whatever JUnit version you declared. Once that one call happens, Maven resolves everything under it — the parent POM, the dependencies — through its completely ordinary, static resolution machinery. It's one dynamic entry point dragging in a normal seven-artifact static subtree behind it. -That's the root cause, and it's why every tool that only walks the declared POM graph is structurally blind to it — there's no edge in the graph to find. +That's the root cause, and it's why every tool that only walks the *declared* POM graph is structurally blind to the whole subtree — there's no edge in anyone's graph pointing at the root. ## None of the native mechanisms see it We checked, directly, against this exact reproduction: -- **`mvn dependency:tree`** — 0 matches for any of the 7 artifacts above. +- **`mvn dependency:tree`** — 0 matches for any of the 8 artifacts above. - **`mvn dependency:resolve-plugins`** — 0 matches. It resolves the plugin's own *declared* dependencies (`surefire-api`, `surefire-logger-api`, ...), but the provider isn't among them. - **`mvn dependency:go-offline`** — 0 matches. Its own documentation promises "resolve everything needed to build offline." Then, using the exact repo it just populated: ``` @@ -67,7 +89,7 @@ This isn't a Surefire-specific quirk, either. `maven-failsafe-plugin` shares the ## Where maven-lockfile comes in -Today, `mvn lockfile:generate` has the same blind spot as everything above — it also only walks the declared graph. We're changing that: a [draft in progress](https://github.com/chains-project/maven-lockfile/pull/1623) adds a `DynamicResolutionSpy`, a Maven core extension that taps `EventSpy`, the same extension point Maven itself uses to observe every artifact resolution in a session — regardless of which plugin triggered it, with no per-plugin logic required. Attach it via `.mvn/extensions.xml`, and it records what it sees; `generate` merges that recording into `lockfile.json` alongside the normal dependency graph, complete with a real SHA-256 checksum for each artifact — including all 7 GAVs above, verified end-to-end against this exact reproduction. +Today, `mvn lockfile:generate` has the same blind spot as everything above — it also only walks the declared graph. We're changing that: a [draft in progress](https://github.com/chains-project/maven-lockfile/pull/1623) adds a `DynamicResolutionSpy`, a Maven core extension that taps `EventSpy`, the same extension point Maven itself uses to observe every artifact resolution in a session — regardless of which plugin triggered it, with no per-plugin logic required. Attach it via `.mvn/extensions.xml`, and it records what it sees; `generate` merges that recording into `lockfile.json` alongside the normal dependency graph, complete with a real SHA-256 checksum for each artifact — verified end-to-end against this exact reproduction, capturing all 7 jars in the diagram above. (The parent POM is a known follow-up: the extension currently records binary artifacts only, on the assumption that a POM is already visible through some declared parent/BOM chain — an assumption this exact case disproves, since `surefire-providers.pom` is only ever reachable through the dynamic root.) That matters for `freeze` too. `lockfile:freeze` takes a generated lockfile and produces `pom.lockfile.xml` — a fully version-pinned POM meant to make a build reproducible without relying on Maven's live dependency resolution. A lockfile that's missing Surefire's provider is a lockfile that can describe a build it cannot actually reproduce offline. Once the dynamically-resolved artifacts are captured with real checksums at generation time, they become exactly the kind of pre-verified, pre-fetchable record an airgapped mirror needs — closing the gap between "the build passed `lockfile:validate`" and "the build actually runs with no network." From 4f724abbb48506dd7aad15d3100400355547c824 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 15:57:12 +0000 Subject: [PATCH 3/5] Show exact commands with -Dmaven.repo.local for reproducibility --- maven-hermetic-builds-blind-spot.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/maven-hermetic-builds-blind-spot.md b/maven-hermetic-builds-blind-spot.md index b43037b..adc5d8a 100644 --- a/maven-hermetic-builds-blind-spot.md +++ b/maven-hermetic-builds-blind-spot.md @@ -70,14 +70,19 @@ That's the root cause, and it's why every tool that only walks the *declared* PO ## None of the native mechanisms see it -We checked, directly, against this exact reproduction: +We checked, directly, against this exact reproduction — each command against its own throwaway local repo, via `-Dmaven.repo.local`, so nothing pre-cached from an earlier step hides the gap: -- **`mvn dependency:tree`** — 0 matches for any of the 8 artifacts above. -- **`mvn dependency:resolve-plugins`** — 0 matches. It resolves the plugin's own *declared* dependencies (`surefire-api`, `surefire-logger-api`, ...), but the provider isn't among them. -- **`mvn dependency:go-offline`** — 0 matches. Its own documentation promises "resolve everything needed to build offline." Then, using the exact repo it just populated: +``` +mvn -B org.apache.maven.plugins:maven-dependency-plugin:3.10.0:tree -Dmaven.repo.local=sandbox +mvn -B org.apache.maven.plugins:maven-dependency-plugin:3.10.0:resolve-plugins -Dmaven.repo.local=sandbox +mvn -B org.apache.maven.plugins:maven-dependency-plugin:3.10.0:go-offline -Dmaven.repo.local=sandbox +mvn -B --offline test -Dmaven.repo.local=sandbox # using the repo go-offline just populated +``` + +- **`dependency:tree`** — 0 matches for any of the 8 artifacts above. +- **`dependency:resolve-plugins`** — 0 matches. It resolves the plugin's own *declared* dependencies (`surefire-api`, `surefire-logger-api`, ...), but the provider isn't among them. +- **`dependency:go-offline`** — 0 matches. Its own documentation promises "resolve everything needed to build offline." Then, `mvn --offline test` against the exact repo it just populated: ``` - mvn --offline test - ... [ERROR] The following artifacts could not be resolved: org.apache.maven.surefire:surefire-junit-platform:jar:3.2.5 (absent): Cannot access central in offline mode and the artifact has not been @@ -85,6 +90,8 @@ We checked, directly, against this exact reproduction: ``` A tool whose entire job is "make this buildable offline" ships a repo that isn't. +For comparison, an actual `mvn test -Dmaven.repo.local=sandbox2` run resolves all 8 — inspect `sandbox2/` afterward and every artifact from the diagram is there, `surefire-providers` POM included. + This isn't a Surefire-specific quirk, either. `maven-failsafe-plugin` shares the same provider-selection code and shows the identical blind spot. `maven-compiler-plugin`'s `annotationProcessorPaths` (how tools like Error Prone get attached) resolves outside the main dependency graph too — [MCOMPILER-503](https://issues.apache.org/jira/browse/MCOMPILER-503) is the upstream ticket. `quarkus-maven-plugin` resolves "deployment" extension JARs the same way. `protobuf-maven-plugin`, combined with `os-maven-plugin`, resolves an OS-specific `protoc` binary whose exact coordinate can't even be known without evaluating a Maven extension first. The pattern — a plugin that calls straight into the resolver from its own Mojo code instead of declaring what it needs — is common enough that it has its own [maven-lockfile issue](https://github.com/chains-project/maven-lockfile/issues/1568). ## Where maven-lockfile comes in From c812cad73b63d1b76b24cff9f8fec4547d46755c Mon Sep 17 00:00:00 2001 From: Aman Sharma Date: Wed, 12 Aug 2026 19:00:31 +0200 Subject: [PATCH 4/5] Polish blog post --- maven-hermetic-builds-blind-spot.md | 33 ++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/maven-hermetic-builds-blind-spot.md b/maven-hermetic-builds-blind-spot.md index adc5d8a..2a606d8 100644 --- a/maven-hermetic-builds-blind-spot.md +++ b/maven-hermetic-builds-blind-spot.md @@ -4,7 +4,7 @@ title: "The Dependency Your Build Downloads That No Maven Tool Will Show You" # The Dependency Your Build Downloads That No Maven Tool Will Show You -For hermetic and airgapped Maven builds, the first question is always: what do I need to pre-fetch? The obvious answer is "run `dependency:tree`, or `go-offline`, and mirror everything it lists." That answer is wrong, and the gap it leaves is invisible until you actually try to build offline. +For hermetic and airgapped Maven builds, the first question is "what do I need to pre-fetch?" The obvious answer is "run `dependency:tree`, or `go-offline`, and mirror everything it lists." That answer is wrong, and the gap it leaves is invisible until you actually try to build offline. ## A two-line reproduction @@ -29,12 +29,14 @@ For hermetic and airgapped Maven builds, the first question is always: what do I ``` -Nothing exotic: JUnit 5 as a test dependency, Surefire as the runner. Building it, however, pulls down 8 artifacts that appear nowhere in this file. There's exactly **one** genuinely dynamic resolution — Surefire picking its test-framework provider — and everything else is that one artifact's own, perfectly ordinary POM ancestry and dependency tree, invisible only because the root of that tree was never in anyone's graph to begin with: +Building this minimal POM, however, pulls down 8 artifacts that appear nowhere in this file. +The reason for this is **dynamic dependency resolution**. +Surefire picks its test-framework provider and its dependencies. ``` maven-surefire-plugin:3.2.5 (declared in the POM) │ - │ at test-execution time: detects org.junit.jupiter on the test + │ at test-execution time: detects junit-platform-commons on the test │ classpath, resolves its provider directly - no POM edge for this step ▼ org.apache.maven.surefire:surefire-junit-platform:3.2.5 (jar) ← the one dynamic resolution @@ -60,11 +62,17 @@ org.apache.maven.surefire:surefire-junit-platform:3.2.5 (jar) ← the one dyn dependency:tree shows neither one. ``` -Eight nodes, eight artifacts a hermetic mirror needs on disk: the provider jar, its parent POM, its direct dependency, two versions of `junit-platform-launcher`, and the three artifacts that hang off `1.9.3`. That's not a rounding error — the build ends up with two different versions of the same launcher jar, one your dependency graph knows about, one it doesn't, plus a parent POM that exists purely to serve a dependency the graph never had. ## Why: this isn't in the declared graph at all -Surefire doesn't declare its test-framework provider as a Maven dependency. `maven-surefire-plugin`'s own POM lists exactly three dependencies (`maven-surefire-common`, `maven-core`, `maven-plugin-annotations`) — no provider anywhere. Instead, code inside the plugin inspects the test classpath at *execution time*, detects `org.junit.jupiter`, and makes a live, imperative resolver call for `org.apache.maven.surefire:surefire-junit-platform`, at Surefire's own version, independent of whatever JUnit version you declared. Once that one call happens, Maven resolves everything under it — the parent POM, the dependencies — through its completely ordinary, static resolution machinery. It's one dynamic entry point dragging in a normal seven-artifact static subtree behind it. +Surefire doesn't declare its test-framework provider as a Maven dependency. `maven-surefire-plugin`'s own POM lists exactly three dependencies (`maven-surefire-common`, `maven-core`, `maven-plugin-annotations`) — no provider anywhere. +Instead, [code inside the plugin](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L1105-L1113) inspects the test classpath at *execution time* and makes a live, imperative resolver call for `org.apache.maven.surefire:surefire-junit-platform`, at Surefire's own version, independent of whatever JUnit version you declared. + +1. [`createProviders`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L1105-L1113) builds the candidate provider list and picks the first applicable one. +2. [`JUnitPlatformProviderInfo.isApplicable()`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L2953-L2955) is the detection — it returns true when [`getJUnit5Artifact()`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L2183-L2190) finds `org.junit.platform:junit-platform-commons` on the project's test classpath, which is exactly what our one declared `junit-jupiter` dependency drags in. +3. [`JUnitPlatformProviderInfo.getProviderClasspath()`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L2972-L2979) then passes the string literal `"surefire-junit-platform"` — plus Surefire's own version, not yours — straight into [`SurefireDependencyResolver.getProviderClasspath`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/SurefireDependencyResolver.java#L186-L200), which synthesizes a `Dependency` on the spot and hands it to Aether. + +That last call is the dynamic edge: a coordinate that exists only as an argument in Java code, never as an entry in any POM. Once that one call happens, Maven resolves everything under it — the parent POM, the dependencies — through its completely ordinary, static resolution machinery. It's one dynamic entry point dragging in a normal seven-artifact static subtree behind it. That's the root cause, and it's why every tool that only walks the *declared* POM graph is structurally blind to the whole subtree — there's no edge in anyone's graph pointing at the root. @@ -88,16 +96,17 @@ mvn -B --offline test -Dmaven.repo.local=sandbox # using the repo go-offline j Cannot access central in offline mode and the artifact has not been downloaded from it before. ``` - A tool whose entire job is "make this buildable offline" ships a repo that isn't. For comparison, an actual `mvn test -Dmaven.repo.local=sandbox2` run resolves all 8 — inspect `sandbox2/` afterward and every artifact from the diagram is there, `surefire-providers` POM included. -This isn't a Surefire-specific quirk, either. `maven-failsafe-plugin` shares the same provider-selection code and shows the identical blind spot. `maven-compiler-plugin`'s `annotationProcessorPaths` (how tools like Error Prone get attached) resolves outside the main dependency graph too — [MCOMPILER-503](https://issues.apache.org/jira/browse/MCOMPILER-503) is the upstream ticket. `quarkus-maven-plugin` resolves "deployment" extension JARs the same way. `protobuf-maven-plugin`, combined with `os-maven-plugin`, resolves an OS-specific `protoc` binary whose exact coordinate can't even be known without evaluating a Maven extension first. The pattern — a plugin that calls straight into the resolver from its own Mojo code instead of declaring what it needs — is common enough that it has its own [maven-lockfile issue](https://github.com/chains-project/maven-lockfile/issues/1568). - -## Where maven-lockfile comes in +This isn't a Surefire-specific quirk, either: -Today, `mvn lockfile:generate` has the same blind spot as everything above — it also only walks the declared graph. We're changing that: a [draft in progress](https://github.com/chains-project/maven-lockfile/pull/1623) adds a `DynamicResolutionSpy`, a Maven core extension that taps `EventSpy`, the same extension point Maven itself uses to observe every artifact resolution in a session — regardless of which plugin triggered it, with no per-plugin logic required. Attach it via `.mvn/extensions.xml`, and it records what it sees; `generate` merges that recording into `lockfile.json` alongside the normal dependency graph, complete with a real SHA-256 checksum for each artifact — verified end-to-end against this exact reproduction, capturing all 7 jars in the diagram above. (The parent POM is a known follow-up: the extension currently records binary artifacts only, on the assumption that a POM is already visible through some declared parent/BOM chain — an assumption this exact case disproves, since `surefire-providers.pom` is only ever reachable through the dynamic root.) +- **`maven-failsafe-plugin`** — shares the same provider-selection code, and shows the identical blind spot. +- **`maven-compiler-plugin`** — `annotationProcessorPaths`, how tools like Error Prone get attached, resolves outside the main dependency graph. [MCOMPILER-503](https://issues.apache.org/jira/browse/MCOMPILER-503) is the upstream ticket. +- **`quarkus-maven-plugin`** — resolves "deployment" extension JARs the same way. +- **`protobuf-maven-plugin`** — combined with `os-maven-plugin`, resolves an OS-specific `protoc` binary whose exact coordinate can't even be known without evaluating a Maven extension first. -That matters for `freeze` too. `lockfile:freeze` takes a generated lockfile and produces `pom.lockfile.xml` — a fully version-pinned POM meant to make a build reproducible without relying on Maven's live dependency resolution. A lockfile that's missing Surefire's provider is a lockfile that can describe a build it cannot actually reproduce offline. Once the dynamically-resolved artifacts are captured with real checksums at generation time, they become exactly the kind of pre-verified, pre-fetchable record an airgapped mirror needs — closing the gap between "the build passed `lockfile:validate`" and "the build actually runs with no network." +## [maven-lockfile](https://github.com/chains-project/maven-lockfile) -The broader point generalizes past Surefire: `dependency:tree`, `resolve-plugins`, `go-offline`, and trusted-checksums schemes all share this blind spot, because they all read the same declared graph. Anything that resolves imperatively, from inside a plugin's own code, needs a different kind of observation — not a smarter reading of the POM, but watching what the resolver actually does. +[maven-lockfile](https://github.com/chains-project/maven-lockfile) is a Maven plugin that pins every dependency of a build to an exact version and checksum in a `lockfile.json`, so the same build always resolves the same artifacts. +Our goal is to record these dynamically-resolved artifacts in the lockfile too, with their checksums, so that a lockfile is a complete pre-fetch list and hermetic, offline builds actually work. \ No newline at end of file From 6bcdb01e6e6da205179adec969008fd847ca4905 Mon Sep 17 00:00:00 2001 From: Aman Sharma Date: Wed, 12 Aug 2026 19:08:28 +0200 Subject: [PATCH 5/5] Remove em dashes and tighten wording Co-Authored-By: Claude Sonnet 5 --- maven-hermetic-builds-blind-spot.md | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/maven-hermetic-builds-blind-spot.md b/maven-hermetic-builds-blind-spot.md index 2a606d8..2fa720c 100644 --- a/maven-hermetic-builds-blind-spot.md +++ b/maven-hermetic-builds-blind-spot.md @@ -29,7 +29,7 @@ For hermetic and airgapped Maven builds, the first question is "what do I need t ``` -Building this minimal POM, however, pulls down 8 artifacts that appear nowhere in this file. +Building this minimal POM, pulls down 8 artifacts that appear nowhere in this file apart from the two declared dependencies. The reason for this is **dynamic dependency resolution**. Surefire picks its test-framework provider and its dependencies. @@ -63,22 +63,22 @@ org.apache.maven.surefire:surefire-junit-platform:3.2.5 (jar) ← the one dyn ``` -## Why: this isn't in the declared graph at all +## Why dynamic resolution? -Surefire doesn't declare its test-framework provider as a Maven dependency. `maven-surefire-plugin`'s own POM lists exactly three dependencies (`maven-surefire-common`, `maven-core`, `maven-plugin-annotations`) — no provider anywhere. +Surefire doesn't declare its test-framework provider as a Maven dependency. `maven-surefire-plugin`'s own POM lists exactly three dependencies (`maven-surefire-common`, `maven-core`, `maven-plugin-annotations`). Instead, [code inside the plugin](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L1105-L1113) inspects the test classpath at *execution time* and makes a live, imperative resolver call for `org.apache.maven.surefire:surefire-junit-platform`, at Surefire's own version, independent of whatever JUnit version you declared. 1. [`createProviders`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L1105-L1113) builds the candidate provider list and picks the first applicable one. -2. [`JUnitPlatformProviderInfo.isApplicable()`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L2953-L2955) is the detection — it returns true when [`getJUnit5Artifact()`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L2183-L2190) finds `org.junit.platform:junit-platform-commons` on the project's test classpath, which is exactly what our one declared `junit-jupiter` dependency drags in. -3. [`JUnitPlatformProviderInfo.getProviderClasspath()`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L2972-L2979) then passes the string literal `"surefire-junit-platform"` — plus Surefire's own version, not yours — straight into [`SurefireDependencyResolver.getProviderClasspath`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/SurefireDependencyResolver.java#L186-L200), which synthesizes a `Dependency` on the spot and hands it to Aether. +2. [`JUnitPlatformProviderInfo.isApplicable()`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L2953-L2955) is the detection: it returns true when [`getJUnit5Artifact()`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L2183-L2190) finds `org.junit.platform:junit-platform-commons` on the project's test classpath, which is exactly what our one declared `junit-jupiter` dependency drags in. +3. [`JUnitPlatformProviderInfo.getProviderClasspath()`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java#L2972-L2979) then passes the string literal `"surefire-junit-platform"` (plus Surefire's own version, not yours) straight into [`SurefireDependencyResolver.getProviderClasspath`](https://github.com/apache/maven-surefire/blob/surefire-3.2.5/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/SurefireDependencyResolver.java#L186-L200), which synthesizes a `Dependency` on the spot and hands it to Aether. -That last call is the dynamic edge: a coordinate that exists only as an argument in Java code, never as an entry in any POM. Once that one call happens, Maven resolves everything under it — the parent POM, the dependencies — through its completely ordinary, static resolution machinery. It's one dynamic entry point dragging in a normal seven-artifact static subtree behind it. +That last call is the dynamic edge: a coordinate that exists only as an argument in Java code, never as an entry in any POM. Once that one call happens, Maven resolves everything under it (the parent POM, the dependencies) through its completely ordinary, static resolution machinery. It's one dynamic entry point dragging in a normal seven-artifact static subtree behind it. -That's the root cause, and it's why every tool that only walks the *declared* POM graph is structurally blind to the whole subtree — there's no edge in anyone's graph pointing at the root. +That's the root cause, and it's why every tool that only walks the *declared* POM graph is structurally blind to the whole subtree: there's no edge in anyone's graph pointing at the root. ## None of the native mechanisms see it -We checked, directly, against this exact reproduction — each command against its own throwaway local repo, via `-Dmaven.repo.local`, so nothing pre-cached from an earlier step hides the gap: +We checked, directly, against this exact reproduction, each command against its own throwaway local repo, via `-Dmaven.repo.local`, so nothing pre-cached from an earlier step hides the gap: ``` mvn -B org.apache.maven.plugins:maven-dependency-plugin:3.10.0:tree -Dmaven.repo.local=sandbox @@ -87,9 +87,9 @@ mvn -B org.apache.maven.plugins:maven-dependency-plugin:3.10.0:go-offline -D mvn -B --offline test -Dmaven.repo.local=sandbox # using the repo go-offline just populated ``` -- **`dependency:tree`** — 0 matches for any of the 8 artifacts above. -- **`dependency:resolve-plugins`** — 0 matches. It resolves the plugin's own *declared* dependencies (`surefire-api`, `surefire-logger-api`, ...), but the provider isn't among them. -- **`dependency:go-offline`** — 0 matches. Its own documentation promises "resolve everything needed to build offline." Then, `mvn --offline test` against the exact repo it just populated: +- **`dependency:tree`**: 0 matches for any of the 8 artifacts above. +- **`dependency:resolve-plugins`**: 0 matches. It resolves the plugin's own *declared* dependencies (`surefire-api`, `surefire-logger-api`, ...), but the provider isn't among them. +- **`dependency:go-offline`**: 0 matches. Its own documentation promises "resolve everything needed to build offline." Then, `mvn --offline test` against the exact repo it just populated: ``` [ERROR] The following artifacts could not be resolved: org.apache.maven.surefire:surefire-junit-platform:jar:3.2.5 (absent): @@ -97,14 +97,14 @@ mvn -B --offline test -Dmaven.repo.local=sandbox # using the repo go-offline j downloaded from it before. ``` -For comparison, an actual `mvn test -Dmaven.repo.local=sandbox2` run resolves all 8 — inspect `sandbox2/` afterward and every artifact from the diagram is there, `surefire-providers` POM included. +For comparison, an actual `mvn test -Dmaven.repo.local=sandbox2` run resolves all 8; inspect `sandbox2/` afterward and every artifact from the diagram is there, `surefire-providers` POM included. This isn't a Surefire-specific quirk, either: -- **`maven-failsafe-plugin`** — shares the same provider-selection code, and shows the identical blind spot. -- **`maven-compiler-plugin`** — `annotationProcessorPaths`, how tools like Error Prone get attached, resolves outside the main dependency graph. [MCOMPILER-503](https://issues.apache.org/jira/browse/MCOMPILER-503) is the upstream ticket. -- **`quarkus-maven-plugin`** — resolves "deployment" extension JARs the same way. -- **`protobuf-maven-plugin`** — combined with `os-maven-plugin`, resolves an OS-specific `protoc` binary whose exact coordinate can't even be known without evaluating a Maven extension first. +- **`maven-failsafe-plugin`**: shares the same provider-selection code, and shows the identical blind spot. +- **`maven-compiler-plugin`**: `annotationProcessorPaths`, how tools like Error Prone get attached, resolves outside the main dependency graph. [MCOMPILER-503](https://issues.apache.org/jira/browse/MCOMPILER-503) is the upstream ticket. +- **`quarkus-maven-plugin`**: resolves "deployment" extension JARs the same way. +- **`protobuf-maven-plugin`**: combined with `os-maven-plugin`, resolves an OS-specific `protoc` binary whose exact coordinate can't even be known without evaluating a Maven extension first. ## [maven-lockfile](https://github.com/chains-project/maven-lockfile)