Chapter 31Lesson 02~470 minutes

Production Capstone: Build, Test, Secure, Publish, and Optimize a Multi-Module JVM Platform: Implementation and Automation Build-Out

Build the platform incrementally: wrappers and toolchains, module graph, dependency governance, unit and integration tests, reproducible packaging, dependency verification, local publication, clean Maven consumption, CI evidence, immutable promotion, and a measured performance baseline.

Multi-project buildTestingVerificationPublishingCI evidence

Learning objectives

  • Build the Gradle project incrementally and understand the state changed at each step.
  • Create platform, core, and application modules with version governance and Java 17 compatibility.
  • Wire unit and integration evidence into the verification lifecycle without a false-green path.
  • Generate lock and dependency-verification state, publish to staging, and consume from clean Maven.
  • Promote exact bytes without rebuilding and capture a provider-neutral CI/evidence contract.

Do not paste real credentials into this lab. The file repositories require no authentication. Any credential examples later use placeholders only. Keep GRADLE_USER_HOME, Maven local repositories, staging, release, and evidence directories inside the disposable capstone workspace.

1. Create a disposable workspace and preserve a trusted Wrapper

Create the project tree. If you have the verified Gradle 9.7.1 Wrapper from Chapter 15 or a trusted repository skeleton, copy all four wrapper artifacts (gradlew, gradlew.bat, gradle/wrapper/gradle-wrapper.jar, and properties) together. Do not fabricate only one file.

mkdir -p orbit-platform/{gradle,platform,core,app,evidence}
mkdir -p orbit-platform/core/src/{main,test}/java/dev/academy/capstone
mkdir -p orbit-platform/app/src/{main,test,integrationTest}/java/dev/academy/capstone
cd orbit-platform

# Copy a previously verified Wrapper into this directory before continuing.
# Then isolate all Gradle user state for the lab:
export GRADLE_USER_HOME="$PWD/.lab-gradle"
./gradlew -version

Expected preflight: Gradle reports 9.7.1 and JVM 21. If the JVM line differs, stop and record it before changing build logic.

2. Pin the Gradle distribution and verify the bootstrap JAR

The Wrapper properties should contain the official binary distribution checksum:

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

Then independently hash the checked-in Wrapper JAR:

actual="$(sha256sum gradle/wrapper/gradle-wrapper.jar | awk '{print $1}')"
expected="7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d"
printf 'wrapper jar sha256: %s\n' "$actual"
test "$actual" = "$expected"

A mismatch is a security incident for this lab, not a cue to overwrite the expected hash. Reacquire the Wrapper from a trusted Gradle distribution and review the change.

3. Establish settings, repository policy, root policy, and version aliases

settings.gradle.kts owns project inclusion and repository policy:

pluginManagement {
    repositories {
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        mavenCentral()
    }
}

rootProject.name = "orbit-platform"
include("platform", "core", "app")

The root build owns shared coordinate identity and lock activation:

plugins {
    base
}

allprojects {
    group = "dev.academy.capstone"
    version = "1.0.0"
}

subprojects {
    dependencyLocking {
        lockAllConfigurations()
    }
}

The catalog improves declaration ergonomics but deliberately contains no versions; the platform owns accepted versions:

[libraries]
commons-lang3 = { module = "org.apache.commons:commons-lang3" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }

This division avoids pretending that aliases enforce resolution. Version policy lives in an actual resolvable model—the Java platform constraints.

4. Implement the policy module

platform/build.gradle.kts publishes a Maven-compatible BOM-style platform and centralizes two external versions:

plugins {
    `java-platform`
    `maven-publish`
}

dependencies {
    constraints {
        api("org.apache.commons:commons-lang3:3.20.0")
        api("org.junit.jupiter:junit-jupiter:6.1.3")
    }
}

publishing {
    publications {
        create<MavenPublication>("platform") {
            from(components["javaPlatform"])
        }
    }
    repositories {
        maven {
            name = "staging"
            url = uri(rootProject.layout.projectDirectory.dir("staging-repo"))
        }
    }
}

The constraint graph is now inspectable before application code exists. The platform is a project in the Gradle build and later a published metadata contract for Maven consumers.

5. Implement the reusable library

core/build.gradle.kts applies the platform, consumes a versionless catalog alias, targets Java 17 using JDK 21, enables deterministic JAR settings, and publishes one component:

plugins {
    `java-library`
    `maven-publish`
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
    withSourcesJar()
}

