Chapter 17Lesson 04~195 minutes

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.

DiagnosticsOverlapping outputsUndeclared inputsPortabilitySecurity

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

  1. Preserve concise command, task outcome, failure text, and relevant generated files.
  2. Confirm Wrapper/Gradle/JDK identity.
  3. Inspect selected tasks with --dry-run and task help.
  4. Inspect declared inputs/outputs and producer/consumer ownership.
  5. Compare filesystem state with the model: source, build/, isolated User Home.
  6. Use --info to learn why a task is or is not up-to-date.
  7. Apply the least destructive model correction.
  8. 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?

Can mustRunAfter fix missing producer selection?

Why are overlapping outputs more than a parallelism problem?

What is the safer alternative to deleting ~/.gradle?

Why can ABSOLUTE path sensitivity hurt CI portability?

What should be preserved before repairing stale output?

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.