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.
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.
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:
- Preserve concise evidence. Command, exit code, relevant log section, revision, tool/JDK identity.
- Confirm wrapper/build-tool/JDK identity. Are you executing the project wrapper? Which JVM actually runs it?
- Inspect declared/effective configuration. POM plus effective POM/settings, or Gradle settings/build model and task selection.
- Inspect dependency/project/execution graph. Which module, dependency, phase, goal, or task is actually involved?
- Inspect repository/cache/filesystem state. Is the failure resolution, generated output, permissions, or stale local state?
- Inspect compiler/test/plugin failure. Read the first causal failure, not merely the final summary.
- Apply the least destructive correction. Change one variable when possible.
- 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.
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
- Start from the disposable Gradle project in Lesson 2.
-
Add the intentionally broken
writeBuildFlavortask. -
Predict: changing
BUILD_FLAVORwill not necessarily invalidate the task because the value is undeclared. - Run blue, then green, and capture task outcomes plus file content.
- Apply the corrected input declaration.
- Predict: changing the value now changes the task input and should cause execution.
- Run blue, then green again and verify the output follows the input.
- 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?
Treat the mismatch as evidence. Confirm the expected digest and artifact source, investigate publisher/repository/cache integrity, and avoid using the artifact until resolved. Do not disable verification to make the build pass.
Why can an undeclared environment variable make an incremental build incorrect?
If the variable affects output but is absent from the task/model input set, the build engine may reuse output produced for a different value because it has no declared reason to invalidate that work.
A fresh isolated Gradle User Home fixes a failure. What has this proven?
It narrows the problem toward state that differs between the normal and fresh Gradle homes, but it has not identified the exact corrupt/stale/misconfigured item yet.
Why is skipping tests a poor response to a test failure when the goal is a release artifact?
It removes verification evidence and changes the build contract. Diagnose whether the test or implementation is wrong; do not convert a failed verified build into an unverified package.
A build is slow. Why should you distinguish cold resolution from compilation and tests?
Each bottleneck has a different control. Repository/network caching affects resolution; compiler settings affect compilation; test design/forking affects tests; task/configuration caches affect other phases. Optimizing the wrong layer adds risk without benefit.
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.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.