Gradle Tasks, Task Graph, Dependencies, Ordering, Inputs, Outputs, and Incremental Work: Diagnostics, Failure Modes, Security, and Performance
Diagnose stale or unsafe task behavior by proving the selected graph, declared state, filesystem ownership, path sensitivity, and task outcomes before changing caches or adding ordering hacks.
Diagnose stale or unsafe task behavior by proving the selected graph, declared state, filesystem ownership, path sensitivity, and task outcomes before changing caches or adding ordering hacks.
Learning objectives
- Apply an evidence-preserving diagnostic sequence to task-graph and incremental-build failures.
- Demonstrate how an undeclared input can produce a stale UP-TO-DATE result.
- Recognize overlapping outputs and source/configuration mutation as ownership violations.
- Diagnose ordering rules that are incorrectly used as dependencies.
- Explain why absolute path sensitivity reduces portability and reuse.
- Keep diagnostic output free of secrets and avoid destructive normal-cache cleanup.
1. Diagnostic sequence: preserve evidence before “fixing” the build
- Preserve concise command, task outcome, failure text, and relevant generated files.
- Confirm Wrapper/Gradle/JDK identity.
-
Inspect selected tasks with
--dry-runand task help. - Inspect declared inputs/outputs and producer/consumer ownership.
-
Compare filesystem state with the model: source,
build/, isolated User Home. -
Use
--infoto learn why a task is or is not up-to-date. - Apply the least destructive model correction.
- Re-run from controlled state and verify the intended invalidation/outcome.
Do not start by deleting ~/.gradle. A fresh isolated
GRADLE_USER_HOME is safer when cache/state comparison
is relevant.
2. Failure: task reads an undeclared input and becomes incorrectly UP-TO-DATE
This intentionally broken task declares a tracked source and output
but also reads config/suffix.txt without declaring it:
val render = tasks.register("renderBroken") {
val source = layout.projectDirectory.file("inputs/message.txt")
val suffix = layout.projectDirectory.file("config/suffix.txt") // hidden state
val output = layout.buildDirectory.file("broken/result.txt")
inputs.file(source).withPathSensitivity(PathSensitivity.RELATIVE)
outputs.file(output)
doLast {
val target = output.get().asFile
target.parentFile.mkdirs()
target.writeText(source.asFile.readText().trim() + suffix.asFile.readText())
}
}
./gradlew renderBroken --console=plain
cat build/broken/result.txt
printf '%s\n' '-v2' > config/suffix.txt
./gradlew renderBroken --console=plain
cat build/broken/result.txt
If the second run is UP-TO-DATE, the output remains
stale because the suffix is outside the declared state. Repair it by
adding
inputs.file(suffix).withPathSensitivity(PathSensitivity.RELATIVE)
or, better, exposing suffix as a typed/lazy task input. Preserve the
stale output and console label as evidence before repair.
3. Failure: two tasks write the same output directory
Suppose generateA and generateB both
declare build/generated as an output directory. One
task can overwrite/delete files another produced, and task output
ownership is no longer clean.
Broken:
:generateA -> build/generated/*
:generateB -> build/generated/*
Repair:
:generateA -> build/generated/a/*
:generateB -> build/generated/b/*
:aggregate <- both dedicated directories as inputs
The repair is structural, not “force A before B.” Ordering does not give Gradle truthful ownership or make outputs safely reusable.
4. Failure: ordering rule is mistaken for dependency
Broken model:
val generateConfig = tasks.register("generateConfig") { /* writes config */ }
val packageApp = tasks.register("packageApp") { /* reads generated config */ }
packageApp.configure { mustRunAfter(generateConfig) }
./gradlew packageApp does not select
generateConfig. A developer with an old generated file
may pass; a clean CI agent fails. That is classic local-state
masking.
Repair by wiring the producer output to the consumer input or adding
the real dependency. Then test from a clean project
build/ state—not by installing/staging a file manually.
5. Failure: task mutates source or configuration state
A task that rewrites src/main/java,
settings.gradle.kts, or committed config as a normal
build side effect creates a feedback loop: running the build changes
its own source inputs and Git working tree.
Generate into build/ or another explicitly generated
directory and wire that output to consumers. If a source-update task
is intentionally developer-facing, name it clearly, keep it out of
normal lifecycle dependencies, and require explicit review of the
resulting source diff.
6. Failure: absolute paths make equivalent work non-portable
If an input uses absolute path sensitivity when the tool only cares
about relative layout/content,
/home/agent-a/work/repo/input.txt and
/builds/agent-b/repo/input.txt become distinct state
even when everything semantically relevant is identical.
Repair by selecting RELATIVE, NAME_ONLY,
or NONE only when that normalization is actually
correct. Do not rewrite arbitrary task inputs merely to manufacture
cache hits.
7. Isolated User Home distinguishes build logic from user/global state
# Preserve normal user state. Compare with a disposable Gradle User Home.
export GRADLE_USER_HOME="$PWD/.diagnostic-gradle-home"
./gradlew verifyOutput --info --console=plain | tee diagnostic-info.log
If behavior changes only with the isolated User Home, inspect
user-home gradle.properties, init scripts, downloaded
dependency/plugin state, and other global inputs. Do not jump
directly to deleting the normal cache.
8. Security: diagnostic verbosity can reveal more than the task needs
--info and especially --debug can expose
paths, repository details, environment-derived behavior, and plugin
output. Never print all environment variables or credential values
to explain a task invalidation. Reproduce with synthetic values and
collect the narrowest evidence that answers the question.
9. Performance diagnosis must identify which optimization layer changed
Separate:
- configuration time (Chapter 16 lazy configuration),
- task graph selection/order,
- workspace up-to-date skipping,
- true incremental processing inside an executed task,
- build-cache restoration when separately enabled,
- dependency/plugin resolution and daemon effects.
A faster second build can be entirely due to
UP-TO-DATE tasks; it is not proof of build-cache
effectiveness.
10. Intentionally broken diagnostic mini-lab
mkdir -p inputs config
printf '%s\n' 'payload' > inputs/message.txt
printf '%s\n' '-A' > config/suffix.txt
./gradlew renderBroken --console=plain | tee broken-1.log
cp build/broken/result.txt broken-result-before.txt
printf '%s\n' '-B' > config/suffix.txt
./gradlew renderBroken --console=plain | tee broken-2.log
cp build/broken/result.txt broken-result-after.txt
diff -u broken-result-before.txt broken-result-after.txt || true
A zero diff combined with renderBroken UP-TO-DATE after
suffix changed is the failure evidence. After declaring suffix as an
input, re-run: the task should execute and output should change.
Keep both logs; do not hide the original cause with
--rerun-tasks as the permanent “fix.”
11. Failure-mode checklist
| Symptom | Likely model question | Least-destructive next step |
|---|---|---|
| Stale output but task says UP-TO-DATE | Which real input is undeclared? | Compare action reads with declared inputs; add missing state. |
| Clean CI fails, workstation passes | Is stale generated/local state masking a missing dependency? |
Remove disposable build/, inspect dry run, wire
producer.
|
| Parallel build corrupts output | Do tasks overlap outputs/shared external state? | Split ownership; model shared resource deliberately. |
| Relocated workspace reruns everything | Are absolute paths tracked unnecessarily? | Review path sensitivity against actual semantics. |
| Task unexpectedly changes Git status | Is generated work writing source/config? | Move output under generated build state; remove lifecycle mutation. |
| Debug log contains sensitive context | Was verbosity broader than necessary? | Redact/discard unsafe logs; reproduce with fake values and narrower logging. |
Knowledge check
Why is --rerun-tasks not a repair for an
undeclared input?
It forces work for that invocation but leaves the task model incomplete, so later builds can become stale again.
Can mustRunAfter fix missing producer
selection?
No. It only orders tasks that are already scheduled.
Why are overlapping outputs more than a parallelism problem?
They make output ownership ambiguous, undermining validation, up-to-date state, and safe cache reuse.
What is the safer alternative to deleting
~/.gradle?
Run the trusted build with a fresh project-local
GRADLE_USER_HOME and compare behavior.
Why can ABSOLUTE path sensitivity hurt CI portability?
Different agent workspace roots become part of input identity even if the task semantics do not require them.
What should be preserved before repairing stale output?
The command, task outcome, old/new hidden input state, stale generated output, and concise diagnostic log.
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.