dependencies {
    api(platform(project(":platform")))
    implementation(libs.commons.lang3)

    testImplementation(platform(project(":platform")))
    testImplementation(libs.junit.jupiter)
}

tasks.withType<JavaCompile>().configureEach {
    options.release.set(17)
}

tasks.withType<Test>().configureEach {
    useJUnitPlatform()
}

tasks.withType<Jar>().configureEach {
    isPreserveFileTimestamps = false
    isReproducibleFileOrder = true
}

publishing {
    publications {
        create<MavenPublication>("core") {
            from(components["java"])
        }
    }
    repositories {
        maven {
            name = "staging"
            url = uri(rootProject.layout.projectDirectory.dir("staging-repo"))
        }
    }
}

Add the production class and its unit test:

package dev.academy.capstone;

import org.apache.commons.lang3.StringUtils;

public final class MessageNormalizer {
    private MessageNormalizer() {}

    public static String normalize(String input) {
        return StringUtils.trimToEmpty(input).replaceAll("\\s+", " ");
    }
}
package dev.academy.capstone;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

class MessageNormalizerTest {
    @Test
    void collapsesWhitespace() {
        assertEquals("Ada Lovelace", MessageNormalizer.normalize("  Ada   Lovelace  "));
    }
}

6. Implement application + explicit integration suite

app/build.gradle.kts makes the unit and integration test identities visible. The custom suite explicitly depends on the current project and is explicitly attached to check:

plugins {
    application
    `jvm-test-suite`
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation(platform(project(":platform")))
    implementation(project(":core"))

    testImplementation(platform(project(":platform")))
    testImplementation(libs.junit.jupiter)
}

tasks.withType<JavaCompile>().configureEach {
    options.release.set(17)
}

tasks.test {
    useJUnitPlatform()
}

testing {
    suites {
        register<JvmTestSuite>("integrationTest") {
            dependencies {
                implementation(project())
                implementation(platform(project(":platform")))
                implementation(libs.junit.jupiter)
            }
            targets {
                all {
                    testTask.configure {
                        useJUnitPlatform()
                        shouldRunAfter(tasks.named("test"))
                    }
                }
            }
        }
    }
}

tasks.named("check") {
    dependsOn(testing.suites.named("integrationTest"))
}

application {
    mainClass = "dev.academy.capstone.App"
}

tasks.withType<Jar>().configureEach {
    isPreserveFileTimestamps = false
    isReproducibleFileOrder = true
}

Add application, unit, and integration fixtures:

package dev.academy.capstone;

import java.util.Locale;

public final class App {
    private App() {}

    public static String render(String input) {
        return MessageNormalizer.normalize(input).toUpperCase(Locale.ROOT);
    }

    public static void main(String[] args) {
        String input = args.length == 0 ? "orbit platform" : String.join(" ", args);
        System.out.println(render(input));
    }
}
package dev.academy.capstone;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

class AppTest {
    @Test
    void rendersNormalizedText() {
        assertEquals("ORBIT PLATFORM", App.render(" orbit   platform "));
    }
}
package dev.academy.capstone;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

class AppIntegrationTest {
    @Test
    void crossesTheAppToCoreBoundary() {
        assertEquals("ADA LOVELACE", App.render("  Ada   Lovelace "));
    }
}

The important evidence is not the task name. After execution, require unit and integration report directories and nonzero test counts.

7. Inspect the model before generating governance state

First prove the project and dependency models:

./gradlew projects
./gradlew :core:dependencies --configuration runtimeClasspath
./gradlew :core:dependencyInsight \
  --dependency org.apache.commons:commons-lang3 \
  --configuration runtimeClasspath
./gradlew :app:tasks --group verification
./gradlew :app:integrationTest --dry-run

Expected selection: Commons Lang resolves to 3.20.0 because the project imports :platform. The integration task exists and its dry-run evidence can be compared to check --dry-run.

8. Generate locks and dependency-verification metadata as reviewable source

Generate lock state only after reviewing the dependency model:

./gradlew clean check --write-locks
find . -name 'gradle.lockfile' -print -exec sed -n '1,120p' {} \;

./gradlew --write-verification-metadata sha256 clean check
sed -n '1,220p' gradle/verification-metadata.xml

Review before acceptance: confirm expected groups/modules/versions and checksums. Generation is not independent verification. Commit lockfiles and reviewed verification metadata with the build policy; never normalize a surprising mismatch by regenerating metadata without explaining the upstream change.

9. Run the verification gate and capture test evidence

Run from an isolated Gradle User Home and preserve concise evidence:

rm -rf core/build app/build
./gradlew clean check --info | tee evidence/check.log

