Chapter 01Lesson 04~90 minutes

Build Automation Foundations, Reproducibility, Build Graphs, and the JVM Toolchain Ecosystem: Diagnostics, Failure Modes, Security, and Performance

Diagnose JVM build failures with evidence before mutation, including tool/JDK drift, undeclared inputs, mutable resolution, cache confusion, security-sensitive configuration, and performance bottlenecks.

DiagnosticsFailure AnalysisSupply ChainCachesPerformance

Learning objectives

  • Apply a repeatable diagnostic sequence from tool identity through effective model, graphs, repositories/caches, tests, packaging, and verification.
  • Diagnose JDK/build-tool drift without confusing wrapper version, runtime JVM, and project toolchain.
  • Demonstrate how an undeclared Gradle environment input can produce stale output and repair it by declaring the input.
  • Recognize mutable dependency/plugin resolution and wrapper/repository changes as supply-chain risks rather than ordinary build noise.
  • Measure build phases before applying performance changes, and use isolated state instead of deleting normal Maven or Gradle caches.
Version baseline — verified 2026-08-23. The examples use JDK 21, Apache Maven 3.9.16, Apache Maven Wrapper 3.3.4, and Gradle 9.7.1. Maven 3.9.16 is the current recommended Maven 3 release; Maven 4.0.0-rc-6 is still a preview and is intentionally not the production baseline here. Gradle 9 requires JVM 17 or newer to run, so JDK 21 gives both tools a common supported runtime. The versions are teaching pins, not timeless “latest” claims.

1. Preserve evidence before you “fix” anything

The fastest way to make a build failure harder to explain is to erase the state that distinguishes one hypothesis from another. Before deleting output or caches, capture the command, exit status, relevant tool/JDK versions, current revision, concise error, and the model/graph state nearest the failure.

git rev-parse --verify HEAD 2>/dev/null || true
java -version
./mvnw -v 2>/dev/null || true
./gradlew --version 2>/dev/null || true

# Re-run the failing command without piping away its exit status.
# Capture only relevant diagnostics; do not dump secrets or all environment variables.

Use this sequence as the default:

  1. Preserve concise evidence. Command, exit code, relevant log section, revision, tool/JDK identity.
  2. Confirm wrapper/build-tool/JDK identity. Are you executing the project wrapper? Which JVM actually runs it?
  3. Inspect declared/effective configuration. POM plus effective POM/settings, or Gradle settings/build model and task selection.
  4. Inspect dependency/project/execution graph. Which module, dependency, phase, goal, or task is actually involved?
  5. Inspect repository/cache/filesystem state. Is the failure resolution, generated output, permissions, or stale local state?
  6. Inspect compiler/test/plugin failure. Read the first causal failure, not merely the final summary.
  7. Apply the least destructive correction. Change one variable when possible.
  8. Verify with a controlled rebuild. Re-run the same observation that demonstrated failure.

2. Failure mode: “works on my machine” because Java identity differs

A teammate reports that Maven succeeds locally but Gradle fails in CI with an “unsupported class file” or JVM-version error. Do not immediately edit sourceCompatibility or the Maven compiler release. First determine which JVM is launching each tool and which toolchain the project requests.

java -version
./mvnw -v
./gradlew --version

Gradle 9.x requires JVM 17 or newer for the build daemon. Maven 3.9.16 can run on JDK 8+, but the project may require a newer compiler/toolchain. Therefore a Maven success on an older JVM does not prove that a Gradle 9 build can start there, and a Gradle daemon running on JDK 21 does not automatically mean the project is compiled for Java 21.

Observation Likely layer Least-destructive next check
Wrapper starts, compiler rejects language feature Project compile target/toolchain Inspect Java release/toolchain configuration
Gradle does not start on old JVM Build-tool runtime Check Gradle runtime requirement and JAVA_HOME
IDE builds, CLI fails IDE vs command-line JDK/model Compare IDE JVM/toolchain with wrapper output
CI only fails Agent image/environment Compare CI JDK + wrapper identity with local recorded values

3. Failure mode: generated output is treated as source of truth

Suppose a test passes only when an old generated file remains in build/generated/ or target/generated-sources/. Committing the generated directory or copying it between agents may hide the defect. The diagnostic question is: which declared task/goal should generate this file, from which inputs, before which consumer runs?

Use an isolated clean project-output experiment first. If a clean build cannot recreate required generated content, fix the generation edge in the execution graph. Do not promote stale generated output to source merely because it makes the symptom disappear.

