Java CI has two decisions that no other language in this cluster forces on you: which JDK distribution to build against, and which build tool’s cache to trust. Both are easy to get wrong in ways that produce a pipeline which works and is still subtly not testing what you think.
The complete Maven workflow is at examples/github-actions/java-ci/maven.yml, validated by
npm run check:workflows.
distribution is not optional
Section titled “distribution is not optional”actions/setup-java requires a distribution as well as a java-version. There is no default,
because there is no neutral choice — “Java 21” names a specification, and several organisations ship
builds of it under different licences and support terms.
- uses: actions/setup-java@v6 with: distribution: temurin java-version: ${{ matrix.java-version }} cache: mavenWhat it doesInstalls the named JDK build at the requested version and configures the Maven local repository cache.
Why we run itA distribution is a licensing and support decision, not a technical detail. Temurin is the Eclipse Foundation's build, freely redistributable, and the common default for CI.
Expected resultA `Setup Java JDK` group in the log naming the exact build, then a cache restore keyed on your build files.
The distributions you are most likely to encounter:
| Value | Publisher | Notes |
|---|---|---|
temurin | Eclipse Adoptium | The usual CI default. Freely redistributable. |
zulu | Azul | Free builds available; commercial support sold separately. |
corretto | Amazon | Long-term support aligned with AWS. |
microsoft | Microsoft | Builds of OpenJDK, LTS versions. |
oracle | Oracle | Licence terms differ materially from the others. |
graalvm | Oracle | Needed for native-image builds. |
Which Java versions to test
Section titled “Which Java versions to test”Java’s LTS releases are the ones with multi-year support, and a matrix built from them matches how projects are actually deployed:
strategy: fail-fast: false matrix: java-version: ["17", "21", "25"]Quote them. 17 unquoted is an integer and works by luck; a future java-version: 25.0 would become
the float 25 and a version like 1.8 becomes 1.8 only because it happens to round-trip. Quoting
removes the class of problem entirely — see YAML syntax.
fail-fast: false again: with three JDKs you want all three verdicts, not the first failure.
Caching: let setup-java do it
Section titled “Caching: let setup-java do it”The dependency cache is where most hand-written Java workflows go wrong. setup-java has built-in
support for all three build tools, keyed on the files that actually determine the dependency set:
cache: maven # keyed on pom.xmlcache: gradle # keyed on the Gradle build and wrapper filescache: sbt # keyed on the sbt build filesA hand-rolled equivalent looks harmless and usually is not:
{/* Don't do this — the key never changes, so the cache never updates. */}- uses: actions/cache@v6 with: path: ~/.m2/repository key: maven-${{ runner.os }}That key has no content hash in it. The first run populates the cache and every later run restores
it, so a new dependency added to pom.xml is downloaded on top of a stale tree forever, and a
removed dependency stays available — meaning CI can pass on code that would not build from a clean
checkout. setup-java’s built-in caching hashes the build files, so the key changes when the
dependencies change.
Maven: verify, not test
Section titled “Maven: verify, not test”- name: Build and test run: mvn --batch-mode --no-transfer-progress verifyWhat it doesRuns the full Maven lifecycle up to and including verify: compile, unit tests, package, and integration tests.
Why we run it`mvn test` stops after unit tests, so it never packages the artifact and never runs failsafe integration tests. A pipeline that only runs `test` reports green on a project whose packaging is broken.
Expected resultSurefire results for unit tests and, if configured, failsafe results for integration tests.
The two flags matter more in CI than locally:
--batch-mode(-B) disables interactive prompts and ANSI colour. Without it, Maven’s progress output is written for a terminal and the log becomes hard to read.--no-transfer-progresssuppresses the per-artifact download percentages. On a cold cache that is thousands of lines of noise between you and the actual error.
Upload the test reports whether or not the build passed:
- name: Upload surefire reports if: always() uses: actions/upload-artifact@v7 with: name: surefire-jdk-${{ matrix.java-version }} path: target/surefire-reports/ retention-days: 14if: always() is the point of the step. Reports from a passing build are of limited interest;
reports from a failing one are the whole reason to look. The artifact name includes the matrix value
because names must be unique within a run.
Gradle: the wrapper is the version
Section titled “Gradle: the wrapper is the version”- uses: actions/setup-java@v6 with: distribution: temurin java-version: "21"
- uses: gradle/actions/setup-gradle@v6
- name: Build and test run: ./gradlew build --no-daemonAlways invoke ./gradlew, never a gradle installed on the runner. The wrapper pins the Gradle
version in gradle/wrapper/gradle-wrapper.properties, which is committed, so CI and every developer
run the same build tool. Calling a system gradle reintroduces exactly the drift the wrapper exists
to prevent.
--no-daemon is worth setting in CI. The Gradle daemon is a long-lived background JVM that speeds up
repeated local builds; on an ephemeral runner it will never be reused, and it can hold the job open
after the build finishes.
Publishing test results as annotations
Section titled “Publishing test results as annotations”Surefire and JUnit XML are machine-readable, so a failing test can be surfaced inline on the pull request diff rather than buried in a log. Most teams reach for a third-party action for this.
Before adding one, note what it needs: to write annotations it requires checks: write or
pull-requests: write, and on a pull request from a fork the default token is read-only regardless
of what you request. That is a deliberate platform restriction, not a bug — see
secrets for why fork runs get a degraded token, and
workflow security before reaching for
pull_request_target to work around it.
The safe pattern is to upload the XML as an artifact from the untrusted run, and let a separate, trusted workflow render it.
Integration tests with Testcontainers
Section titled “Integration tests with Testcontainers”Java’s ecosystem has largely converged on Testcontainers rather than workflow-level services:, and
it works on GitHub-hosted runners with no extra configuration — the Ubuntu images ship a working
Docker daemon.
@Testcontainersclass OrderRepositoryTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");} integration: runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v6 with: distribution: temurin java-version: "21" cache: maven - run: mvn --batch-mode --no-transfer-progress verify -PintegrationThe advantage over services: is that the container definition lives with the test, so it runs
identically on a developer machine. The cost is that images are pulled on every run.
Two things make it noticeably faster:
Cache nothing, pull less. Pin image tags rather than using latest, and prefer the smaller
official variants. Docker layer caching across jobs is not straightforward for Testcontainers, so
reducing what is pulled beats trying to cache it.
Reuse containers within a run by starting them once for the whole suite rather than per test
class. A static @Container field is per class; a shared singleton started once is per JVM.
Coverage with JaCoCo
Section titled “Coverage with JaCoCo”- run: mvn --batch-mode --no-transfer-progress verify
- name: Upload coverage if: always() uses: actions/upload-artifact@v7 with: name: jacoco-jdk-${{ matrix.java-version }} path: target/site/jacoco/ retention-days: 14JaCoCo attaches to the verify lifecycle when configured in the pom, which is another reason to run
verify rather than test — with test, the report phase never runs and the coverage directory is
empty.
A useful summary in the job output without any extra tooling:
- name: Summarise coverage if: always() run: | if [ -f target/site/jacoco/jacoco.csv ]; then awk -F, 'NR>1 {missed+=$4; covered+=$5} END { if (missed+covered > 0) printf "Line coverage: %.1f%%\n", 100*covered/(missed+covered) }' target/site/jacoco/jacoco.csv >> "$GITHUB_STEP_SUMMARY" fiThe if [ -f ] guard matters because this step runs with if: always(), including on runs where the
build failed before producing a report — and a step that fails because its input is missing turns one
failure into two confusing ones.
Gradle specifics
Section titled “Gradle specifics”- uses: actions/setup-java@v6 with: distribution: temurin java-version: "21"
- uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- run: ./gradlew build --no-daemoncache-read-only on non-default branches is the pattern worth adopting. The Gradle build cache is
written from main and read everywhere, which means feature branches get a warm cache without each
one writing its own entry and competing for the repository’s cache quota. It also removes a path by
which a branch could influence what a default-branch build restores — the isolation argument from
caching.
gradle/actions/setup-gradle also writes a job summary listing which tasks were up to date, from
cache, or executed. On a slow build that summary is usually where the answer is.
Dependency scanning
Section titled “Dependency scanning”The JVM ecosystem’s transitive graphs are deep, and a direct dependency on one library frequently brings in dozens more.
- name: Submit the dependency graph uses: advanced-security/maven-dependency-submission-action@v5Submitting the resolved graph lets GitHub’s own dependency alerting see the full transitive set rather than only what the pom declares directly — without it, Dependabot alerts on Maven projects are substantially less complete than they appear.
For a hard gate in the build itself, the OWASP dependency-check plugin fails on findings above a threshold. Two practical cautions: its database download is large and slow, so cache it; and its false positive rate on JVM projects is high enough that a strict gate needs a suppression file that someone maintains. Reporting on a schedule is often the better starting point.
JVM memory on runners
Section titled “JVM memory on runners”Hosted runners have less memory than most development machines, and the JVM’s default heap sizing is a fraction of available memory. A build that works locally can fail in CI with an out-of-memory error that looks unrelated to the change.
env: MAVEN_OPTS: -Xmx3g GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx3gNote the indirection for Gradle: GRADLE_OPTS configures the launcher, and
-Dorg.gradle.jvmargs is what configures the daemon or worker that actually compiles. Setting
-Xmx directly in GRADLE_OPTS frequently has no effect on the process that ran out of memory, which
is why this looks like it does not work.
Forked test JVMs are configured separately again — Surefire’s argLine, or Gradle’s test { maxHeapSize }.
A build with three separate JVM memory settings is normal, and knowing which one applies to the process
that failed saves a lot of guessing.
Publishing
Section titled “Publishing” publish: needs: test runs-on: ubuntu-latest environment: maven-central permissions: contents: read steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v6 with: distribution: temurin java-version: "21" cache: maven server-id: central server-username: MAVEN_USERNAME server-password: MAVEN_PASSWORD gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} gpg-passphrase: MAVEN_GPG_PASSPHRASE
- run: mvn --batch-mode --no-transfer-progress deploy -DskipTests env: MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}setup-java writes the settings.xml and imports the signing key, so no credentials are written by
hand. Note that the server-username and gpg-passphrase values are environment variable names,
not the values themselves — setup-java writes a settings.xml that references them, and the actual
secrets arrive through env: on the step that needs them.
Maven Central still requires stored credentials and a signing key, which makes this one of the cases
covered in removing long-lived cloud credentials
under “what cannot be migrated”. Reduce the exposure the way that page describes: store them as
environment secrets on a maven-central environment with required reviewers, so no other job in
the repository can read them.
Publishing to GitHub Packages instead needs no stored credential at all — GITHUB_TOKEN with
packages: write is sufficient, which is a real argument for it when the consumers are internal.
Making the build use the whole runner
Section titled “Making the build use the whole runner”Maven builds one module at a time by default, which leaves most of a runner’s cores idle on a multi-module project:
- run: mvn --batch-mode --no-transfer-progress -T 1C verify-T 1C runs one thread per available core, building independent modules concurrently. On a reactor
with a wide dependency graph this is often the single largest improvement available.
It requires the build to be thread-safe, which mostly means the plugins are. Most maintained plugins are; an old or in-house one may not be, and Maven warns about plugins not marked thread-safe rather than failing. Read those warnings before trusting a parallel build — a plugin writing to a shared location produces intermittent failures that look like flaky tests.
Gradle parallelises by default within a project’s task graph, and --parallel extends that across
subprojects.
Neither replaces the test-level parallelism configured in Surefire or the Gradle test task, which is
a separate setting governing how many JVMs run tests at once. A build can be parallel at the module
level and serial at the test level, and if your bottleneck is one module with a long suite, the module
setting will not help.
Multi-module builds
Section titled “Multi-module builds”A Maven reactor or Gradle multi-project build compiles many modules in one invocation, and CI has two questions to answer about it.
Build only what changed. Maven’s -pl selects projects and -am adds the modules they depend on:
- run: mvn --batch-mode --no-transfer-progress -pl modules/orders -am verify-am is the important half. Building modules/orders alone fails if it depends on a sibling that has
not been installed; -am builds those first. Omitting it is the most common cause of a
“cannot resolve dependency” failure in a build that works locally, where the sibling is already in
~/.m2.
Fail usefully. By default the reactor stops at the first failing module, so a change breaking three
modules reports one. --fail-at-end builds everything it can and reports all failures together:
- run: mvn --batch-mode --no-transfer-progress --fail-at-end verifyThe same trade-off as fail-fast on a matrix: slower on a
broken build, and it tells you the whole story in one run instead of three.
Surfacing test failures where people read them
Section titled “Surfacing test failures where people read them”A failing Java build produces a stack trace hundreds of lines into a log. The failure is legible in the XML that Surefire already wrote, so it can be rendered where the reviewer is:
- name: Summarise test failures if: failure() run: | { echo "### Failing tests" echo "" echo '```' grep -h -A3 "<failure\|<error" target/surefire-reports/*.xml 2>/dev/null \ | head -c 40000 || echo "no surefire XML found" echo '```' } >> "$GITHUB_STEP_SUMMARY"Crude, dependency-free, and it puts the assertion message on the run summary page rather than 400 lines
into a log. head -c bounds the output because job summaries have a size limit per step.
Third-party actions render this far more nicely, as proper annotations on the diff. Before adding one,
note what it needs: checks: write or pull-requests: write, which a fork pull request’s token does
not have regardless of what you request. The workaround is the workflow_run split described in
workflow security — not pull_request_target.
Reproducible builds
Section titled “Reproducible builds”Two builds of the same commit should produce the same jar. By default they do not, because Maven writes the build timestamp into the archive and file ordering is not guaranteed.
<properties> <project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp></properties>With that property set, Maven normalises entry timestamps and ordering. The value is conventionally
updated per release rather than per build — often from the commit date, which git log -1 --format=%cI
supplies.
This matters beyond tidiness: a build provenance attestation binds a digest to a commit, and that claim is far more useful when anyone can rebuild the commit and confirm they get the same digest. Without reproducibility, the attestation says only “this workflow produced these bytes”, not “these bytes follow from this source”.
Gradle has an equivalent, setting preserveFileTimestamps = false and reproducibleFileOrder = true
on the archive tasks.
The complete pipeline
Section titled “The complete pipeline”-
Test job, per JDK. Checkout,
setup-javawith a distribution andcache,mvn verifyor./gradlew build, upload reports withif: always(). -
Package job,
needs: test. Build once on the primary JDK with-DskipTests— the tests already ran — and upload the jar. -
Branch protection. Require the test job. With a matrix, the check names carry the version:
Test (JDK 17),Test (JDK 21),Test (JDK 25).
-DskipTests in the package job is not a shortcut around testing. It skips re-running tests that
the needs: test job already ran on the same commit. Note that -Dmaven.test.skip=true is different
and stronger: it skips compiling the tests too, which can hide a test-source compilation error.
Exercise
Section titled “Exercise”-
Copy
examples/github-actions/java-ci/maven.ymlinto a Maven project at.github/workflows/ci.ymland push a branch. -
Confirm three matrix legs run and that the second push is faster because the
~/.m2cache was restored. Look for theCache restored from keyline. -
Add a dependency to
pom.xmland push. Confirm the cache key changed — the log shows a cache miss followed by a save — becausesetup-javahashes the pom. -
Make one test fail, push, and download the surefire artifact. Confirm it exists even though the job failed, which is what
if: always()bought you.
Then what?
Section titled “Then what?”Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.