Chapter 17Lesson 05~230 minutes

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.

CheckpointThree-task pipelineInputChangesStale-result repairRollback

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:

  • stageInputs owns build/staged/.
  • incrementalTransform consumes staged files and owns build/transformed/.
  • writeReport consumes 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 ~/.gradle state 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?

Why can changing only reportLabel rerun only writeReport?

Why might the transform lose fine-grained incrementality after stageInputs rebuilds its whole output directory?

What proves the undeclared suffix defect?

Why is --rerun-tasks used only once in the broken step?

What is the bridge to Chapter 18?

Official references and version notes

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.

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