<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Raeva blog</title>
    <subtitle>Notes on reproducible Maven builds and rv, the Maven lockfile CLI.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://raeva.io/blog/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://raeva.io/blog"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-10-15T00:00:00+00:00</updated>
    <id>https://raeva.io/blog/atom.xml</id>
    <entry xml:lang="en">
        <title>Maven has no lockfile</title>
        <published>2026-10-15T00:00:00+00:00</published>
        <updated>2026-10-15T00:00:00+00:00</updated>
        
        <author>
          <name>Raeva</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://raeva.io/blog/maven-has-no-lockfile/"/>
        <id>https://raeva.io/blog/maven-has-no-lockfile/</id>
        
        <content type="html" xml:base="https://raeva.io/blog/maven-has-no-lockfile/">&lt;p&gt;npm writes a lockfile by default. Cargo does. Poetry does. Even
Gradle has one you can turn on. Maven has none.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;pom.xml&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;The gap is acknowledged upstream. Tamas Cservenak, a Maven committer,
&lt;a rel=&quot;external&quot; href=&quot;https://maveniverse.eu/blog/2025/12/06/lockfiles/&quot;&gt;wrote in December 2025&lt;/a&gt;
that a project can end up on version ranges through a transitive
dependency it does not control, and that the improvements in Maven 4
&quot;still do not solve lack of lockfile&quot;. He is right. &quot;Just don&#39;t use
ranges&quot; is not an answer. A transitive POM you do not control can
put you on one.&lt;/p&gt;
&lt;p&gt;Both cases below were reproduced with Maven 3.9.16 against a local
&lt;code&gt;file://&lt;/code&gt; repository.&lt;/p&gt;
&lt;h2 id=&quot;a-version-range-moves&quot;&gt;A version range moves&lt;/h2&gt;
&lt;pre&gt;&lt;code data-lang=&quot;xml&quot;&gt;&amp;lt;dependency&amp;gt;
  &amp;lt;groupId&amp;gt;com.example&amp;lt;/groupId&amp;gt;
  &amp;lt;artifactId&amp;gt;greeting&amp;lt;/artifactId&amp;gt;
  &amp;lt;version&amp;gt;[1.0,2.0)&amp;lt;/version&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The repository holds &lt;code&gt;greeting&lt;/code&gt; 1.0.0, and the build uses it. Then
1.1.0 is published, and the next build takes it without a warning:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ mvn dependency:list
   com.example:greeting:jar:1.1.0:compile -- module greeting (auto)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same &lt;code&gt;pom.xml&lt;/code&gt;. Different jar. Pinning your direct dependencies does
not prevent this. A range in a dependency&#39;s POM moves the same way
unless you also pin that artifact in &lt;code&gt;dependencyManagement&lt;/code&gt;, and a
SNAPSHOT moves on every deploy. The enforcer&#39;s &lt;code&gt;banDynamicVersions&lt;/code&gt;
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.&lt;/p&gt;
&lt;p&gt;With an &lt;code&gt;rv.lock&lt;/code&gt; written before 1.1.0 appeared, CI fails with exit
status 7 and names the change:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ rv sync --frozen
✗ lockfile mismatch: dependencies would change from current lockfile. Run &amp;#39;rv sync&amp;#39; to update rv.lock, or remove --frozen flag.

Changed entries:
  ~ [pom.xml] external com.example:greeting:1.0.0 -&amp;gt; com.example:greeting:1.1.0 (linux-x86_64)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Taking 1.1.0 is then an &lt;code&gt;rv sync&lt;/code&gt; and a reviewed change to &lt;code&gt;rv.lock&lt;/code&gt;.
