Chapter 24Lesson 04~260 minutes

Incremental Builds, Configuration Cache, Build Cache, Remote Cache, and Reproducibility: Diagnostics, Failure Modes, Security, and Performance

Diagnose stale results, path-sensitive cache misses, configuration-cache problems, untrusted shared cache reuse, and false reproducibility claims without deleting valuable normal caches.

DiagnosticsCache poisoningSensitive statePathSensitivityClean-room

Learning objectives

  • Use a fixed evidence-first sequence for cache and reproducibility failures.
  • Reproduce an incorrect cache hit caused by an undeclared input and repair it with the narrowest task-model change.
  • Diagnose non-relocatable absolute-path inputs without deleting caches.
  • Handle Configuration Cache incompatibility and sensitive-state risks using generated reports and lazy providers.
  • Explain how untrusted remote cache writers turn performance infrastructure into a software-supply-chain boundary.

1. Diagnostic sequence: preserve evidence before cleaning

  1. Preserve concise task outcomes, configuration-cache messages, checksums, and the failing command.
  2. Confirm ./gradlew --version and JDK/toolchain identity.
  3. Inspect declared task inputs/outputs and build/settings configuration.
  4. Inspect task/dependency graph and the exact requested task set.
  5. Inspect project .gradle, build outputs, and the isolated lab cache location.
  6. Inspect compiler/test/plugin errors and configuration-cache HTML reports where generated.
  7. Apply the least destructive model correction.
  8. Verify with a controlled rebuild, then independently with clean-room evidence.

Blindly deleting ~/.gradle destroys useful evidence and can convert a deterministic modeling defect into an intermittent network/download problem.

2. Broken example: undeclared input creates a stale cached result

Start from the Lesson 2 task, then deliberately make the suffix file invisible to Gradle by annotating it @Internal even though the action reads it. This is intentionally incorrect.

import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.bundling.AbstractArchiveTask

plugins {
    `java-library`
}

group = "dev.academy.cache"
version = "1.0.0"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.release.set(17)
}

// These are already Gradle 9+ archive defaults; writing them here makes the
// reproducibility contract visible to the learner and reviewers.
tasks.withType<AbstractArchiveTask>().configureEach {
    isPreserveFileTimestamps = false
    isReproducibleFileOrder = true
}

@CacheableTask
abstract class RenderGreeting : DefaultTask() {
    @get:InputFile
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val templateFile: RegularFileProperty

    @get:Input
    abstract val audience: Property<String>

    // Deliberately wrong: the action reads this file but marks it Internal,
    // so it does not participate in up-to-date or build-cache keys.
    @get:Internal
    abstract val suffixFile: RegularFileProperty

    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    @TaskAction
    fun render() {
        val suffix = suffixFile.get().asFile.readText().trim()
        val text = templateFile.get().asFile.readText()
            .replace("{{audience}}", audience.get()) + " " + suffix + "\n"
        val out = outputFile.get().asFile
        out.parentFile.mkdirs()
        out.writeText(text)
    }
}

tasks.register<RenderGreeting>("renderGreeting") {
    templateFile.set(layout.projectDirectory.file("src/greeting/template.txt"))
    audience.convention(providers.gradleProperty("audience").orElse("engineers"))
    suffixFile.set(layout.projectDirectory.file("src/greeting/suffix.txt"))
    outputFile.set(layout.buildDirectory.file("generated/greeting.txt"))
}
# src/greeting/template.txt
printf '%s
' 'Welcome, {{audience}}.' > src/greeting/template.txt
printf '%s
' '[v1]' > src/greeting/suffix.txt

./gradlew clean renderGreeting --build-cache --no-configuration-cache --console=plain | tee broken-v1.log
cat build/generated/greeting.txt

# Change only the hidden/undeclared input.
printf '%s
' '[v2]' > src/greeting/suffix.txt
./gradlew clean renderGreeting --build-cache --no-configuration-cache --console=plain | tee broken-v2.log
cat build/generated/greeting.txt

Because suffix.txt is excluded from task input identity, Gradle is allowed to reuse the previous cache entry. The output can still show [v1] after the file changed to [v2]. That is an incorrect cache hit caused by an incorrect task model.

3. Repair the model, not the cache

import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.bundling.AbstractArchiveTask

plugins {
    `java-library`
}

group = "dev.academy.cache"
version = "1.0.0"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.release.set(17)
}

// These are already Gradle 9+ archive defaults; writing them here makes the
// reproducibility contract visible to the learner and reviewers.
tasks.withType<AbstractArchiveTask>().configureEach {
    isPreserveFileTimestamps = false
    isReproducibleFileOrder = true
}

@CacheableTask
abstract class RenderGreeting : DefaultTask() {
    @get:InputFile
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val templateFile: RegularFileProperty

    @get:Input
    abstract val audience: Property<String>

    // Correct: suffix content and relative path now participate in task state.
    @get:InputFile
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val suffixFile: RegularFileProperty

    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    @TaskAction
    fun render() {
        val suffix = suffixFile.get().asFile.readText().trim()
        val text = templateFile.get().asFile.readText()
            .replace("{{audience}}", audience.get()) + " " + suffix + "\n"
        val out = outputFile.get().asFile
        out.parentFile.mkdirs()
        out.writeText(text)
    }
}

