Chapter 17Lesson 02~220 minutes

Gradle Tasks, Task Graph, Dependencies, Ordering, Inputs, Outputs, and Incremental Work: Guided Hands-On Workflow and Core Operations

Build a disposable three-task pipeline, observe task selection and UP-TO-DATE outcomes, then implement an incremental transformation that processes only added, modified, or removed inputs.

TaskProviderdependsOnPathSensitivityInputChangesEvidence

Build a disposable three-task pipeline, observe task selection and UP-TO-DATE outcomes, then implement an incremental transformation that processes only added, modified, or removed inputs.

Learning objectives

  • Bootstrap a disposable Kotlin DSL task lab with the already-verified Gradle 9.7.1 Wrapper and isolated User Home.
  • Register three safe tasks and distinguish dependency edges from ordering-only rules.
  • Declare scalar/file inputs and outputs with path sensitivity and verify repeated UP-TO-DATE behavior.
  • Change one declared input and prove which tasks rerun and why.
  • Implement a typed incremental task with InputChanges and interpret added/modified/removed file events.
  • Preserve observable logs/files and clean only disposable lab state.

1. Scenario: source → normalized output → verification report

The lab has a repository-owned input file and three tasks:

  1. prepareInput copies/normalizes the source into build/stage/.
  2. transformText uppercases the staged content into build/normalized/.
  3. verifyOutput verifies the normalized content and writes a report.

All mutable Gradle user state remains under the lab. No repository manager, cloud cache, or external plugin is required.

2. Preflight and disposable state

mkdir gradle-task-lab
cd gradle-task-lab
export GRADLE_USER_HOME="$PWD/.lab-gradle-home"

# Copy the verified Chapter 15 Wrapper files here first.
chmod +x gradlew
./gradlew --version
java -version

mkdir -p inputs
printf '%s\n' 'hello gradle' > inputs/message.txt
cat > settings.gradle.kts <<'EOF'
rootProject.name = "gradle-task-lab"
EOF

Expected baseline: Gradle 9.7.1, JDK 21 runtime (or another supported runtime), and no use of the normal ~/.gradle state.

3. Register the three tasks with declared state

cat > build.gradle.kts <<'EOF'
import org.gradle.api.tasks.PathSensitivity

val sourceFile = layout.projectDirectory.file("inputs/message.txt")
val stagedFile = layout.buildDirectory.file("stage/message.txt")
val normalizedFile = layout.buildDirectory.file("normalized/message.txt")
val reportFile = layout.buildDirectory.file("reports/verification.txt")

val prepareInput = tasks.register("prepareInput") {
    inputs.file(sourceFile).withPathSensitivity(PathSensitivity.RELATIVE)
    outputs.file(stagedFile)
    doLast {
        val target = stagedFile.get().asFile
        target.parentFile.mkdirs()
        target.writeText(sourceFile.asFile.readText().trim() + System.lineSeparator())
        println("EXECUTION prepareInput")
    }
}

val transformText = tasks.register("transformText") {
    dependsOn(prepareInput)
    inputs.file(stagedFile).withPathSensitivity(PathSensitivity.RELATIVE)
    outputs.file(normalizedFile)
    doLast {
        val target = normalizedFile.get().asFile
        target.parentFile.mkdirs()
        target.writeText(stagedFile.get().asFile.readText().uppercase())
        println("EXECUTION transformText")
    }
}

val verifyOutput = tasks.register("verifyOutput") {
    dependsOn(transformText)
    inputs.file(normalizedFile).withPathSensitivity(PathSensitivity.RELATIVE)
    inputs.property("requiredToken", "GRADLE")
    outputs.file(reportFile)
    doLast {
        val text = normalizedFile.get().asFile.readText()
        check("GRADLE" in text) { "normalized output must contain GRADLE" }
        val report = reportFile.get().asFile
        report.parentFile.mkdirs()
        report.writeText("verified=true" + System.lineSeparator())
        println("EXECUTION verifyOutput")
    }
}

val summarize = tasks.register("summarize") {
    doLast { println("SUMMARY only") }
}

// Ordering-only demonstration: does not add summarize to verifyOutput's graph.
verifyOutput.configure { mustRunAfter(summarize) }
EOF

The strong dependency chain is prepareInput → transformText → verifyOutput. The mustRunAfter(summarize) rule does not make summarize a prerequisite.

4. Inspect the graph before task actions execute

./gradlew tasks --all | grep -E 'prepareInput|transformText|verifyOutput|summarize' || true
./gradlew verifyOutput --dry-run
./gradlew summarize verifyOutput --dry-run

Expected first dry run: prepare, transform, verify. summarize is absent. In the second dry run, both summarize and the pipeline are selected; the ordering rule requires summarize before verify, but the producer dependencies still control prepare/transform ordering.

5. Full run, repeat run, and outcome evidence

./gradlew verifyOutput --console=plain | tee run-1.log
cat build/normalized/message.txt
cat build/reports/verification.txt

./gradlew verifyOutput --console=plain | tee run-2.log

