Chapter 24Lesson 05~320 minutes

Checkpoint Lab — Incremental Builds, Configuration Cache, Build Cache, Remote Cache, and Reproducibility

Turn one small Gradle build into an auditable cache-aware pipeline, deliberately create a stale cached result, repair the task model, and prove artifact identity across separate project paths.

CheckpointCache invalidationFROM-CACHEReproducible JAREvidence

Learning objectives

  • Capture a baseline matrix of task, Build Cache, Configuration Cache, and artifact outcomes.
  • Predict which layer invalidates when declared task input, build configuration, or workspace path changes.
  • Create and diagnose one stale cache hit from an intentionally hidden input.
  • Repair the task model and prove correct invalidation.
  • Compare artifact SHA-256 values from separate project paths with both output/configuration caches disabled.

1. Checkpoint scenario and acceptance contract

You are preparing a small Gradle Java library for CI. The build must be fast when reuse is safe, but its cache policy cannot be a hidden correctness dependency. Your dossier must contain:

  • Gradle/JDK/Wrapper identity.
  • Task input/output model for the custom generator.
  • Observed UP-TO-DATE and FROM-CACHE states.
  • Configuration Cache store/reuse evidence.
  • A deliberate stale-cache failure and its repaired task declaration.
  • Byte-for-byte JAR comparison from separate paths with caches disabled.
  • A rollback/cleanup record containing only disposable lab state.

2. Preflight

set -eu
java -version
./gradlew --version

# Expected chapter baseline:
# Gradle 9.7.1 via verified Wrapper
# Gradle runtime: JDK 21
# Java compile toolchain: 21
# Java release target: 17
# Build Cache: opt-in
# Configuration Cache: opt-in

LAB="$PWD/gradle-cache-checkpoint"
rm -rf "$LAB" 2>/dev/null || true
mkdir -p "$LAB/template/src/main/java/dev/academy/cache" "$LAB/template/src/greeting"
export GRADLE_USER_HOME="$LAB/gradle-user-home"

If your runtime/toolchain differs, record the actual identity and stop before comparing results as if they came from this pinned baseline.

3. Create the baseline project

rootProject.name = "cache-lab"

// The normal local Build Cache lives under GRADLE_USER_HOME. For this lab we
// redirect it to one disposable directory shared by workspace-a/workspace-b.
buildCache {
    local {
        directory = rootDir.parentFile.resolve("shared-build-cache")
        enabled = true
        push = true
    }
}
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>

    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    @TaskAction
    fun render() {
        val text = templateFile.get().asFile.readText()
            .replace("{{audience}}", audience.get())
        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"))
    outputFile.set(layout.buildDirectory.file("generated/greeting.txt"))
}
package dev.academy.cache;

public final class Greeting {
    private Greeting() {}

    public static String value(String name) {
        String safe = (name == null || name.isBlank()) ? "world" : name.strip();
        return "hello, " + safe;
    }
}
# src/greeting/template.txt
Welcome, {{audience}}.

# src/greeting/suffix.txt (not used by the baseline task yet)
[v1]
cd "$LAB/template"
# Save settings.gradle.kts, build.gradle.kts, Java source, and text inputs.
# Copy the already reviewed Gradle 9.7.1 Wrapper files into this template.
chmod +x gradlew 2>/dev/null || true
./gradlew --version
./gradlew renderGreeting --dry-run

4. Predictions before execution

Change Prediction Independent evidence
Run same task twice without changing inputs Second run becomes UP-TO-DATE. Console task outcome; output still exists in workspace.
Run clean then same task with Build Cache Task can restore FROM-CACHE. Console outcome plus regenerated output after build directory deletion.
Edit declared template.txt Task cache key changes and task must produce new output. New output text and non-stale task outcome.
Copy project to another path with relative path sensitivity Shared directory cache can still match task state. Workspace B reports FROM-CACHE where cacheable.
Edit build script Configuration Cache must miss/store a new entry. Configuration-cache console message/report.
Hide a suffix input as @Internal Changing suffix can incorrectly reuse old cached output. Output remains old after clean + cache restore.

5. Capture baseline outcome matrix

cd "$LAB/template"
./gradlew clean renderGreeting jar --no-build-cache --no-configuration-cache --console=plain | tee ../baseline-1.log
./gradlew renderGreeting jar --no-build-cache --no-configuration-cache --console=plain | tee ../baseline-2.log

./gradlew clean renderGreeting jar --build-cache --no-configuration-cache --console=plain | tee ../cache-store.log
./gradlew clean
./gradlew renderGreeting jar --build-cache --no-configuration-cache --console=plain | tee ../cache-restore.log

./gradlew renderGreeting --no-build-cache --configuration-cache --console=plain | tee ../cc-store.log
./gradlew renderGreeting --no-build-cache --configuration-cache --console=plain | tee ../cc-reuse.log