test -d core/build/test-results/test
test -d app/build/test-results/test
test -d app/build/test-results/integrationTest

find core/build/test-results app/build/test-results -name 'TEST-*.xml' -print
./gradlew :app:check --dry-run | tee evidence/app-check-dry-run.txt

If integrationTest is absent from the dry-run or report tree, stop. A green check without the intended suite is false-green evidence.

10. Capture source/tool evidence before publication

Create a source manifest that excludes derived state. This is the lab’s source identity when a Git commit is unavailable:

python - <<'PY'
from pathlib import Path
import hashlib

skip = {'.gradle', '.lab-gradle', 'build', 'staging-repo', 'release-repo', 'evidence'}
rows = []
for p in sorted(Path('.').rglob('*')):
    if not p.is_file() or any(part in skip for part in p.parts):
        continue
    rows.append(f"{hashlib.sha256(p.read_bytes()).hexdigest()}  {p.as_posix()}")
Path('evidence').mkdir(exist_ok=True)
Path('evidence/source-files.sha256').write_text('\n'.join(rows) + '\n')
print('\n'.join(rows))
PY

Review the manifest itself. A source hash list is useful only when the include/exclude policy is documented and stable.

11. Build once, publish to staging, and capture exact artifact identity

Publish the platform and core components to the disposable staging repository. Publication may execute prerequisite packaging tasks; from this point onward the accepted staged JAR is the release candidate and must never be recreated during promotion:

rm -rf staging-repo release-repo
./gradlew :platform:publishPlatformPublicationToStagingRepository \
          :core:publishCorePublicationToStagingRepository

find staging-repo/dev/academy/capstone -type f -print | sort
jar="staging-repo/dev/academy/capstone/core/1.0.0/core-1.0.0.jar"
test -f "$jar"
sha256sum "$jar" | tee evidence/staged-core.sha256

# Inspect both Maven-compatible and Gradle-rich metadata.
sed -n '1,220p' staging-repo/dev/academy/capstone/core/1.0.0/core-1.0.0.pom
sed -n '1,260p' staging-repo/dev/academy/capstone/core/1.0.0/core-1.0.0.module

The POM is the cross-tool contract; .module is Gradle Module Metadata and can preserve richer variant semantics. They are complementary representations, not interchangeable proof. Record the coordinate dev.academy.capstone:core:1.0.0 and exact JAR checksum in the evidence bundle.

12. Promote exact staged bytes without rebuilding

Promotion is a repository operation, not a compilation operation. Refuse to overwrite an existing immutable release coordinate:

test ! -e release-repo/dev/academy/capstone/core/1.0.0
mkdir -p release-repo/dev/academy
cp -a staging-repo/dev/academy/capstone release-repo/dev/academy/

stage_jar="staging-repo/dev/academy/capstone/core/1.0.0/core-1.0.0.jar"
release_jar="release-repo/dev/academy/capstone/core/1.0.0/core-1.0.0.jar"
stage_sha="$(sha256sum "$stage_jar" | awk '{print $1}')"
release_sha="$(sha256sum "$release_jar" | awk '{print $1}')"
printf 'stage=%s\nrelease=%s\n' "$stage_sha" "$release_sha" | tee evidence/promotion.sha256
test "$stage_sha" = "$release_sha"

No gradlew, javac, or test command belongs in this promotion step. If the checksum differs, promotion fails and the candidate must be investigated.

13. Prove the Maven interoperability boundary from clean state

Create maven-consumer/pom.xml and one Java source file. The Maven project is intentionally a consumer, not a second Orbit source build:

<?xml version="1.0" encoding="UTF-8"?>
<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>dev.academy.consumer</groupId>
  <artifactId>maven-consumer</artifactId>
  <version>1.0.0</version>

  <repositories>
    <repository>
      <id>capstone-release</id>
      <url>file://${project.basedir}/../release-repo</url>
    </repository>
  </repositories>

  <dependencies>
    <dependency>
      <groupId>dev.academy.capstone</groupId>
      <artifactId>core</artifactId>
      <version>1.0.0</version>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.15.0</version>
        <configuration>
          <release>17</release>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
package dev.academy.consumer;

import dev.academy.capstone.MessageNormalizer;

public final class ConsumerMain {
    public static void main(String[] args) {
        System.out.println(MessageNormalizer.normalize("  maven   consumer  "));
    }
}

Copy the already verified Maven Wrapper 3.3.4 files from the Maven portion of this course, then use an isolated Maven local repository:

