Skip to content

Java CI with GitHub Actions: Maven and Gradle

Lesson 4 of 8Beginner → Intermediate12 min readGitHub Actions & CI/CD · Continuous IntegrationVerified: actions/setup-java v6, gradle/actions v6, actions/upload-artifact v7, August 2026

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.

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: maven

What 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:

ValuePublisherNotes
temurinEclipse AdoptiumThe usual CI default. Freely redistributable.
zuluAzulFree builds available; commercial support sold separately.
correttoAmazonLong-term support aligned with AWS.
microsoftMicrosoftBuilds of OpenJDK, LTS versions.
oracleOracleLicence terms differ materially from the others.
graalvmOracleNeeded for native-image builds.

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.

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.xml
cache: gradle # keyed on the Gradle build and wrapper files
cache: sbt # keyed on the sbt build files

A 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.

- name: Build and test
run: mvn --batch-mode --no-transfer-progress verify

What 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-progress suppresses 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: 14

if: 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.

- 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-daemon

Always 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.

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.

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.

@Testcontainers
class 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 -Pintegration

The 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.

- 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: 14

JaCoCo 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"
fi

The 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.

- 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-daemon

cache-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.

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@v5

Submitting 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.

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=-Xmx3g

Note 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.

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.

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.

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 verify

The 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.

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.

  1. Test job, per JDK. Checkout, setup-java with a distribution and cache, mvn verify or ./gradlew build, upload reports with if: always().

  2. Package job, needs: test. Build once on the primary JDK with -DskipTests — the tests already ran — and upload the jar.

  3. 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.

  1. Copy examples/github-actions/java-ci/maven.yml into a Maven project at .github/workflows/ci.yml and push a branch.

  2. Confirm three matrix legs run and that the second push is faster because the ~/.m2 cache was restored. Look for the Cache restored from key line.

  3. Add a dependency to pom.xml and push. Confirm the cache key changed — the log shows a cache miss followed by a save — because setup-java hashes the pom.

  4. 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.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.