First run: the three task actions execute. Second run: with unchanged tracked state and intact outputs, expect the pipeline tasks to report UP-TO-DATE. If a task executes again, use --info and inspect which input/output Gradle says changed instead of assuming “Gradle cache is broken.”

6. Change one declared input and follow invalidation downstream

printf '%s\n' 'hello gradle task graph' > inputs/message.txt
./gradlew verifyOutput --console=plain | tee run-input-change.log

prepareInput must rerun because its input file changed. Its staged output changes, which invalidates transformText; that changes the normalized input consumed by verifyOutput. This is expected downstream invalidation, not a failure of incrementality.

7. Prove ordering is not dependency

./gradlew verifyOutput --console=plain | tee verify-alone.log
# summarize should not be executed merely because verifyOutput mustRunAfter it.

if grep -q 'SUMMARY only' verify-alone.log; then
  echo 'unexpected: summarize executed'
  exit 1
fi

If verifyOutput actually required data from summarize, this model would be wrong. Replace the ordering rule with a real dependency/data-flow relationship rather than relying on command-line co-selection.

8. Add a typed incremental task for a directory of files

Now create a task type whose action receives file-level changes. Append this code to build.gradle.kts:

abstract class IncrementalUppercase : org.gradle.api.DefaultTask() {
    @get:org.gradle.work.Incremental
    @get:org.gradle.api.tasks.InputDirectory
    @get:org.gradle.api.tasks.PathSensitive(org.gradle.api.tasks.PathSensitivity.RELATIVE)
    abstract val inputDir: org.gradle.api.file.DirectoryProperty

    @get:org.gradle.api.tasks.OutputDirectory
    abstract val outputDir: org.gradle.api.file.DirectoryProperty

    @org.gradle.api.tasks.TaskAction
    fun transform(changes: org.gradle.work.InputChanges) {
        val outputRoot = outputDir.get().asFile
        if (!changes.isIncremental) {
            outputRoot.deleteRecursively()
        }

        changes.getFileChanges(inputDir).forEach { change ->
            val target = outputDir.file(change.normalizedPath).get().asFile
            when (change.changeType) {
                org.gradle.work.ChangeType.REMOVED -> target.delete()
                else -> {
                    target.parentFile.mkdirs()
                    target.writeText(change.file.readText().uppercase())
                }
            }
            println("CHANGE ${change.changeType}: ${change.normalizedPath}")
        }
    }
}

tasks.register<IncrementalUppercase>("incrementalUppercase") {
    inputDir.set(layout.projectDirectory.dir("batch-input"))
    outputDir.set(layout.buildDirectory.dir("batch-output"))
}

On a non-incremental run, the implementation resets output state and Gradle reports all relevant inputs as changes. On later incremental runs, it can process only individual file changes. RELATIVE means the workspace’s absolute location is not part of each input’s path identity.

9. Observe added, modified, and removed input changes

mkdir -p batch-input
printf '%s\n' alpha > batch-input/a.txt
printf '%s\n' beta  > batch-input/b.txt
./gradlew incrementalUppercase --console=plain | tee incremental-1.log

printf '%s\n' beta-v2 > batch-input/b.txt
printf '%s\n' gamma   > batch-input/c.txt
rm batch-input/a.txt
./gradlew incrementalUppercase --console=plain | tee incremental-2.log

find build/batch-output -type f -print -exec cat {} \;

The second execution should contain file-change evidence for removed a.txt, modified b.txt, and added c.txt. Exact log ordering can vary; the semantic change set is what matters.

10. Why this lab stops at UP-TO-DATE instead of forcing FROM-CACHE

The Gradle Build Cache is disabled by default, and custom tasks are not automatically cacheable merely because they have inputs and outputs. The mandatory exercise therefore proves workspace up-to-date and true incremental behavior without turning on another optimization layer. Later cache-focused material can build on the same accurate task model.

11. Challenge: choose graph semantics, do not copy a command sequence

Add a task called publishReport that reads verification.txt. Decide whether it needs dependsOn(verifyOutput) or only mustRunAfter(verifyOutput). Then justify your choice in a comment before running it.

Correct reasoning: if publishReport cannot operate correctly without verifyOutput having produced the report, that is a dependency/data relationship. Ordering-only is insufficient.

12. Verification and cleanup

test -f build/reports/verification.txt
test -f build/batch-output/b.txt
test -f build/batch-output/c.txt
test ! -f build/batch-output/a.txt

./gradlew --stop || true
cd ..
rm -rf gradle-task-lab

Only the disposable project and its isolated Gradle User Home are removed. Do not delete the normal user ~/.gradle cache to diagnose these task-model behaviors.

Knowledge check

Why does changing inputs/message.txt rerun all three pipeline tasks?

Why does mustRunAfter(summarize) not make summarize run?

What does changes.isIncremental == false require from the incremental task?

Why remove output for a REMOVED input?

Why use RELATIVE path sensitivity for the batch input?

Why is FROM-CACHE not required in this lab?

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.