Incremental Builds, Configuration Cache, Build Cache, Remote Cache, and Reproducibility: Guided Hands-On Workflow and Core Operations
Use a disposable Java build to observe UP-TO-DATE versus FROM-CACHE, opt into the configuration cache, compare clean workspaces, and prove what invalidates each layer.
Learning objectives
- Create a disposable Java build using the verified Gradle 9.7.1 Wrapper and an isolated Gradle User Home.
-
Observe the same task as executed,
UP-TO-DATE, andFROM-CACHE. - Opt into the Configuration Cache and distinguish store/reuse messages from task-output cache outcomes.
- Compare JAR SHA-256 values from separate checkout paths and explain what that does and does not prove.
- Use a shared directory cache as a local simulation of cross-agent output reuse without configuring an HTTP service.
1. Preflight and lab boundary
This lab assumes the verified Gradle 9.7.1 Wrapper from Chapter 15
is available. Do not run a random system gradle binary.
Gradle runs on JDK 21 and compiles the tiny library with a Java 21
toolchain plus Java 17 --release target. No external
application dependency is needed.
set -eu
java -version
./gradlew --version
# Keep all mutable Gradle state for this chapter under one disposable parent.
LAB="$PWD/gradle-cache-lab"
mkdir -p "$LAB/template/src/main/java/dev/academy/cache" "$LAB/template/src/greeting"
export GRADLE_USER_HOME="$LAB/gradle-user-home"
printf 'GRADLE_USER_HOME=%s\n' "$GRADLE_USER_HOME"
Wrapper trust remains in force. Use the wrapper files already reviewed in Chapter 15. Do not regenerate or retarget the Wrapper merely for this cache lab, and do not weaken distribution checksum verification.
2. Create the deterministic project
The task model contains one cacheable custom task. Its template file uses relative path sensitivity, its audience is a declared scalar input, and its output is a project build file. The Java JAR is configured with Gradle 9+ reproducible archive settings explicitly even though those values are current defaults.
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}}.
# gradle.properties
org.gradle.caching=false
org.gradle.configuration-cache=false
cd "$LAB/template"
printf '%s
' 'rootProject.name = "cache-lab"' > /dev/null
# Save the shown settings.gradle.kts and build.gradle.kts exactly as above.
# Save Greeting.java and src/greeting/template.txt exactly as above.
# Copy the already verified gradlew, gradlew.bat, and gradle/wrapper/ directory here.
chmod +x gradlew 2>/dev/null || true
./gradlew --version
The org.gradle.* properties begin false so the first
observations are not ambiguous. The shared cache directory is
configured, but Gradle does not consult it until build caching is
enabled.
3. Observe ordinary execution and UP-TO-DATE
cd "$LAB/template"
./gradlew clean renderGreeting jar --no-build-cache --no-configuration-cache --console=plain | tee ../01-first.log
./gradlew renderGreeting jar --no-build-cache --no-configuration-cache --console=plain | tee ../02-repeat.log
cat build/generated/greeting.txt
sha256sum build/libs/cache-lab-1.0.0.jar | tee ../jar-in-place.sha256
On the first run, work executes because outputs do not exist. On the
immediate repeat, correctly modeled tasks can report
UP-TO-DATE. Nothing was downloaded from a task-output
cache; Gradle simply compared current declared state with outputs
already present in this workspace.
4. Enable the Build Cache for one invocation
cd "$LAB/template"
./gradlew clean renderGreeting jar --build-cache --no-configuration-cache --console=plain | tee ../03-cache-store.log
# clean removes project outputs, not the shared Build Cache.
./gradlew clean
./gradlew renderGreeting jar --build-cache --no-configuration-cache --console=plain | tee ../04-cache-restore.log
grep -E 'FROM-CACHE|UP-TO-DATE' ../04-cache-restore.log || true
The expected distinction is now observable: after
clean, a cacheable task may report
FROM-CACHE because its outputs were restored from the
shared directory cache. If a task executes instead, that is evidence
to inspect cacheability and key inputs—not a reason to assume the
feature is broken.
5. Opt into Configuration Cache separately
cd "$LAB/template"
./gradlew renderGreeting --configuration-cache --no-build-cache --console=plain | tee ../05-cc-store.log
./gradlew renderGreeting --configuration-cache --no-build-cache --console=plain | tee ../06-cc-reuse.log
# Inspect only paths/messages; do not dump binary configuration-cache entries.
grep -Ei 'configuration cache' ../05-cc-store.log ../06-cc-reuse.log || true
find .gradle/configuration-cache -type f 2>/dev/null | head -n 20 || true
The first compatible run should store a configuration-cache entry; a
later compatible invocation can reuse it. This says nothing about
whether renderGreeting executes, is up-to-date, or
comes from the Build Cache. Configuration reuse and task-output
reuse remain orthogonal.
6. Use both caches and read the evidence correctly
cd "$LAB/template"
./gradlew clean renderGreeting jar --build-cache --configuration-cache --console=plain | tee ../07-both.log
grep -E 'FROM-CACHE|UP-TO-DATE|Configuration cache|configuration cache' ../07-both.log || true
A single build can reuse configuration and restore some task outputs from the Build Cache. Record both kinds of evidence. Do not collapse the summary into “Gradle used cache,” because that loses which state was reused and why.
7. Simulate cross-agent reuse with two project paths
The built-in local cache can point at a directory. We use one disposable directory outside both workspaces so workspace B can reuse outputs produced in A. This simulates the cross-workspace behavior of shared cache reuse without claiming to test HTTP transport, authentication, TLS, or production cache governance.
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 ../08-a.log
cd ../workspace-b
./gradlew renderGreeting jar --build-cache --no-configuration-cache --console=plain | tee ../09-b.log
grep 'FROM-CACHE' ../09-b.log || true
Relative path sensitivity makes the custom task eligible for reuse across different checkout roots. Absolute path sensitivity would make the path itself part of the key and typically prevent this reuse.
8. Prove artifact bytes independently of output caches
cd "$LAB/workspace-a"
./gradlew clean jar --no-build-cache --no-configuration-cache --console=plain
sha256sum build/libs/cache-lab-1.0.0.jar > ../workspace-a.sha256
cd "$LAB/workspace-b"
./gradlew clean jar --no-build-cache --no-configuration-cache --console=plain
sha256sum build/libs/cache-lab-1.0.0.jar > ../workspace-b.sha256
cat ../workspace-a.sha256 ../workspace-b.sha256
cmp -s build/libs/cache-lab-1.0.0.jar ../workspace-a/build/libs/cache-lab-1.0.0.jar && echo 'byte-identical' || echo 'DIFFERENT'
jar tf build/libs/cache-lab-1.0.0.jar | sort | head -n 40
The final identity run disables both reuse mechanisms. If the JARs match byte-for-byte across separate paths, you have useful reproducibility evidence for this fixture. It is still scoped evidence: a larger build may include generators, native tools, timestamps, locale, or signing state that require additional controls.
9. Change one declared input and observe causality
cd "$LAB/workspace-b"
printf '%s
' 'Welcome to the cache lab, {{audience}}.' > src/greeting/template.txt
./gradlew renderGreeting --build-cache --configuration-cache --console=plain | tee ../10-input-change.log
cat build/generated/greeting.txt
The template is a declared task input, so the task output-cache key must change. The build-script task graph may still reuse configuration because editing a task input file does not necessarily change build configuration. This is the kind of layered reasoning Chapter 24 expects.
10. Challenge: choose the correct control
| Symptom | Choose first | Why |
|---|---|---|
| Output file exists and task is skipped after no changes | Inspect up-to-date inputs/outputs | This is current-workspace incremental state, not necessarily the Build Cache. |
After clean, output reappears without task
execution
|
Inspect Build Cache evidence/key |
clean removed outputs but not the cache entry.
|
| Build skips configuration but task still runs | Inspect Configuration Cache separately | Configuration reuse does not imply task-output reuse. |
| Same source in two directories makes different JAR hashes | Inspect reproducibility inputs/metadata | Cache toggles cannot fix nondeterministic output generation. |
11. Cleanup
cd "$LAB/.."
rm -rf gradle-cache-lab
Only the disposable lab, its isolated Gradle User Home, its
shared-directory build cache, and project-local state are removed.
Do not delete normal ~/.gradle or shared organization
caches.
Knowledge check
Why run clean between Build Cache
demonstrations?
It removes workspace outputs, so a later skipped execution can be distinguished as FROM-CACHE rather than merely UP-TO-DATE.
What does a reused Configuration Cache entry prove about JAR bytes?
Nothing by itself. It proves reusable configuration state for that invocation, not artifact reproducibility.
Why use
--no-build-cache --no-configuration-cache for the
final checksum comparison?
It forces fresh output generation and prevents a reused output from masquerading as independent reproducibility evidence.
What does the shared directory cache simulate?
Cross-workspace task-output reuse. It does not simulate HTTP transport, credentials, TLS, eviction policy, or production cache authorization.
A declared template file changes. Which layer must invalidate the task result?
The task up-to-date/build-cache key must change because the file is a declared input.
What should you do if workspace B executes instead of reporting FROM-CACHE?
Preserve the logs and inspect cacheability, cache key inputs, path sensitivity, toolchain/environment differences, and whether the task actually stored an entry.
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.