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.
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?
Because a mismatch may be evidence of upstream tampering, repository drift, or an unreviewed dependency change. Regeneration would normalize the symptom.
What exactly is “build once” in this lab?
The accepted staged publication contains the candidate JAR bytes. Promotion copies those exact bytes to release; it does not invoke compilation, tests, or packaging again.
Why use an isolated Maven local repository for the consumer?
It prevents an existing user cache from hiding a missing or malformed release-repository contract.
What does the clean-room two-directory test add beyond
clean?
It changes the checkout path and isolates Gradle user state, exposing path-sensitive/nondeterministic output that a single workspace can hide.
May an untrusted pull-request job write the shared authoritative build cache?
Not under this policy. Cache writer trust must be at least as strong as the readers that will consume its outputs.
Why is --profile run last?
Performance tuning must not precede correctness, provenance, test, and artifact-identity acceptance; otherwise speedups can optimize an invalid build.
Official references and version notes
- Gradle 9.7.1 release notes — pinned Gradle baseline for the capstone.
- Gradle Wrapper and release checksums — wrapper/distribution identity and integrity.
- Gradle JVM toolchains — build JVM versus compiler/test launchers.
- Dependency verification — checksum/signature verification metadata and review workflow.
- Repository declarations and content filtering — dependency origin controls.
- Java testing and JVM Test Suite — unit/integration verification model.
- Maven Publish — Gradle publication to Maven repository layout and clean Maven consumption.
- Build Cache and Configuration Cache — bounded build-state reuse.
- Gradle performance guidance — measurement-first optimization and profiling.
- Apache Maven release history — Maven 3.9.16 GA consumer baseline; Maven 4.0.0-rc-6 remains pre-GA.
- Apache Maven Wrapper 3.3.4 — stable Maven Wrapper baseline.
- Maven Compiler Plugin 3.15.0 — clean Maven consumer compilation baseline.
- JUnit 6.1.3 — Java 17+ test runtime baseline.
- Apache Commons Lang release notes — Commons Lang 3.20.0 dependency baseline.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.