Chapter 01Lesson 02~95 minutes

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.

Hands-OnMaven WrapperGradle WrapperTestingArtifacts

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.
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. 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.

Lab boundary: use a disposable directory. The examples isolate Maven dependency downloads with -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 -v reports Maven 3.9.16 and the expected Java runtime.
  • ./gradlew --version reports 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
Destructive boundary: delete only the disposable lab directory after confirming the absolute path. Do not remove normal ~/.m2 or ~/.gradle state as “cleanup.”

Knowledge check

Why use a globally installed Maven/Gradle only during wrapper bootstrap in this lab?

Why are Maven’s target/ and the isolated local repository not the same kind of state?

Why should the Maven and Gradle JAR checksums not be compared directly to each other?

A Gradle second run says UP-TO-DATE but a source file really changed. What should you investigate?

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.”

Next lesson

Choose build controls deliberately

Lesson 3 turns the workflow into design decisions: lifecycle convention versus programmable graphs, wrappers versus global tools, clean versus incremental execution, and speed versus hermeticity and auditability.

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.