grep -E 'UP-TO-DATE|FROM-CACHE|Configuration cache|configuration cache' ../*.log || true

Do not summarize from memory. Keep the log files as the checkpoint evidence. If your actual outcomes differ, explain why based on task cacheability and state rather than rewriting the expected output.

6. Cross-workspace Build Cache and artifact baseline

cd "$LAB"
cp -R template workspace-a
cp -R template workspace-b
rm -rf workspace-a/.gradle workspace-a/build workspace-b/.gradle workspace-b/build

cd workspace-a
./gradlew renderGreeting jar --build-cache --no-configuration-cache --console=plain | tee ../workspace-a-cache.log

cd ../workspace-b
./gradlew renderGreeting jar --build-cache --no-configuration-cache --console=plain | tee ../workspace-b-cache.log
grep 'FROM-CACHE' ../workspace-b-cache.log || true

The shared directory cache is deliberately inside the disposable lab parent. It proves cross-path cache behavior without a network server. It must not be described as a production remote HTTP cache.

7. Inject the undeclared-input defect

Replace the baseline task class/configuration with the deliberately broken version below. The suffix file is read but marked @Internal.

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"))
}
cd "$LAB/workspace-b"
printf '%s
' '[v1]' > src/greeting/suffix.txt
./gradlew clean renderGreeting --build-cache --no-configuration-cache --console=plain | tee ../broken-store.log
cat build/generated/greeting.txt | tee ../broken-output-v1.txt

printf '%s
' '[v2]' > src/greeting/suffix.txt
./gradlew clean renderGreeting --build-cache --no-configuration-cache --console=plain | tee ../broken-reuse.log
cat build/generated/greeting.txt | tee ../broken-output-after-v2.txt

# If the task reports FROM-CACHE and output still contains [v1], preserve it.
grep 'FROM-CACHE' ../broken-reuse.log || true
cat ../broken-output-after-v2.txt

This is the intended failure: Gradle did exactly what the declared model allowed. The root cause is not “cache corruption”; it is that a semantic input was hidden from the task key.

8. Repair and independently verify invalidation

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"))
}
cd "$LAB/workspace-b"
./gradlew clean renderGreeting --build-cache --no-configuration-cache --console=plain | tee ../repair.log
cat build/generated/greeting.txt
# Expected repaired output ends in [v2].

printf '%s
' '[v3]' > src/greeting/suffix.txt
./gradlew renderGreeting --build-cache --no-configuration-cache --console=plain | tee ../repair-v3.log
cat build/generated/greeting.txt
# Expected output now ends in [v3]; declared input invalidated prior state.

9. Prove configuration-state invalidation separately

cd "$LAB/workspace-b"
./gradlew renderGreeting --configuration-cache --no-build-cache --console=plain | tee ../cc-before-edit.log
./gradlew renderGreeting --configuration-cache --no-build-cache --console=plain | tee ../cc-reuse-before-edit.log

# Make a harmless build-configuration change.
printf '
// checkpoint-marker
' >> build.gradle.kts
./gradlew renderGreeting --configuration-cache --no-build-cache --console=plain | tee ../cc-after-build-edit.log

grep -Ei 'configuration cache' ../cc-before-edit.log ../cc-reuse-before-edit.log ../cc-after-build-edit.log || true

The build-script change is a configuration input, so the previous configuration-cache entry must not be blindly reused. This is a different invalidation mechanism from changing suffix.txt, which is a task input.

10. Clean-room artifact identity across project paths

Restore both workspaces to the same final source-controlled baseline before comparing. Remove only project-local outputs/state. Then build with both output/configuration caches disabled.

cd "$LAB"
# Make the final repaired source-controlled baseline byte-for-byte identical.
cp workspace-b/build.gradle.kts workspace-a/build.gradle.kts
cp workspace-b/src/greeting/template.txt workspace-a/src/greeting/template.txt
cp workspace-b/src/greeting/suffix.txt workspace-a/src/greeting/suffix.txt

cd workspace-a
rm -rf .gradle build
./gradlew clean jar --no-build-cache --no-configuration-cache --console=plain
sha256sum build/libs/cache-lab-1.0.0.jar > ../final-a.sha256

cd "$LAB/workspace-b"
rm -rf .gradle build
./gradlew clean jar --no-build-cache --no-configuration-cache --console=plain
sha256sum build/libs/cache-lab-1.0.0.jar > ../final-b.sha256

cat ../final-a.sha256 ../final-b.sha256
cmp -s ../workspace-a/build/libs/cache-lab-1.0.0.jar build/libs/cache-lab-1.0.0.jar   && echo 'PASS: byte-identical'   || echo 'FAIL: preserve both jars and inspect metadata'

Document any remaining non-reproducible metadata instead of forcing a pass. For this tiny Java fixture on the pinned baseline, the explicit archive settings are expected to make the JAR byte-identical when all real inputs match.

11. Verification checklist

  • Wrapper is the reviewed Gradle 9.7.1 wrapper.
  • Gradle runtime/JDK 21 and Java 17 release target are recorded.
  • Baseline repeat demonstrates current-workspace up-to-date behavior where applicable.
  • Post-clean build demonstrates Build Cache restoration where applicable.
  • Configuration Cache store/reuse evidence is preserved independently.
  • Declared input change invalidates task result.
  • Hidden suffix creates the intended stale result and is preserved as evidence.
  • Repair changes suffix to a declared relative-path input and correct output follows.
  • Build-script change invalidates configuration state.
  • Final two-path JAR comparison is performed with Build/Configuration caches disabled.
  • No real secrets, remote cache server, shared organization cache, or normal ~/.gradle is modified.

12. Cleanup and rollback

cd "$LAB/.."
# Keep evidence elsewhere first if this were a real incident/review.
rm -rf gradle-cache-checkpoint

The checkpoint rollback removes only the disposable project copies, isolated User Home, shared directory cache, and logs. In production, evidence retention should happen before cleanup.

13. What Chapter 24 adds to a production build-engineering operating model

The build model now distinguishes declared task state, current-workspace state, reusable task outputs, reusable configuration state, shared-cache trust, and independent artifact identity. That is enough to adopt caches as accelerators rather than invisible correctness dependencies.

Chapter 25 moves from correctness of reuse to capacity engineering: Gradle Daemon lifecycle, worker counts, parallelism, file-system watching, profiling, memory/GC, and evidence-based critical-path optimization.

Knowledge check

Why did the broken suffix example produce a stale cache hit?

What is the narrow repair?

What does editing build.gradle.kts primarily invalidate?

Why compare JARs with both caches disabled at the end?

What must be documented if the two JAR hashes differ?

What operational question becomes Chapter 25’s focus?

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.