CI/CD Build Patterns, Caching, Test Sharding, Artifact Promotion, and Ephemeral Build Agents: Guided Hands-On Workflow and Core Operations
Build a local CI simulation around a verified Gradle Wrapper, JDK 21, isolated dependency state, deterministic test shards, archived XML/HTML reports, artifact checksums, and a promotion step that consumes the previously built JAR without invoking the build again.
Learning objectives
- Create a small JVM project whose CI shards have distinct tasks and report paths.
- Run Wrapper-first clean builds with isolated Gradle state and record tool identity.
- Separate dependency cache restoration from project build outputs.
- Export a JAR, checksum, reports, and identity evidence as the pipeline handoff.
- Promote the exported JAR without invoking Gradle a second time.
Lab boundary. Use a disposable directory and a previously verified Gradle 9.7.1 Wrapper. The lesson does not ask an untrusted checkout to generate or bless its own Wrapper. JDK 21 runs Gradle; Java release 17 is the artifact target.
1. Create the disposable project model
Create ch29-ci-lab, copy in the verified Wrapper files
from the Chapter 15 baseline, then add the following build script
and source files. The two shard tasks use the same compiled test
source set but select disjoint test classes and write separate
result/report directories.
plugins {
java
}
group = "dev.academy.ci"
version = "1.0.0"
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
}
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
}
tasks.test {
useJUnitPlatform()
}
val testSourceSet = sourceSets.named("test")
fun registerShard(taskName: String, pattern: String, reportName: String) =
tasks.register<Test>(taskName) {
group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Runs deterministic CI test shard $reportName"
testClassesDirs = testSourceSet.get().output.classesDirs
classpath = testSourceSet.get().runtimeClasspath
useJUnitPlatform()
filter {
includeTestsMatching(pattern)
isFailOnNoMatchingTests = true
}
reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/$reportName"))
reports.html.outputLocation.set(layout.buildDirectory.dir("reports/tests/$reportName"))
}
val shardA = registerShard("testShardA", "*ShardATest", "shard-a")
val shardB = registerShard("testShardB", "*ShardBTest", "shard-b")
tasks.register("ciTest") {
group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Runs every declared CI shard"
dependsOn(shardA, shardB)
}
tasks.jar {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
package dev.academy.ci;
public final class Calculator {
private Calculator() {}
public static int add(int a, int b) { return a + b; }
public static int multiply(int a, int b) { return a * b; }
}
package dev.academy.ci;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ShardATest {
@Test void adds() { assertEquals(7, Calculator.add(3, 4)); }
}
package dev.academy.ci;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ShardBTest {
@Test void multiplies() { assertEquals(12, Calculator.multiply(3, 4)); }
}
2. Preflight: prove Wrapper/JDK identity before touching caches
The first job action records build identity. Use an isolated Gradle User Home so the lab never deletes or mutates your normal cache.
set -euo pipefail
cd ch29-ci-lab
export GRADLE_USER_HOME="$PWD/.ci/gradle-home"
mkdir -p .ci/evidence
git rev-parse HEAD 2>/dev/null | tee .ci/evidence/commit.txt || true
java -version 2>&1 | tee .ci/evidence/java.txt
./gradlew --version | tee .ci/evidence/gradle.txt
./gradlew tasks --group verification | tee .ci/evidence/verification-tasks.txt
Expected model evidence includes test,
testShardA, testShardB, and
ciTest. If the Wrapper or JDK is not the expected
identity, stop before running build logic with credentials.
3. Baseline build: start clean and keep outputs local to this agent
Run the complete shard gate and package once.
clean removes only this project's output tree. It does
not delete the isolated dependency cache.
./gradlew clean ciTest jar --stacktrace
find build/test-results -type f -name 'TEST-*.xml' -print | sort
find build/reports/tests -type f -name 'index.html' -print | sort
ls -l build/libs/
A successful command means both shard tasks completed. It does not yet prove complete partitioning; the checkpoint later validates a shard manifest independently.
4. Preserve test evidence per shard
Gradle's binary test state is not the CI handoff. Keep the machine-readable JUnit XML and, where useful, HTML reports. Distinct report paths prevent shard A from overwriting shard B.
| Shard | JUnit XML | HTML |
|---|---|---|
| A | build/test-results/shard-a/ |
build/reports/tests/shard-a/ |
| B | build/test-results/shard-b/ |
build/reports/tests/shard-b/ |
5. Cache only approved reusable state
In a real CI platform, cache the Wrapper distribution and
dependency-resolution state with a key that encodes the relevant
trust/version inputs. Do not cache
build/ as though it were a dependency cache. A
conceptual Gradle cache policy is:
cache key inputs:
trust_class = protected | untrusted
gradle_wrapper_version = 9.7.1
jdk_major = 21
os_arch = runner platform when required
dependency_policy_hash = hash(lockfiles + verification metadata + repository policy)
restore:
GRADLE_USER_HOME/wrapper/
dependency cache namespaces needed by Gradle
never restore as dependency cache:
build/
test reports from an older commit
previous release JAR under the current run identity
Gradle's Build Cache is a separate mechanism for cacheable task outputs and should have its own trust/push policy. If untrusted branches can populate a shared remote Build Cache, they can influence later cache hits.
6. Export artifact identity as a first-class pipeline output
After tests pass, hash the JAR and copy the JAR plus evidence into a handoff directory. This directory stands in for the CI platform's artifact store.
mkdir -p .ci/artifacts/build-001/reports
artifact="build/libs/ch29-ci-lab-1.0.0.jar"
sha256sum "$artifact" | tee .ci/artifacts/build-001/artifact.sha256
cp "$artifact" .ci/artifacts/build-001/
cp -R build/test-results/shard-a .ci/artifacts/build-001/reports/
cp -R build/test-results/shard-b .ci/artifacts/build-001/reports/
cp .ci/evidence/*.txt .ci/artifacts/build-001/
The checksum file is not a signature, but it binds later promotion to the exact bytes this build exported.
7. Promotion: consume the handoff, do not rebuild
Simulate a release repository with another directory. The promotion
stage verifies the exported checksum, copies the existing JAR, and
rechecks identity. Notice the deliberate absence of
./gradlew jar.
mkdir -p .ci/release/dev/academy/ci/ci-demo/1.0.0
(
cd .ci/artifacts/build-001
sha256sum -c artifact.sha256
)
cp .ci/artifacts/build-001/ch29-ci-lab-1.0.0.jar \
.ci/release/dev/academy/ci/ci-demo/1.0.0/ci-demo-1.0.0.jar
release_sha=$(sha256sum .ci/release/dev/academy/ci/ci-demo/1.0.0/ci-demo-1.0.0.jar | awk '{print $1}')
build_sha=$(awk '{print $1}' .ci/artifacts/build-001/artifact.sha256)
test "$release_sha" = "$build_sha"
printf 'promoted_sha256=%s\n' "$release_sha"
If the hashes differ, promotion fails. The fix is not “rebuild until they match”; find where the artifact was changed.
8. Platform-neutral pipeline first
Keep the correctness contract in repository-owned build scripts where practical, then map it into the CI platform. This reduces duplicated logic across GitHub Actions, GitLab CI/CD, Jenkins, or another runner.
pipeline:
checkout:
create_clean_workspace()
record(commit_sha)
bootstrap:
verify(wrapper_files_and_distribution_checksum)
select_and_record(jdk_21)
print(build_tool_version)
restore_cache:
restore_only(approved_dependency_or_build_cache_namespaces)
never_restore(target_or_build_as_release_truth)
test:
run(shard_a)
run(shard_b)
merge_and_validate(test_reports)
fail_if_missing_or_duplicate_test_coverage()
package:
run(package_once)
artifact_sha256 = hash(artifact)
export(artifact, artifact_sha256, test_reports, tool_identity)
promote:
import_exported_artifact()
verify(artifact_sha256)
copy_same_bytes_to_release_repository()
never_run_compile_or_package_here
9. Concrete CI mapping: a deliberately small GitLab CI example
The Academy has a separate GitLab CI/CD course, so this is only a build-tool mapping—not a GitLab tutorial. The runner is assumed to provide JDK 21; the project Wrapper supplies Gradle. Provider-specific cache write rules are intentionally omitted here because branch protection and runner trust must be designed with the CI course's governance model.
stages: [build, test, promote]
variables:
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle-ci"
build:
stage: build
script:
- java -version
- ./gradlew --version
- ./gradlew clean jar
- sha256sum build/libs/ci-demo-1.0.0.jar > artifact.sha256
artifacts:
paths:
- build/libs/ci-demo-1.0.0.jar
- artifact.sha256
shard_a:
stage: test
script:
- ./gradlew testShardA
artifacts:
when: always
paths: [build/test-results/shard-a/, build/reports/tests/shard-a/]
shard_b:
stage: test
script:
- ./gradlew testShardB
artifacts:
when: always
paths: [build/test-results/shard-b/, build/reports/tests/shard-b/]
promote:
stage: promote
needs: [build, shard_a, shard_b]
script:
- sha256sum -c artifact.sha256
- mkdir -p release/
- cp build/libs/ci-demo-1.0.0.jar release/
- sha256sum -c artifact.sha256
10. Maven lane: same operating model, different stores
Maven uses the same CI principles. Pin the Maven Wrapper, record
./mvnw --version, use an isolated local repository such
as -Dmaven.repo.local="$PWD/.ci/m2", run tests/package
in the clean workspace, preserve
target/surefire-reports, and export the package plus
checksum. Surefire supports explicit test selection with
-Dtest=..., but shard completeness remains your
pipeline responsibility.
./mvnw -Dmaven.repo.local="$PWD/.ci/m2" -Dtest=ShardATest test
./mvnw -Dmaven.repo.local="$PWD/.ci/m2" -Dtest=ShardBTest test
# Package once only after the intended verification gate:
./mvnw -Dmaven.repo.local="$PWD/.ci/m2" package
sha256sum target/*.jar
11. Challenge: choose the correct control
Your CI provider offers a large generic cache and a durable artifact
store. The build produces build/libs/app.jar. Which
location should carry the release candidate between build and deploy
stages?
Reasoned answer: the artifact store, with a recorded checksum and run/source identity. A generic cache is an optimization whose entries may be evicted, overwritten, or keyed for reuse; it is not the authoritative release handoff.
12. Cleanup
Delete only the disposable lab state:
cd ..
rm -rf ch29-ci-lab
Knowledge check
Why are shard report directories different?
So parallel/independent shards cannot overwrite each other and CI can preserve each shard as separate evidence.
Why is build/ absent from the dependency-cache example?
It is project output for the current invocation, not downloaded dependency state.
What command is intentionally absent from promotion?
Any compile/package command such as ./gradlew jar. Promotion consumes the exported artifact.
What should a Maven CI cache avoid if the job runs install?
Blindly treating a shared local repository as dependency-only state, because install can place the project’s own artifacts there.
Why is provider-specific cache policy not hard-coded into the GitLab sketch?
Protected-branch, runner, and secret governance belong to the CI platform model; the build chapter establishes the trust requirements without duplicating that course.
Official references and version notes
- Gradle 9.7.1 release notes — pinned Gradle baseline.
- Gradle dependency caching — dependency-cache state and guidance for ephemeral builds.
- Gradle Build Cache — local/remote task-output cache and CI push/read trust model.
- Build Cache use cases — CI-produced cache entries and cross-machine reuse.
-
Gradle task outcomes
—
UP-TO-DATEversusFROM-CACHE. - Gradle on GitLab CI — current Wrapper-first CI guidance and cache considerations.
- Maven release history — Maven 3.9.16 GA baseline.
- Maven Wrapper 3.3.4 — current stable Wrapper baseline.
- Maven Surefire test goal — explicit test selection and failure behavior.
Version-sensitive statements were rechecked against primary documentation on 2026-08-24. Mandatory labs remain local/free; hosted CI, remote caches, artifact repositories, and secret stores are represented as optional production mappings 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.