← Java articles

Java / JVM / Build Systems

Reproducible Java Builds: Pinning, Locking, and Clean-Room Verification

“It built on my machine” still happens. Here’s how I pin versions, lock dependencies, and verify reproducibility in CI.

Shubham Anandani··5 min read

I’ve seen Java builds succeed locally and differ in CI—transitive dependencies float, plugin versions advance, or caches hide changes. Reproducibility takes intention: pin versions, use lock files, and verify clean-room rebuilds. In a Java build, dependency locks and clean CI environments help reveal resolution drift before artifacts are released.

The failure mode

You set versions in pom.xml but leave transitive ranges unconstrained. Locally, a cached artifact hides a minor release; in CI, it resolves a new version. Behavior changes. The fix is not “rerun until green.” It’s making builds deterministic and verifying them under clean conditions.

What I learned

Pin dependencies and plugins explicitly

Use dependencyManagement to pin versions of critical dependencies and plugins. Avoid wildcard ranges. If you depend on a BOM (bill of materials), understand what it pins and add explicit versions for anything critical that the BOM leaves flexible.

Example

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
</dependency>
<!-- pin more -->
</dependencies>
</dependencyManagement>

Use a lock mechanism to freeze transitive versions

A lock file captures resolved versions so CI doesn’t “discover” new transitive artifacts. Tools exist for Maven and Gradle that generate and enforce locks. The specifics depend on your chosen plugin; the principle is the same: generate once, commit, and enforce.

Generate lock file locally (or in CI).

Commit it.

Configure the build to fail if resolutions differ from the lock.

Clean-room rebuild in CI

Build in an isolated environment with a cleared local repository. Verify that resolving dependencies succeeds strictly against your lock. Optionally run the build twice—once with the lock (expected success), once with lock enforcement disabled to detect version drift. The second build should fail if drift occurred.

CI steps (illustrative)

- name: Clean local repo
run: rm -rf ~/.m2/repository
- name: Verify dependency locks
run: mvn -q -B clean verify -P enforce-lock
- name: “Clean room” rebuild
run: docker run --rm -v "$PWD:/w" -w /w eclipse-temurin:21-jdk bash -lc "mvn -q -B clean verify -P enforce-lock"

Reproducible artifacts (timestamps and ordering) Ensure JARs are reproducible: strip timestamps, stabilize entry ordering, and avoid embedding environment-specific paths. Many build tools support “reproducible build” flags. Enable them and verify artifact hashes are stable across clean rebuilds.

Digest references for external assets If your build fetches external tools or base images (for integration tests, for example), reference them by digest. Tags are mutable; digests fix identity. The trade-off is manual refresh when security updates appear—worth it for determinism.

Automate updates safely

Use a dependency-update bot that proposes version bumps. Gate those PRs with tests and scanning. Accepting updates becomes explicit, and drift is no longer silent.

Document the “how to fix drift” playbook

When lock enforcement fails, the fix should be documented: regenerate lock with your plugin, review changes, run tests, and commit. Avoid ad-hoc local resolutions—keep the workflow consistent.

Trade-offs and failure modes

Trade-offs

Pins vs agility: Strict pins and locks slow updates but eliminate surprises. Use bots to keep pace safely.

Clean rooms vs speed: Clean rebuilds take longer. For CI, prioritize correctness over a few minutes of build time.

Digests vs tags: Digests provide determinism; tags ease maintenance. For production-critical assets, default to digests.

Failure modes

“Partial pins”: You pin direct deps but miss transitive ones. Locks solve this; without locks, drift leaks in.

“Plugin drift”: Build plugins advance silently. Pin plugin versions in pluginManagement.

“Non-reproducible JARs”: Differences in timestamps or ordering break hash equality. Turn on reproducible flags and verify.

Try it yourself

Add dependency and plugin pins in dependencyManagement/pluginManagement.

Integrate a lock mechanism; commit the lock file; add an enforcement profile.

Run a clean-room rebuild in CI and compare the artifacts. Confirm artifact hashes match between runs.