83

Hermetic builds: the cache is legitimate exactly as far as the inputs are declared

#
  • pattern
  • established
  • fragment

Conflicts with: Build cache ↔ correctness

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.

Code example (fragment)
fragment
Gradle · Kotlin DSL
// 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
}
CI
# 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.

Last technical review: 2026-08-04 · Reference · 112 principles