Skip to content

Commit c812cad

Browse files
committed
Polish blog post
1 parent 4f724ab commit c812cad

1 file changed

Lines changed: 21 additions & 12 deletions

File tree

maven-hermetic-builds-blind-spot.md

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ title: "The Dependency Your Build Downloads That No Maven Tool Will Show You"
44

55
# The Dependency Your Build Downloads That No Maven Tool Will Show You
66

7-
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.
7+
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.
88

99
## A two-line reproduction
1010

@@ -29,12 +29,14 @@ For hermetic and airgapped Maven builds, the first question is always: what do I
2929
</build>
3030
```
3131

32-
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:
32+
Building this minimal POM, however, pulls down 8 artifacts that appear nowhere in this file.
33+
The reason for this is **dynamic dependency resolution**.
34+
Surefire picks its test-framework provider and its dependencies.
3335

3436
```
3537
maven-surefire-plugin:3.2.5 (declared in the POM)
3638
37-
│ at test-execution time: detects org.junit.jupiter on the test
39+
│ at test-execution time: detects junit-platform-commons on the test
3840
│ classpath, resolves its provider directly - no POM edge for this step
3941
4042
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
6062
dependency:tree shows neither one.
6163
```
6264

63-
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.
6465

6566
## Why: this isn't in the declared graph at all
6667

67-
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.
68+
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.
69+
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.
70+
71+
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.
72+
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.
73+
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.
74+
75+
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.
6876

6977
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.
7078

@@ -88,16 +96,17 @@ mvn -B --offline test -Dmaven.repo.local=sandbox # using the repo go-offline j
8896
Cannot access central in offline mode and the artifact has not been
8997
downloaded from it before.
9098
```
91-
A tool whose entire job is "make this buildable offline" ships a repo that isn't.
9299

93100
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.
94101

95-
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).
96-
97-
## Where maven-lockfile comes in
102+
This isn't a Surefire-specific quirk, either:
98103

99-
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.)
104+
- **`maven-failsafe-plugin`** — shares the same provider-selection code, and shows the identical blind spot.
105+
- **`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.
106+
- **`quarkus-maven-plugin`** — resolves "deployment" extension JARs the same way.
107+
- **`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.
100108

101-
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."
109+
## [maven-lockfile](https://github.com/chains-project/maven-lockfile)
102110

103-
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.
111+
[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.
112+
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.

0 commit comments

Comments
 (0)