cd maven-consumer
mkdir -p src/main/java/dev/academy/consumer
# Save ConsumerMain.java at the path above and copy the trusted Maven Wrapper.
./mvnw -version
./mvnw -Dmaven.repo.local="$PWD/.m2-clean" clean compile
cd ..

Expected result: Maven resolves core:1.0.0 from release-repo, follows the generated POM dependency metadata, and compiles the consumer with release 17. This proves a specific interoperability contract; it does not imply Gradle Module Metadata and Maven POMs encode every rich Gradle concept identically.

14. Prove clean-room reproducibility in two source paths

Copy only declared source/governance inputs—not caches, build outputs, staging, release, or evidence—into two independent paths. Use independent Gradle User Homes and disable the build cache for the artifact-identity proof:

cd ..
python - <<'PY'
from pathlib import Path
import shutil
src = Path('orbit-platform')
ignore = shutil.ignore_patterns('.gradle','.lab-gradle','build','staging-repo','release-repo','evidence','.m2-clean')
for name in ('orbit-clean-a','orbit-clean-b'):
    dst = Path(name)
    if dst.exists(): shutil.rmtree(dst)
    shutil.copytree(src,dst,ignore=ignore)
PY

for d in orbit-clean-a orbit-clean-b; do
  (cd "$d" && GRADLE_USER_HOME="$PWD/.clean-gradle" ./gradlew --no-build-cache clean :core:jar)
done
sha256sum orbit-clean-a/core/build/libs/core-1.0.0.jar \
          orbit-clean-b/core/build/libs/core-1.0.0.jar | tee orbit-platform/evidence/clean-room.sha256

Pass condition: both SHA-256 values are identical. A single warm workspace is not independent reproducibility evidence.

15. Provider-neutral CI contract

Keep the course build-engineering focus by specifying the contract rather than teaching a specific CI product:

pipeline:
  checkout:
    workspace: fresh
    source: exact-reviewed-revision
  bootstrap:
    verify_wrapper_distribution_checksum: true
    verify_wrapper_jar_checksum: true
    jdk: 21
    java_release_target: 17
  restore_cache:
    dependency_cache: read
    build_cache: read_if_trusted
    workspace_build_outputs: never_restore_as_dependency_cache
  verify:
    command: ./gradlew clean check
    publish_reports:
      - core/build/test-results/test/**
      - app/build/test-results/test/**
      - app/build/test-results/integrationTest/**
  package_and_stage:
    command: ./gradlew :platform:publish... :core:publish...
    record:
      - source_revision_or_manifest
      - wrapper_and_jdk_identity
      - dependency_graph_and_verification_policy
      - artifact_sha256
  promote:
    input: previously_staged_artifact
    rebuild: false
    require_checksum_match: true
  cache_write:
    allowed_only_from: protected_trusted_lane

Real provider syntax may differ, but the state transitions must not: fresh source, pinned bootstrap, bounded caches, explicit reports, immutable candidate capture, and promotion without rebuild.

16. Establish a performance baseline after correctness

Only after invariants I-01 through I-09 are passing should you profile. Capture several comparable runs and environment notes:

./gradlew --stop
./gradlew clean check --no-build-cache --profile | tee evidence/profile-cold.log
./gradlew check --profile | tee evidence/profile-warm.log
find build/reports/profile -type f -name '*.html' -print | tail -n 2

Record configuration time, dominant task path, dependency-resolution state, worker settings, daemon state, CPU/memory context, and whether outputs/caches were warm. The fixture is intentionally small; “no optimization justified” is an acceptable evidence-based outcome.

17. Challenge: choose the right control

A team asks to put Commons Lang 3.20.0 into libs.versions.toml and remove the platform constraint because “the catalog is already central.” Choose the correct response and defend it with one inspection command.

Expected reasoning: a catalog alias improves declaration ergonomics but does not enforce resolution policy. Keep the platform constraint as the graph policy; keeping the alias versionless avoids two central version authorities. Verify selection with dependencyInsight.

Knowledge check

Why is verification-metadata.xml reviewed instead of blindly regenerated on failure?

What exactly is “build once” in this lab?

Why use an isolated Maven local repository for the consumer?

What does the clean-room two-directory test add beyond clean?

May an untrusted pull-request job write the shared authoritative build cache?

Why is --profile run last?

Official references and version notes

Version-sensitive statements were rechecked against primary documentation on 2026-08-24. Mandatory work remains local/free. Hosted CI, commercial build analytics, production repository managers, remote caches, and real signing/credential systems are optional integration boundaries only.

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.