# In a disposable project only:
./mvnw clean verify
# or
./gradlew clean build

# Inspect generated directories after the build, not before.
find target build -type f 2>/dev/null | sort | head -100

4. Intentionally broken Gradle example: an undeclared environment input

This task writes BUILD_FLAVOR into a file, but declares only an output. On the first run it produces a file. Change the environment variable and run again: Gradle may consider the task up-to-date because the value that affects the output was never declared as an input.

val writeBuildFlavor by tasks.registering {
    val out = layout.buildDirectory.file("generated/flavor.txt")
    outputs.file(out)

    doLast {
        val flavor = System.getenv("BUILD_FLAVOR") ?: "dev"
        out.get().asFile.apply {
            parentFile.mkdirs()
            writeText(flavor + "\n")
        }
    }
}
BUILD_FLAVOR=blue ./gradlew writeBuildFlavor --console=plain
cat build/generated/flavor.txt

BUILD_FLAVOR=green ./gradlew writeBuildFlavor --console=plain
cat build/generated/flavor.txt

If the second invocation is UP-TO-DATE and the file still says blue, the cache/up-to-date mechanism is behaving according to the information it was given. The build model is wrong because it hid an output-affecting input.

Repair: declare the environmental value as an input

val buildFlavor = providers.environmentVariable("BUILD_FLAVOR").orElse("dev")

val writeBuildFlavor by tasks.registering {
    val out = layout.buildDirectory.file("generated/flavor.txt")

    inputs.property("buildFlavor", buildFlavor)
    outputs.file(out)

    doLast {
        out.get().asFile.apply {
            parentFile.mkdirs()
            writeText(buildFlavor.get() + "\n")
        }
    }
}

Now changing BUILD_FLAVOR changes a declared task input, so Gradle has a reason to execute the task again. The deeper lesson applies beyond Gradle: if an environment variable, local file, network response, clock value, or command output affects generated bytes, it must be either declared/controlled or deliberately excluded from reproducibility claims.

5. Failure mode: graph changes without an application-source diff

Dynamic dependency selectors, mutable snapshot-like publications, unpinned plugins, repository metadata changes, or a changed mirror can alter resolution even when Java source does not change. That makes “same Git commit” insufficient artifact identity.

Maven version range:
  [1.0,2.0)

Gradle dynamic version:
  com.example:library:1.+

Unreviewed wrapper change:
  distributionUrl points to a different host/version

Plugin with no governed version:
  build logic resolves whatever metadata selects today

Do not “repair” a checksum or verification failure by disabling verification. Establish whether the publisher intentionally changed bytes, whether metadata is stale/corrupt, or whether the repository path is compromised/misconfigured. A mismatched checksum is evidence, not inconvenience.

Credential leak response: if repository, signing, or CI credentials appear in a log or committed build file, revoke/rotate or disable the credential first. Deleting the log or rewriting Git history does not make an already exposed secret safe.

6. Suspect a cache? Reproduce with isolated state instead of deleting everything

A fresh local repository or Gradle User Home is a controlled experiment. It lets you compare warm and cold behavior while preserving the original state for investigation.

mkdir -p ../.lab-state/m2-fresh
./mvnw -Dmaven.repo.local="$PWD/../.lab-state/m2-fresh" verify
mkdir -p ../.lab-state/gradle-fresh
GRADLE_USER_HOME="$PWD/../.lab-state/gradle-fresh" ./gradlew build --console=plain

If the fresh-state build succeeds while the warm-state build fails, you have narrowed the scope. Next compare metadata, artifact integrity, permissions, or local configuration. You still do not know that “the cache was bad” until you identify the differing state that caused the result.

7. Know which “build fixes” cross a security boundary

Change Why sensitive Safe lab pattern
Repository credentials Authenticates read/write access Fake values; no live secret in source or logs
Signing key Can assert artifact identity/provenance Synthetic/local key only; real signing deferred
Wrapper URL/checksum Selects executable build-tool distribution Official/approved URL + reviewed integrity metadata
Plugin repository Supplies executable build logic Use approved public source in lab; pin versions
Global settings.xml / init scripts Can silently affect many projects Prefer project-local/disposable settings for experiments
Shared cache Can cross project/trust boundaries Use isolated lab state; do not mix untrusted and privileged producers
Publish target May make an artifact externally available Use a local file:// target in later publishing labs

8. Performance diagnosis: measure the part that is slow