Someone has to look at that diff on purpose.&lt;/p&gt;
&lt;h2 id=&quot;a-release-changes-its-bytes&quot;&gt;A release changes its bytes&lt;/h2&gt;
&lt;p&gt;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 &lt;code&gt;greeting-1.0.0.jar&lt;/code&gt; is replaced along
with its &lt;code&gt;.sha1&lt;/code&gt;, so the two still match. A build from an empty local
repository, as on most CI runners, gets the new jar, and
&lt;code&gt;mvn dependency:list&lt;/code&gt; prints the same line as before.&lt;/p&gt;
&lt;p&gt;Same coordinates. Different bytes. The version string did not move.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rv.lock&lt;/code&gt; holds a SHA-256 of every artifact and POM, taken when it
was written. From an empty cache, &lt;code&gt;rv sync --frozen&lt;/code&gt; downloads again
and compares:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ rv sync --frozen
Changed entries:
  ~ [pom.xml] external checksum changed for com.example:greeting:1.0.0 (linux-x86_64)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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 &lt;code&gt;~/.m2&lt;/code&gt;, against a checksum file kept in the project.
&lt;code&gt;rv export-checksums&lt;/code&gt; writes that file from &lt;code&gt;rv.lock&lt;/code&gt; (Maven reads
it with the summary-file source set to SHA-256 and
&lt;code&gt;originAware=false&lt;/code&gt;). Trusted checksums cover bytes. They do not
record which version a range picked. You need both.&lt;/p&gt;
&lt;h2 id=&quot;rv&quot;&gt;rv&lt;/h2&gt;
&lt;p&gt;rv is a native CLI I built for this. It reads the
&lt;code&gt;pom.xml&lt;/code&gt; you already have, resolves it the way Maven does (parents,
imported BOMs, profiles, exclusions, nearest-wins mediation) and
writes &lt;code&gt;rv.lock&lt;/code&gt; with each artifact&#39;s exact version, source
repository and hashes. A multi-module reactor gets one lock at the
root. Maven still runs the build:&lt;/p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;rv sync          # resolve and write rv.lock
rv export-m2     # copy the locked artifacts into ~/.m2/repository
mvn -o package   # build offline against them
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The closest prior art is chains-project&#39;s
&lt;a rel=&quot;external&quot; href=&quot;https://github.com/chains-project/maven-lockfile&quot;&gt;maven-lockfile&lt;/a&gt;, 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.&lt;/p&gt;
&lt;p&gt;I compare it with &lt;code&gt;mvn dependency:list&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;On spring-petclinic (171 dependencies, warm cache), &lt;code&gt;rv sync --offline&lt;/code&gt; takes 0.27 s and &lt;code&gt;mvn -o dependency:list&lt;/code&gt; 0.99 s, median
of five runs on one machine. The tables are in
&lt;a rel=&quot;external&quot; href=&quot;https://github.com/raeva-io/raeva/blob/main/docs/verification.md&quot;&gt;docs/verification.md&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;limits&quot;&gt;Limits&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Build plugins are not locked. &lt;code&gt;mvn -o&lt;/code&gt; still needs them in
&lt;code&gt;~/.m2&lt;/code&gt;, usually from one earlier online build.&lt;/li&gt;
&lt;li&gt;The comparison covers each module&#39;s external
&lt;code&gt;group:artifact:classifier:version&lt;/code&gt; set, not scopes or graph
edges.&lt;/li&gt;
&lt;li&gt;It has not been run against your company&#39;s repository manager. A
POM on which rv and Maven disagree is a bug worth reporting.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;curl -fsSL https://raeva.io/install.sh | sh   # or: brew install raeva-io/tap/rv
cd your-maven-project &amp;amp;&amp;amp; rv sync
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Windows, install with &lt;code&gt;irm https://raeva.io/install.ps1 | iex&lt;/code&gt;.
PowerShell aliases &lt;code&gt;rv&lt;/code&gt; to Remove-Variable, so type &lt;code&gt;rv.exe&lt;/code&gt; there.&lt;/p&gt;
&lt;p&gt;The source is at &lt;a rel=&quot;external&quot; href=&quot;https://github.com/raeva-io/raeva&quot;&gt;raeva-io/raeva&lt;/a&gt;,
under MIT or Apache-2.0.&lt;/p&gt;
</content>
        
    </entry>
</feed>
