← Java articles

Java notes

Reproducible Gradle Builds: Why It Passes Locally and Fails in CI

Resource paths, dependency locking, and byte-for-byte reproducible jars

Shubham Anandani··~9 min read

The classic trap: files outside the classpath

// Works from an IDE or the repo root; breaks in CI or when packaged
String json = Files.readString(Path.of("config/app-manifest.json"));
try (InputStream in = AppManifest.class.getResourceAsStream("/manifests/app-manifest.json")) {
    if (in == null) {
        throw new IllegalStateException("Missing classpath resource: /manifests/app-manifest.json");
    }
    return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}

Fail the build when packaging is wrong

// build.gradle.kts
val verifyManifest by tasks.registering {
    dependsOn(tasks.jar)
    doLast {
        val jarFile = tasks.jar.get().archiveFile.get().asFile
        java.util.zip.ZipFile(jarFile).use { zip ->
            check(zip.getEntry("manifests/app-manifest.json") != null) {
                "manifests/app-manifest.json missing from ${jarFile.name}"
            }
        }
    }
}
tasks.check { dependsOn(verifyManifest) }

Avoid classpath hacks in tests

Pin dependencies

# gradle/libs.versions.toml
[versions]
junit = "5.10.2"

[libraries]
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
dependencyLocking { lockAllConfigurations() }
./gradlew dependencies --write-locks      # create or update lock files

Make the output reproducible

tasks.withType<AbstractArchiveTask>().configureEach {
    isPreserveFileTimestamps = false
    isReproducibleFileOrder = true
}
./gradlew clean jar && sha256sum build/libs/*.jar > first.sum
./gradlew clean jar && sha256sum build/libs/*.jar > second.sum
diff first.sum second.sum && echo "reproducible"

A repeatable CI parity checklist

  1. Use the Gradle wrapper, and commit the wrapper files.
  2. Pin the JDK with a toolchain.
  3. Commit dependency lock files.
  4. Run ./gradlew clean build locally from a clean checkout before pushing.
  5. Fail the build on missing resources, as shown above.
  6. Compare artifact checksums between two builds periodically.

Demo to build

Failure modes

  • Resources loaded by file path instead of classpath.
  • Floating dependency versions such as 1.+ or latest.
  • Tests that pass only because of leftover files in a local build directory (always test after clean).
  • Build plugins and tools that differ between developer machines and CI.

Takeaways

  • Load files from the classpath, and fail loudly when they are missing.
  • Pin dependencies and the JDK so two builds mean the same thing.
  • Make archives reproducible and prove it with a checksum comparison.