Java notes
Reproducible Gradle Builds: Why It Passes Locally and Fails in CI
Resource paths, dependency locking, and byte-for-byte reproducible jars
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
- Use the Gradle wrapper, and commit the wrapper files.
- Pin the JDK with a toolchain.
- Commit dependency lock files.
- Run
./gradlew clean buildlocally from a clean checkout before pushing. - Fail the build on missing resources, as shown above.
- 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.+orlatest. - Tests that pass only because of leftover files in a local
builddirectory (always test afterclean). - 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.