tasks.register<RenderGreeting>("renderGreeting") {
    templateFile.set(layout.projectDirectory.file("src/greeting/template.txt"))
    audience.convention(providers.gradleProperty("audience").orElse("engineers"))
    suffixFile.set(layout.projectDirectory.file("src/greeting/suffix.txt"))
    outputFile.set(layout.buildDirectory.file("generated/greeting.txt"))
}
./gradlew clean renderGreeting --build-cache --no-configuration-cache --console=plain | tee repaired.log
cat build/generated/greeting.txt
# Expected content now ends in [v2].

Changing the task implementation/annotations also changes cache identity, so the stale entry is not a valid match. The repaired suffix input uses relative path sensitivity, allowing its content and project-relative location to matter without making the checkout root part of the key.

4. Failure: absolute paths destroy relocatability

If a repository-relative input is declared with PathSensitivity.ABSOLUTE, workspace A and B can calculate different cache keys solely because their checkout roots differ. This is normally a cache miss, not stale output.

@get:InputFile
@get:PathSensitive(PathSensitivity.ABSOLUTE) // deliberately over-specific
abstract val templateFile: RegularFileProperty

Repair by asking whether absolute location truly changes semantics. If not, use RELATIVE. Do not weaken path sensitivity blindly: a task that embeds the absolute path in output genuinely is not relocatable until the task implementation stops doing that.

5. Failure: Configuration Cache rejects live build-model access

A common anti-pattern is using project or other build-model objects inside task execution callbacks. The Configuration Cache needs serializable, isolated task state and can reject such access.

// Broken pattern for configuration-cache adoption:
tasks.register("whereAmI") {
    doLast {
        println(project.projectDir) // execution-time access to Project
    }
}
./gradlew whereAmI --configuration-cache --console=plain
# Preserve the console path to the generated HTML configuration-cache report.

The repair is to model needed data as task properties/providers during configuration rather than reaching back into Project during execution. Use the generated report as primary evidence; do not switch permanently to warning mode just to hide the problem.

6. Failure: sensitive values are captured into configuration state

Configuration Cache serializes scheduled task state and encrypts it on disk, but sensitive values should still not be eagerly materialized into task fields or logged. The safe direction is lazy execution-time wiring.

// Better shape: Provider remains lazy and the value is not printed.
val tokenProvider = providers.environmentVariable("LAB_FAKE_TOKEN")

abstract class CallFixture : DefaultTask() {
    @get:Input
    abstract val endpointName: Property<String>

    // Real credentials should use a dedicated credentials/property design and
    // should not be emitted to logs or task outputs.
}

For real repository credentials, use protected Gradle properties/credentials APIs and CI secret management. This chapter deliberately does not create a real secret or remote endpoint.

7. Failure: poisoned or untrusted remote cache output

If an attacker or compromised developer can write arbitrary cache entries accepted by trusted CI, a cache restore can introduce generated classes/resources without running the producing task locally. This is why cache authorization is supply-chain policy.

// Illustrative production client policy. Do not execute against example.invalid.
buildCache {
    remote<HttpBuildCache> {
        url = uri("https://cache.example.invalid/")
        isPush = false // developer/read-only posture
        // Keep normal TLS validation. Do not set isAllowUntrustedServer=true.
    }
}

For a suspected poisoning incident, stop writes, preserve the cache key/build provenance, reproduce with remote cache disabled, compare artifacts, and rotate/rebuild the cache trust domain as an infrastructure incident. Deleting only one developer’s local cache is not a sufficient response.

8. Failure: “same warm workspace” is not reproducibility

Running jar twice in one workspace can yield UP-TO-DATE. Running after clean with Build Cache enabled can yield FROM-CACHE. Neither regenerates independent bytes. A clean-room check uses separate paths and disables the reuse layers under test.

./gradlew clean jar --no-build-cache --no-configuration-cache
sha256sum build/libs/*.jar
# Repeat from a second checkout/path with the same declared inputs and compare.

9. Performance diagnosis by phase

Observed delay Likely layer Evidence before tuning
First build spends time resolving artifacts Dependency resolution/cache Repository logs, --info, warm-vs-cold isolated User Home.
Every invocation spends long before tasks start Configuration/model Configuration Cache compatibility/report; build script/plugin cost.
Compilation dominates and often misses cache Task inputs/cache key Compile task inputs, toolchain, classpath ABI changes, cache debug evidence.
Tests dominate Execution/test model Suite reports, fork settings, external resources; Chapter 22 evidence.
Cache downloads slower than executing task Remote cache/network Measure cache transfer and task duration; do not assume more caching is faster.

Chapter 25 goes deeper into daemon, worker, parallelism, file-system watching, profiling, and memory. Here, performance discussion stays tied to whether reuse is correct and measurable.

Knowledge check

A task restores stale output after only an untracked file changed. What is the first repair?

Workspace A and B always miss the cache even with identical bytes. What path-related cause should you inspect?

What should you do with a Configuration Cache HTML problem report?

Why is isAllowUntrustedServer=true a dangerous “fix”?

Why can local cache deletion be the wrong first response to suspected remote-cache poisoning?

What does an uncached second-workspace checksum add?

Official references and version notes

Version-sensitive behavior was rechecked against current Gradle primary documentation on 2026-08-24. The mandatory path uses Gradle 9.7.1 through the previously verified Wrapper, JDK 21 as the Gradle runtime/compiler toolchain, Java 17 as the project target, isolated lab state, and no paid service. Both the Build Cache and Configuration Cache are opt-in in Gradle 9.7.1. The Configuration Cache is local-only and cannot currently be shared across developers or CI machines. Remote HTTP build-cache examples are explanatory only; the hands-on cross-workspace exercise uses a disposable shared directory cache so no server or credentials are needed.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.