Chapter 29Lesson 02~340 minutes

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.

Clean workspaceTest shardingJUnit XMLArtifact evidenceLocal CI simulation

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?

Why is build/ absent from the dependency-cache example?

What command is intentionally absent from promotion?

What should a Maven CI cache avoid if the job runs install?

Why is provider-specific cache policy not hard-coded into the GitLab sketch?

Official references and version notes

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.