raeva.io

Maven has no lockfile

npm writes a lockfile by default. Cargo does. Poetry does. Even Gradle has one you can turn on. Maven has none.

A pom.xml records what you asked for. What you get depends on what the repositories hold on the day of the build. That is a build I will not ship.

The gap is acknowledged upstream. Tamas Cservenak, a Maven committer, wrote in December 2025 that a project can end up on version ranges through a transitive dependency it does not control, and that the improvements in Maven 4 "still do not solve lack of lockfile". He is right. "Just don't use ranges" is not an answer. A transitive POM you do not control can put you on one.

Both cases below were reproduced with Maven 3.9.16 against a local file:// repository.

A version range moves

<dependency>
  <groupId>com.example</groupId>
  <artifactId>greeting</artifactId>
  <version>[1.0,2.0)</version>
</dependency>

The repository holds greeting 1.0.0, and the build uses it. Then 1.1.0 is published, and the next build takes it without a warning:

$ mvn dependency:list
   com.example:greeting:jar:1.1.0:compile -- module greeting (auto)

Same pom.xml. Different jar. Pinning your direct dependencies does not prevent this. A range in a dependency's POM moves the same way unless you also pin that artifact in dependencyManagement, and a SNAPSHOT moves on every deploy. The enforcer's banDynamicVersions rule can ban them, but it does not record what a build resolved. A ban is a policy. A lockfile is a record of what you actually built.

With an rv.lock written before 1.1.0 appeared, CI fails with exit status 7 and names the change:

$ rv sync --frozen
✗ lockfile mismatch: dependencies would change from current lockfile. Run 'rv sync' to update rv.lock, or remove --frozen flag.

Changed entries:
  ~ [pom.xml] external com.example:greeting:1.0.0 -> com.example:greeting:1.1.0 (linux-x86_64)

Taking 1.1.0 is then an rv sync and a reviewed change to rv.lock. Someone has to look at that diff on purpose.

A release changes its bytes

Maven Central never lets a release change. An internal repository does if redeploy is enabled or an admin deletes the artifact and someone uploads it again. Here greeting-1.0.0.jar is replaced along with its .sha1, so the two still match. A build from an empty local repository, as on most CI runners, gets the new jar, and mvn dependency:list prints the same line as before.

Same coordinates. Different bytes. The version string did not move.

rv.lock holds a SHA-256 of every artifact and POM, taken when it was written. From an empty cache, rv sync --frozen downloads again and compares:

$ rv sync --frozen
Changed entries:
  ~ [pom.xml] external checksum changed for com.example:greeting:1.0.0 (linux-x86_64)

rv compares bytes only when it downloads them, so this check needs a clean cache. Maven 3.9 can check on every build with trusted checksums, which verify each resolved artifact, including those already in ~/.m2, against a checksum file kept in the project. rv export-checksums writes that file from rv.lock (Maven reads it with the summary-file source set to SHA-256 and originAware=false). Trusted checksums cover bytes. They do not record which version a range picked. You need both.

rv

rv is a native CLI I built for this. It reads the pom.xml you already have, resolves it the way Maven does (parents, imported BOMs, profiles, exclusions, nearest-wins mediation) and writes rv.lock with each artifact's exact version, source repository and hashes. A multi-module reactor gets one lock at the root. Maven still runs the build:

rv sync          # resolve and write rv.lock
rv export-m2     # copy the locked artifacts into ~/.m2/repository
mvn -o package   # build offline against them

The closest prior art is chains-project's maven-lockfile, a Maven plugin that records versions and checksums of the resolved graph, plugins included. Running inside Maven, it agrees with Maven by construction. That is a real advantage. rv is a separate resolver and needs no JVM; the cost is that it has to show it resolves the same way.

I compare it with mvn dependency:list from Maven 3.9.16 on 48 open-source projects, among them apache/dubbo, spring-ai, netty, langchain4j and jenkins. 46 lock, and on all 1,309 modules where both tools produced a list, the external dependency sets are identical. The two failures are expected: apache/struts needs a repository declared only in a transitive POM, which rv does not trust by default, and flyway/flyway needs GitHub Packages credentials, without which Maven fails as well.

On spring-petclinic (171 dependencies, warm cache), rv sync --offline takes 0.27 s and mvn -o dependency:list 0.99 s, median of five runs on one machine. The tables are in docs/verification.md.

Limits

  • Build plugins are not locked. mvn -o still needs them in ~/.m2, usually from one earlier online build.
  • The comparison covers each module's external group:artifact:classifier:version set, not scopes or graph edges.
  • It has not been run against your company's repository manager. A POM on which rv and Maven disagree is a bug worth reporting.
curl -fsSL https://raeva.io/install.sh | sh   # or: brew install raeva-io/tap/rv
cd your-maven-project && rv sync

On Windows, install with irm https://raeva.io/install.ps1 | iex. PowerShell aliases rv to Remove-Variable, so type rv.exe there.

The source is at raeva-io/raeva, under MIT or Apache-2.0.

← all posts