Build Automation Foundations, Reproducibility, Build Graphs, and the JVM Toolchain Ecosystem: Guided Hands-On Workflow and Core Operations
Create two disposable JVM builds around the same source and tests, bootstrap project wrappers, isolate dependency/cache state, run Maven and Gradle safely, and verify what each command changed.
Learning objectives
- Create a disposable Maven and Gradle lab with the same Java behavior and clearly separated generated state.
- Bootstrap Maven 3.9.16 and Gradle 9.7.1 wrappers, then use the wrappers for all normal build commands.
- Run compile/test/package workflows and inspect reports, task/lifecycle evidence, JAR contents, and SHA-256 identities.
- Explain which project model, repository/cache, and generated-output state each command reads or mutates.
- Diagnose one small build-control challenge without copying an opaque command sequence.
1. Scenario and disposable workspace
You are preparing a tiny library named Greeting. The
business behavior is intentionally boring: validate a name and
return a greeting. That keeps attention on build mechanics. You will
maintain two sibling projects—one Maven, one Gradle—with equivalent
Java source and tests. They are not expected to produce
byte-identical JARs across tools; they are expected to make
their own inputs, test evidence, and artifact identities observable.
-Dmaven.repo.local and Gradle state with
GRADLE_USER_HOME. Do not delete your normal
~/.m2 or ~/.gradle directories.
mkdir -p build-foundations-lab/{maven-app,gradle-app,.lab-state}
cd build-foundations-lab
printf '%s\n' "workspace=$(pwd)"
New-Item -ItemType Directory -Force `
build-foundations-lab\maven-app, `
build-foundations-lab\gradle-app, `
build-foundations-lab\.lab-state | Out-Null
Set-Location build-foundations-lab
(Get-Location).Path
Preflight: the live portion assumes JDK 21 plus one bootstrap Maven and Gradle installation are available. Chapters 04 and 15 teach installation in depth. Here, the system installations are used only to create wrappers; after that, wrapper scripts become the project entry points.
java -version
javac -version
mvn -v
gradle -v
Stop if the tools report a different Java installation than you
intended. A successful java -version does not prove
that mvn and gradle are using the same
JVM; inspect their version banners too.
2. Create identical application and test inputs
Both projects use the conventional Java source layout. The identical Java source lets us compare build models without changing application behavior at the same time.
package com.example.greeting;
public final class Greeting {
private Greeting() {}
public static String message(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
return "Hello, " + name.trim() + "!";
}
}
package com.example.greeting;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class GreetingTest {
@Test
void formatsAGreeting() {
assertEquals("Hello, DevOps!", Greeting.message(" DevOps "));
}
@Test
void rejectsBlankNames() {
assertThrows(IllegalArgumentException.class, () -> Greeting.message(" "));
}
}
Create those two files under both maven-app/ and
gradle-app/. The tests are first-class build inputs. A
packaging command that silently skips or discovers zero tests is not
equivalent to a verified build.
3. Declare the Maven model
The POM below pins the Java release, test dependency, and important
build plugins used by the lab.
project.build.outputTimestamp gives plugins a stable
timestamp input for reproducible archive output when they support
it. The timestamp is intentionally a fixed teaching value; in a real
release process it is normally derived from controlled release
metadata such as the source commit time.
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>greeting-maven</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-08-23T00:00:00Z</project.build.outputTimestamp>
<junit.version>5.13.4</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.1</version>
</plugin>
</plugins>
</build>
</project>
Notice what is absent: no credentials, no private repository, no environment-specific absolute path, and no dynamic dependency version. The POM should describe project behavior; machine-specific authentication and organization repository policy belong at other boundaries.
4. Bootstrap and inspect the Maven Wrapper
From maven-app/, use the installed Maven only to
install wrapper files. The fully qualified plugin coordinate avoids
relying on plugin-prefix resolution while bootstrapping. After this
step, use mvnw or mvnw.cmd for the
project.
cd maven-app
mvn org.apache.maven.plugins:maven-wrapper-plugin:3.3.4:wrapper \
-Dmaven=3.9.16 \
-Dtype=only-script
./mvnw -v
cat .mvn/wrapper/maven-wrapper.properties
cd ..
Set-Location maven-app
mvn org.apache.maven.plugins:maven-wrapper-plugin:3.3.4:wrapper `
-Dmaven=3.9.16 `
-Dtype=only-script
.\mvnw.cmd -v
Get-Content .mvn\wrapper\maven-wrapper.properties
Set-Location ..
The wrapper properties are security-sensitive because they select a Maven distribution URL. Apache Maven Wrapper also supports SHA-256 properties for the wrapper JAR and Maven distribution. Chapter 04 develops that verification workflow; for now, confirm that the URL names the expected Maven 3.9.16 distribution and comes from an approved Maven distribution source.
5. Declare the Gradle model with Kotlin DSL
Gradle separates build-wide settings from project build logic. This single-project lab needs only a project name in settings and the Java build configuration below.
rootProject.name = "greeting-gradle"
plugins {
java
}
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.13.4")
}
tasks.test {
useJUnitPlatform()
}
/*
* Gradle 9 produces reproducible archives by default. Keeping these values
* explicit documents the desired invariant and is harmless for this lab.
*/
tasks.withType<Jar>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
The Java toolchain request says that compilation/testing work should use Java 21. That is distinct from the JVM that starts Gradle itself. Gradle 9.x requires a JVM 17 or newer to run; JDK 21 satisfies both boundaries for this lab.
6. Bootstrap and inspect the Gradle Wrapper
Generate a wrapper for the exact Gradle version. The current Gradle guidance recommends the Wrapper for normal project execution. Running the wrapper task twice after an upgrade refreshes both properties and the wrapper implementation files.
cd gradle-app
gradle wrapper --gradle-version 9.7.1 --distribution-type bin
./gradlew wrapper --gradle-version 9.7.1 --distribution-type bin
./gradlew --version
cat gradle/wrapper/gradle-wrapper.properties
cd ..
Set-Location gradle-app
gradle wrapper --gradle-version 9.7.1 --distribution-type bin
.\gradlew.bat wrapper --gradle-version 9.7.1 --distribution-type bin
.\gradlew.bat --version
Get-Content gradle\wrapper\gradle-wrapper.properties
Set-Location ..
Gradle supports distributionSha256Sum and wrapper-JAR
integrity verification. Do not invent a checksum or copy one from an
unrelated version. Obtain the checksum from the official Gradle
release/distribution source and commit the verified wrapper metadata
with the project.
7. Inspect the models before executing the full build
Read-only inspection makes the eventual build output easier to explain. You want to know which project exists, which tasks/phases will matter, and what dependencies are expected before a failure forces you to reconstruct that model from logs.
cd maven-app
./mvnw -Dmaven.repo.local="$PWD/../.lab-state/m2" help:effective-pom
./mvnw -Dmaven.repo.local="$PWD/../.lab-state/m2" dependency:tree
cd ..
cd gradle-app
GRADLE_USER_HOME="$PWD/../.lab-state/gradle" ./gradlew projects
GRADLE_USER_HOME="$PWD/../.lab-state/gradle" ./gradlew tasks
GRADLE_USER_HOME="$PWD/../.lab-state/gradle" ./gradlew build --dry-run
cd ..
The first execution may need network access to download the pinned build-tool distribution, plugins, and JUnit dependency. Network activity is an input-resolution event, not evidence that generated build output belongs in source control.
8. Run the Maven verification path
Invoke verify, not merely package, because
the chapter’s mental model is “compile, test, package, then complete
verification work bound up to the verify phase.” In this simple
project there is no separate integration-test plugin yet, but the
lifecycle boundary is explicit.
cd maven-app
./mvnw -Dmaven.repo.local="$PWD/../.lab-state/m2" clean verify
find target -maxdepth 2 -type f | sort
jar tf target/greeting-maven-1.0.0.jar
sha256sum target/greeting-maven-1.0.0.jar
cat target/surefire-reports/*.txt
cd ..
Expected evidence includes compiler output under
target/classes, test results under
target/surefire-reports, and the packaged JAR under
target/. The local repository path contains downloaded
dependencies and plugin artifacts; it is deliberately outside the
application output tree.
9. Run the Gradle build task graph
Gradle’s Java plugin wires tasks such as compilation, testing,
checking, and assembly into the build lifecycle task.
The command asks for a task; Gradle selects its dependencies and
executes the resulting graph.
cd gradle-app
GRADLE_USER_HOME="$PWD/../.lab-state/gradle" ./gradlew clean build --console=plain
find build -maxdepth 3 -type f | sort
jar tf build/libs/greeting-gradle-1.0.0.jar
sha256sum build/libs/greeting-gradle-1.0.0.jar
find build/test-results/test -maxdepth 2 -type f -print
cd ..
Expected evidence includes task outcomes in the console, XML test
results under build/test-results/test, HTML test
reports under build/reports/tests/test, and the JAR
under build/libs. A successful build is
stronger evidence than a JAR existing by itself because the
test/check path is part of the selected graph.
10. Repeat without clean and interpret the difference
Now repeat the verification entry points without deleting generated output. This experiment highlights a model difference rather than declaring one tool “faster.” Maven normally executes the selected lifecycle/plugin work again, although dependency resolution is warmer. Gradle can skip tasks whose declared inputs and outputs are unchanged.
cd maven-app
./mvnw -Dmaven.repo.local="$PWD/../.lab-state/m2" verify
sha256sum target/greeting-maven-1.0.0.jar
cd ../gradle-app
GRADLE_USER_HOME="$PWD/../.lab-state/gradle" ./gradlew build --console=plain
sha256sum build/libs/greeting-gradle-1.0.0.jar
cd ..
Look for Gradle outcomes such as UP-TO-DATE or
NO-SOURCE. Do not expect Maven to print an equivalent
task-level outcome because its execution model is different. Record
the SHA-256 value for each tool’s artifact and compare that tool to
its own previous run. Do not expect the Maven JAR checksum to equal
the Gradle JAR checksum: archive metadata and manifests can differ
by tool.
11. Windows path and environment equivalents
The project files are cross-platform, but shell syntax is not. On Windows PowerShell, isolate Gradle by setting the process environment variable and pass the Maven local-repository property explicitly.
Set-Location maven-app
.\mvnw.cmd "-Dmaven.repo.local=$PWD\..\.lab-state\m2" verify
Get-FileHash .\target\greeting-maven-1.0.0.jar -Algorithm SHA256
Set-Location ..\gradle-app
$env:GRADLE_USER_HOME = "$PWD\..\.lab-state\gradle"
.\gradlew.bat build --console=plain
Get-FileHash .\build\libs\greeting-gradle-1.0.0.jar -Algorithm SHA256
Set-Location ..
Quoting is intentional. Shell syntax is part of operational correctness; copying POSIX environment-variable syntax into PowerShell is not a Maven or Gradle failure.
12. Causality map: what changed where?
| Action | Reads | Changes | Evidence |
|---|---|---|---|
| Generate Maven wrapper | POM/project directory + bootstrap Maven/plugin |
mvnw, mvnw.cmd,
.mvn/wrapper/
|
Wrapper properties and ./mvnw -v |
Maven clean verify |
POM, source/tests, repositories, JDK | isolated local repo + target/ |
lifecycle log, tests, JAR, SHA-256 |
| Generate Gradle wrapper | settings/build project + bootstrap Gradle | gradlew*, gradle/wrapper/ |
wrapper properties and ./gradlew --version
|
Gradle clean build |
settings/build scripts, source/tests, repositories, JDK/toolchain | isolated Gradle User Home + build/ |
task outcomes, tests, JAR, SHA-256 |
Repeat Gradle build |
same declared inputs + task history/output state | may change little or nothing | UP-TO-DATE evidence where applicable |
13. Challenge — choose the right control
A teammate says, “The Gradle build is wrong because it did not
recompile on the second run.” Before changing anything, decide which
observation proves whether that is expected: delete the cache,
inspect task inputs/outputs and task outcome, reinstall Java, or add
clean permanently to every CI build.
The correct first move is to inspect task outcome and declared
inputs/outputs. If the source, compiler/toolchain inputs, and
outputs are unchanged, UP-TO-DATE is expected. Forcing
clean would remove the evidence that incremental
execution works and may mask an undeclared-input bug rather than
diagnose it.
14. Verification checklist and cleanup
-
./mvnw -vreports Maven 3.9.16 and the expected Java runtime. -
./gradlew --versionreports Gradle 9.7.1 and the expected JVM. - Both projects report two passing tests.
-
Both JARs contain
com/example/greeting/Greeting.class. -
Maven dependency state is under
.lab-state/m2; Gradle User Home is under.lab-state/gradle. - No credentials, private URLs, or personal absolute paths were added.
pwd
find . -maxdepth 2 -type d | sort
# After confirming this is build-foundations-lab:
# cd ..
# rm -rf build-foundations-lab
~/.m2 or ~/.gradle state as
“cleanup.”
Knowledge check
Why use a globally installed Maven/Gradle only during wrapper bootstrap in this lab?
Because a wrapper makes the project declare the build-tool distribution used by later developers and CI. The global tool is only the one-time generator; normal project execution then uses the wrapper.
Why are Maven’s target/ and the isolated local
repository not the same kind of state?
target/ contains generated outputs for this
project. The local repository contains resolved
dependency/plugin artifacts and metadata that can be reused
across builds.
Why should the Maven and Gradle JAR checksums not be compared directly to each other?
Different build tools can produce semantically equivalent JARs with different manifests or archive metadata. The useful Chapter 01 check is repeatability of each controlled build against itself, plus inspection of JAR contents and test evidence.
A Gradle second run says UP-TO-DATE but a source
file really changed. What should you investigate?
Investigate whether the affected task declares the changed source as an input, whether the source set points to the expected path, and whether the output/task history belongs to the build you think you are running. Do not begin by deleting every cache.
Summary
You built one small behavior through two controlled build models. Maven expressed verification through lifecycle phases and plugin goals; Gradle expressed it through a task graph and incremental task state. Wrappers moved build-tool version selection into project-owned files. Isolated local state kept the lab reversible. Test reports, JAR contents, version banners, task/lifecycle logs, and SHA-256 values provided evidence instead of relying on “BUILD SUCCESS.”
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.