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.
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-DATEandFROM-CACHEstates. - 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
~/.gradleis 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?
The task read suffix.txt but excluded it from the cache/up-to-date input model with @Internal, so the key did not change when suffix content changed.
What is the narrow repair?
Declare suffixFile as @InputFile with appropriate PathSensitivity.RELATIVE, then rebuild and verify invalidation.
What does editing build.gradle.kts primarily
invalidate?
Configuration-cache state, because the build script is a configuration input; task-output keys may also change if task implementation/configuration changes.
Why compare JARs with both caches disabled at the end?
To prove independent regeneration rather than equality caused by restoring the same cached output.
What must be documented if the two JAR hashes differ?
Preserve both artifacts and inspect timestamps, file order, generated metadata, toolchain/environment, paths, locale, and other nondeterministic inputs instead of deleting evidence.
What operational question becomes Chapter 25’s focus?
How to measure and tune daemon/process reuse, workers, parallel execution, file-system watching, memory, and the build critical path without weakening correctness.
Official references and version notes
-
Incremental Builds and Build Caching
— task outcome labels including
UP-TO-DATEandFROM-CACHE. -
Build Cache
— enabling local/remote caches, cacheable task outputs, local
DirectoryBuildCache, remote HTTP cache, and push/read controls. - Build Cache concepts — cache keys, stable inputs, repeatable outputs, path sensitivity, relocatability, and overlapping-output risks.
- Debugging Build Cache misses — controlled cache-miss and relocatability diagnosis.
- Configuration Cache — what is cached, configuration inputs, serialization, security considerations, and execution behavior.
- Enabling the Configuration Cache — current opt-in adoption workflow and HTML problem report.
- Configuration Cache debugging — problem reports and supported diagnostic workflow.
- AbstractArchiveTask API — Gradle 9+ reproducible archive defaults: timestamps not preserved and reproducible file ordering enabled.
- Gradle security best practices — reproducible archives and build-security guidance.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.