Checkpoint Lab — Gradle Tasks, Task Graph, Dependencies, Ordering, Inputs, Outputs, and Incremental Work
Create a three-task Gradle pipeline with declared state and an incremental transform, predict reruns across controlled changes, inject an undeclared input that produces stale output, and repair the task model.
Create a three-task Gradle pipeline with declared state and an incremental transform, predict reruns across controlled changes, inject an undeclared input that produces stale output, and repair the task model.
Learning objectives
- Build a three-task pipeline with precise declared inputs/outputs and a typed incremental transformation.
- Predict task outcomes before clean, repeat, source-change, label-change, add/remove-file, and undeclared-input runs.
- Capture task outcome evidence and correlate it with filesystem state rather than relying on elapsed time.
- Inject an undeclared report suffix and demonstrate a stale UP-TO-DATE result.
- Repair the missing input declaration and verify correct invalidation.
- Clean only disposable build/User Home state and explain how this task contract prepares later plugin/dependency/cache chapters.
1. Checkpoint acceptance contract
Your checkpoint is complete only when you can explain every edge and every rerun:
stageInputsownsbuild/staged/.-
incrementalTransformconsumes staged files and ownsbuild/transformed/. -
writeReportconsumes transformed files plus a report label and owns one report file. - A repeat run is UP-TO-DATE when tracked state is unchanged.
- A single source-file change invalidates the producer and downstream work; the transform reports file-level changes.
- An undeclared suffix change first demonstrates stale output, then the repaired declaration invalidates correctly.
-
No normal
~/.gradlestate or production secret is touched.
2. Setup and preflight
mkdir gradle-task-checkpoint
cd gradle-task-checkpoint
export GRADLE_USER_HOME="$PWD/.checkpoint-gradle-home"
# Copy the already-verified Gradle 9.7.1 Wrapper files here.
chmod +x gradlew
./gradlew --version | tee wrapper-version.txt
java -version 2>&1 | tee java-version.txt
mkdir -p source-input config
printf '%s\n' 'alpha one' > source-input/a.txt
printf '%s\n' 'beta two' > source-input/b.txt
printf '%s\n' '-A' > config/report-suffix.txt
cat > settings.gradle.kts <<'EOF'
rootProject.name = "gradle-task-checkpoint"
EOF
Use no external Gradle plugins. The Wrapper is the build entry point. The isolated User Home prevents ordinary global init/property/cache state from becoming an unexamined dependency.
3. Create the correct baseline task model
cat > build.gradle.kts <<'EOF'
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.work.ChangeType
import org.gradle.work.Incremental
import org.gradle.work.InputChanges
abstract class IncrementalTransform : DefaultTask() {
@get:Incremental
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val inputDir: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun execute(changes: InputChanges) {
val outRoot = outputDir.get().asFile
if (!changes.isIncremental) outRoot.deleteRecursively()
changes.getFileChanges(inputDir).forEach { change ->
val target = outputDir.file(change.normalizedPath).get().asFile
when (change.changeType) {
ChangeType.REMOVED -> target.delete()
else -> {
target.parentFile.mkdirs()
target.writeText(change.file.readText().uppercase())
}
}
println("TRANSFORM ${change.changeType}: ${change.normalizedPath}")
}
}
}
val sourceDir = layout.projectDirectory.dir("source-input")
val stagedDir = layout.buildDirectory.dir("staged")
val transformedDir = layout.buildDirectory.dir("transformed")
val reportFile = layout.buildDirectory.file("reports/report.txt")
val reportLabel = providers.gradleProperty("reportLabel").orElse("checkpoint")
val stageInputs = tasks.register("stageInputs") {
inputs.dir(sourceDir).withPathSensitivity(PathSensitivity.RELATIVE)
outputs.dir(stagedDir)
doLast {
val targetRoot = stagedDir.get().asFile
targetRoot.deleteRecursively()
sourceDir.asFile.copyRecursively(targetRoot, overwrite = true)
println("EXECUTION stageInputs")
}
}
val incrementalTransform = tasks.register<IncrementalTransform>("incrementalTransform") {
dependsOn(stageInputs)
inputDir.set(stagedDir)
outputDir.set(transformedDir)
}
val writeReport = tasks.register("writeReport") {
dependsOn(incrementalTransform)
inputs.dir(transformedDir).withPathSensitivity(PathSensitivity.RELATIVE)
inputs.property("reportLabel", reportLabel)
outputs.file(reportFile)
doLast {
val files = transformedDir.get().asFile.walkTopDown()
.filter { it.isFile }
.sortedBy { it.relativeTo(transformedDir.get().asFile).path }
.toList()
val report = reportFile.get().asFile
report.parentFile.mkdirs()
report.writeText(
"label=${reportLabel.get()}\n" +
files.joinToString("\n") { f ->
"${f.name}:${f.readText().trim()}"
} + "\n"
)
println("EXECUTION writeReport")
}
}
EOF
Note the deliberate ownership boundaries.
stageInputs rebuilds its dedicated staged directory.
The typed transform can process changed staged files incrementally.
The report owns a single report file and tracks both transformed
content and its label.
4. Predict outcomes before running
Create predictions.txt before execution:
Run A — clean workspace, writeReport:
stageInputs EXECUTES
incrementalTransform EXECUTES (non-incremental/full input set)
writeReport EXECUTES
Run B — immediate repeat:
all three expected UP-TO-DATE
Run C — change source-input/b.txt:
stageInputs EXECUTES
incrementalTransform EXECUTES and reports b.txt change
writeReport EXECUTES
Run D — change only -PreportLabel:
stageInputs expected UP-TO-DATE
incrementalTransform expected UP-TO-DATE
writeReport EXECUTES because scalar input changed
5. Capture clean and repeat task outcomes
./gradlew writeReport --console=plain | tee run-a-clean.log
cat build/reports/report.txt
./gradlew writeReport --console=plain | tee run-b-repeat.log
On the repeat, UP-TO-DATE is the expected evidence. Do
not enable the build cache merely to obtain a different label; this
checkpoint is about task state correctness.
6. Change one source file and observe downstream + incremental evidence
printf '%s\n' 'beta changed' > source-input/b.txt
./gradlew writeReport --console=plain | tee run-c-source-change.log
cat build/reports/report.txt
stageInputs reruns because its input directory changed.
Because that task currently rebuilds the staged directory, the
transform may receive a non-incremental change set or more file
changes than a direct source transformer would. That is a useful
modeling lesson: incrementality is end-to-end only when upstream
producers preserve fine-grained state. The output must still be
correct.
7. Change only a scalar report property
./gradlew writeReport -PreportLabel=ci-check --console=plain | tee run-d-label-change.log
cat build/reports/report.txt
The report task must rerun because reportLabel is a
declared scalar input. Upstream file tasks should remain
UP-TO-DATE if their tracked state is unchanged. This
isolates property invalidation from file invalidation.
8. Add and remove source files
printf '%s\n' 'gamma three' > source-input/c.txt
rm source-input/a.txt
./gradlew writeReport --console=plain | tee run-e-add-remove.log
find build/transformed -type f -print -exec cat {} \;
The final transformed directory must contain outputs only for current staged inputs. Whether the typed transform sees granular ADDED/REMOVED events depends on whether the upstream staging task preserved incremental filesystem changes; correctness is mandatory even when Gradle falls back to non-incremental execution.
9. Inject an undeclared input and prove stale output
Temporarily edit only the writeReport task action so it
reads config/report-suffix.txt but do
not declare it. Add this inside
doLast and append the value to the report:
// INTENTIONALLY BROKEN CHECKPOINT STEP
val suffixFile = layout.projectDirectory.file("config/report-suffix.txt")
val suffix = suffixFile.asFile.readText().trim()
// include: "suffix=$suffix" in reportFile content
./gradlew writeReport --rerun-tasks --console=plain | tee broken-seed.log
cp build/reports/report.txt report-with-suffix-a.txt
printf '%s\n' '-B' > config/report-suffix.txt
./gradlew writeReport --console=plain | tee broken-stale.log
cp build/reports/report.txt report-after-hidden-change.txt
diff -u report-with-suffix-a.txt report-after-hidden-change.txt || true
Expected failure mode: the hidden suffix changes but
writeReport can remain UP-TO-DATE, so
report content stays at suffix A. Preserve both reports and
broken-stale.log. The temporary
--rerun-tasks is used only to seed a known broken
output; it is not the repair.
10. Repair the model and verify invalidation
Declare the suffix as an input before the task action:
val reportSuffix = layout.projectDirectory.file("config/report-suffix.txt")
val writeReport = tasks.register("writeReport") {
dependsOn(incrementalTransform)
inputs.dir(transformedDir).withPathSensitivity(PathSensitivity.RELATIVE)
inputs.property("reportLabel", reportLabel)
inputs.file(reportSuffix).withPathSensitivity(PathSensitivity.RELATIVE)
outputs.file(reportFile)
// action reads reportSuffix and writes it into the report
}
./gradlew writeReport --console=plain | tee repaired-b.log
cp build/reports/report.txt repaired-report-b.txt
printf '%s\n' '-C' > config/report-suffix.txt
./gradlew writeReport --console=plain | tee repaired-c.log
cp build/reports/report.txt repaired-report-c.txt
diff -u repaired-report-b.txt repaired-report-c.txt
Now the suffix change is visible to Gradle and must invalidate
writeReport. Upstream tasks do not need to rerun
because the suffix is not their input. This is the central
checkpoint proof: accurate state produces narrow, correct
invalidation.
11. Verification checklist
- Wrapper and JDK identity are captured.
-
All Gradle user state stayed under
.checkpoint-gradle-home. - Task graph is three strong stages: stage → transform → report.
- Each task owns a distinct output location.
- Repeat run showed expected UP-TO-DATE evidence.
- Source/property changes invalidated the correct downstream scope.
- The incremental task handles both incremental and non-incremental execution correctly.
- The undeclared suffix produced preserved stale-output evidence.
- After repair, suffix changes invalidate only the report path.
- No source/config files are mutated by normal task actions.
12. Cleanup and rollback
./gradlew --stop || true
cd ..
rm -rf gradle-task-checkpoint
This removes only disposable checkpoint state. In a repository, rollback is a reviewed build-script revert plus preserved CI evidence—not normal-cache deletion.
13. What Chapter 17 adds to the production build-engineering model
You can now explain Gradle work as an explicit graph of selected
tasks plus truthful state contracts. Dependencies say what work is
required; ordering rules say only how co-selected work should be
sequenced; inputs/outputs explain why a task reruns or skips; and
InputChanges can optimize large transformations without
weakening clean rebuild correctness.
Chapter 18 builds on this model by examining the executable code that contributes many of these tasks and extensions: core, community, and convention plugins, their resolution, versions, provenance, and shared policy boundaries.
Knowledge check
Why is a repeated UP-TO-DATE run valuable checkpoint evidence?
It shows the declared task state is sufficient for Gradle to recognize unchanged work in the same workspace.
Why can changing only reportLabel rerun only writeReport?
The property is declared only as writeReport input, so upstream file-task state is unchanged.
Why might the transform lose fine-grained incrementality after stageInputs rebuilds its whole output directory?
An upstream producer that replaces/recreates the full tree can make downstream filesystem history appear non-incremental; end-to-end incrementality depends on producer behavior too.
What proves the undeclared suffix defect?
The suffix file changes, writeReport remains UP-TO-DATE, and the report stays unchanged/stale.
Why is --rerun-tasks used only once in the broken
step?
It seeds known broken output for demonstration; permanently forcing execution would hide the missing input declaration rather than fix it.
What is the bridge to Chapter 18?
Tasks and their contracts are often contributed by plugins, so the next trust boundary is plugin provenance, versioning, application, and reusable convention logic.
Official references and version notes
- Gradle 9.7.1 Release Notes — pinned Gradle baseline for this chapter.
- Creating and Registering Tasks and Understanding Tasks.
- Controlling Task Execution — dependencies, ordering rules, finalizers, skip behavior, and task graph semantics.
- Incremental Build — task inputs/outputs, validation, path sensitivity, inferred dependencies, and up-to-date checks.
-
Advanced Tasks
—
InputChanges, incremental file changes,@Incremental, and normalized paths. - Implementing Custom Tasks — typed task properties and annotations.
-
Incremental Builds and Build Caching Basic
— task outcome labels such as
UP-TO-DATEandFROM-CACHE. - Build Cache — disabled by default; separate from workspace up-to-date state.
- Build Cache Concepts — repeatable outputs, path normalization, and why overlapping outputs are unsafe for reuse.
Version snapshot: Generated for August 24, 2026 with Gradle 9.7.1, JDK 21 as the Gradle runtime, and Java 17 as the course JVM target. Re-check current Gradle documentation before carrying version-sensitive behavior into future production builds.
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.