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.
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
InputChangesand 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:
-
prepareInputcopies/normalizes the source intobuild/stage/. -
transformTextuppercases the staged content intobuild/normalized/. -
verifyOutputverifies 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?
The changed source invalidates prepareInput; its output becomes changed input to transformText, whose output becomes changed input to verifyOutput.
Why does mustRunAfter(summarize) not make
summarize run?
Ordering rules do not add the referenced task to the graph.
What does changes.isIncremental == false require
from the incremental task?
It must be able to rebuild correct output state without relying on a previous incremental history.
Why remove output for a REMOVED input?
The output corresponding to that input is no longer valid and must not remain as stale generated state.
Why use RELATIVE path sensitivity for the batch input?
The path inside the input root matters, but moving the whole project to another absolute location should not change the semantic input identity.
Why is FROM-CACHE not required in this lab?
The build cache is a separate, disabled-by-default optimization; this lesson is proving correct task state and incrementality first.
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.