A build is a pure function of its declared inputs; anything undeclared (the system toolchain, the network, time, latest tags) makes the output irreproducible and the cache a lottery. The corollary that is the point of the whole exercise: aggressive caching (local, remote, build avoidance) is correct only under hermeticity — a cache hit on an incomplete key is a silent delivery of a stale artifact to prod.
// cacheability conditions are hermeticity conditions
tasks.register<Test>("integrationTest") {
inputs.files(configurations.runtimeClasspath).withNormalizer(ClasspathNormalizer::class)
inputs.property("jdk", java.toolchain.languageVersion) // toolchain is an input, not environment
outputs.cacheIf { true }
// system time, env vars, network are NOT inputs;
// a test reading them either declares them or is uncacheable
}# the reproducibility check is itself a CI test:
# two clean-cache builds on different agents → artifacts must not differ
diff <(sha256sum build-a/*.jar) <(sha256sum build-b/*.jar)Honest assessment. Full Bazel/Nix-style hermeticity is expensive; the pragmatic boundary is hermeticity of the cache key: everything that affects the output goes into the key. A “works locally, fails in CI” incident is almost always an undeclared input found empirically; each one is a patch to the declaration, not to the README.
outputs.cacheIf { true } does not make a task hermetic — it only permits caching. Correctness comes from a complete declaration: all inputs, the toolchain, environment variables, locale and network dependencies are either declared or eliminated. Reproducible archives additionally need normalized timestamps and file order.