“The build takes four minutes” is not yet a diagnosis. Split the observed time into resolution/download, model/configuration, compilation, test execution, packaging, and startup/daemon/cache effects. The optimization depends on which component dominates.

time ./mvnw -Dmaven.repo.local="$PWD/../.lab-state/m2" verify
time GRADLE_USER_HOME="$PWD/../.lab-state/gradle" ./gradlew build --console=plain

For Gradle, task outcomes help distinguish execution from reuse; later chapters cover profiling, the daemon, parallelism, configuration cache, build cache, and file-system watching in depth. For Maven, later chapters cover parallel builds, daemon options, reproducible-build controls, and plugin-specific troubleshooting. Chapter 01’s rule is simpler: collect a baseline before tuning.

Symptom Measure first Do not assume
First build slow, repeats fast Network resolution and warm cache delta Compiler is slow
Every test phase dominates Test count/duration/forks Dependency cache needs deletion
Gradle configuration dominates Configuration/model time More worker threads will fix it
Packaging checksum changes Archive inputs/metadata/tool versions CPU performance issue
CI slower than laptop Agent CPU/disk/network/JDK/cache + exact command CI platform is inherently slow

9. Broken test example: read the causal failure, not only the summary

Change the expected greeting in the Chapter 01 test to an intentionally incorrect value:

assertEquals("Goodbye, DevOps!", Greeting.message(" DevOps "));

Run the normal verification entry point. Maven should fail during test execution and write a Surefire report; Gradle should fail the test task and retain an HTML/XML report. “BUILD FAILURE” or “BUILD FAILED” is the summary. The causal evidence is the failed assertion showing expected versus actual behavior.

Repair the test expectation only after deciding whether the test or implementation is wrong. Then rerun the same verification command. A diagnostic workflow ends by proving the original failure condition has changed for the intended reason.

10. Compact diagnostic playbook

Failure class First evidence Next evidence Typical wrong move
Tool/JDK mismatch ./mvnw -v / ./gradlew --version toolchain/compile target edit source syntax blindly
Dependency resolution exact coordinate/repository error dependency tree/insight + isolated repo/cache delete all user caches
Test failure first failed test/report test inputs/runtime skip tests to obtain a JAR
Stale output input/output timestamps + task/goal relation clean project-output experiment commit generated directory
Checksum mismatch expected vs observed digest + source URL publisher metadata/provenance disable verification
Slow build phase/task timing cold vs warm controlled runs enable every parallel/cache knob

11. Hands-on failure drill

  1. Start from the disposable Gradle project in Lesson 2.
  2. Add the intentionally broken writeBuildFlavor task.
  3. Predict: changing BUILD_FLAVOR will not necessarily invalidate the task because the value is undeclared.
  4. Run blue, then green, and capture task outcomes plus file content.
  5. Apply the corrected input declaration.
  6. Predict: changing the value now changes the task input and should cause execution.
  7. Run blue, then green again and verify the output follows the input.
  8. Remove the training task or delete the disposable project after preserving your notes.

Rollback: restore the original build.gradle.kts. Cleanup: delete only the lab’s .lab-state, build/, and disposable workspace after verifying the path.

Knowledge check

A checksum verification fails after a repository download. What is the correct first response?

Why can an undeclared environment variable make an incremental build incorrect?

A fresh isolated Gradle User Home fixes a failure. What has this proven?

Why is skipping tests a poor response to a test failure when the goal is a release artifact?

A build is slow. Why should you distinguish cold resolution from compilation and tests?

Summary

  • Preserve version, revision, error, and graph/model evidence before mutation.
  • Wrappers, build-runtime JVMs, and project toolchains are distinct identities.
  • Generated outputs should be regenerated from declared inputs, not promoted to source because they hide a graph defect.
  • Undeclared environment/filesystem/network inputs can invalidate incremental and cached-build correctness.
  • Fresh isolated state is a safer diagnostic experiment than deleting normal user caches.
  • Repository/plugin/wrapper/signing/credential changes are supply-chain controls and deserve explicit review.
  • Performance tuning starts with phase/task measurements, not a generic “make it parallel/cache everything” response.
Next lesson

Integrated checkpoint: predict, build, perturb, and verify

Lesson 5 combines the chapter into one evidence-driven lab. You will record source and tool identity, build both projects, compare clean/repeat behavior, alter one declared and one undeclared input, and produce a small reproducibility report.

Primary sources and version notes

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.