CI for Java Projects with Maven using GitHub Actions
1. Overview
Continuous Integration (CI) helps you catch bugs as soon as you push code, instead of waiting until merge time to “break things”. This article walks you through setting up a CI pipeline for a Java Maven project on GitHub Actions: build, run tests, cache dependencies, build matrix with multiple JDK versions, and publish reports. After reading, you can copy the workflow file into your real project and run it in just a few minutes.
2. Prepare Sample Project
Suppose you have a simple Spring Boot project with standard Maven structure. Minimal pom.xml file looks like this:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.nonladev</groupId>
<artifactId>order-service</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
Dependency junit-jupiter and assertj-core are used to write tests. Plugin maven-surefire-plugin is responsible for running tests when you call mvn test.
A small business class to test:
package com.nonladev.order;
public class OrderCalculator {
public double calculateTotal(double unitPrice, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be greater than 0");
}
return unitPrice * quantity;
}
}
And the corresponding test:
package com.nonladev.order;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OrderCalculatorTest {
private final OrderCalculator calculator = new OrderCalculator();
@Test
void shouldCalculateTotalCorrectly() {
double total = calculator.calculateTotal(50_000, 3);
assertThat(total).isEqualTo(150_000);
}
@Test
void shouldThrowWhenQuantityIsInvalid() {
assertThatThrownBy(() -> calculator.calculateTotal(50_000, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Quantity must be greater than 0");
}
}
shouldCalculateTotalCorrectly verifies the sum is correct. shouldThrowWhenQuantityIsInvalid verifies the exception when quantity is invalid. These are tests that CI will run every time you push code.
3. Basic CI Workflow
GitHub Actions reads YAML files in the .github/workflows/ directory. Create file .github/workflows/ci.yml:
name: CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Build and run tests
run: mvn -B clean verify
Explanation of each part:
on: workflow runs onpushorpull_requesttargeting branchmain.actions/checkout@v4: clones source code onto the runner.actions/setup-java@v4: installs JDK 21 Temurin — you always need this step before callingmvn.mvn -B clean verify: the-Bflag (batch mode) disables interactive output, suitable for CI environments.clean verifyrebuilds from scratch and runs tests (theverifyphase includestest).
Push a commit, go to the Actions tab on GitHub, you’ll see the job run with logs similar to:
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.nonladev.order.OrderCalculatorTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
If a test fails, the job will have a red status and you’ll see the stack trace right in the log — no need to SSH into any machine.
4. Cache Maven Dependencies for Faster Builds
Every time a job runs on a new runner (ephemeral), Maven has to download all dependencies from .m2/repository again. For a Spring Boot project, this can take 1–2 minutes each time. setup-java has built-in cache support:
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
The cache: 'maven' property automatically caches the ~/.m2/repository directory based on the hash of pom.xml. On the next build, if pom.xml hasn’t changed, Actions restores the cache immediately, skipping the re-download of each jar.
You can check the effectiveness by comparing the time of the “Build and run tests” step before and after adding cache — usually saves 30–60%.
Note: If your project uses Maven Wrapper (
./mvnw) then you should cache bymvnwinstead of relying on the Maven version pre-installed on the runner, since GitHub updates the image regularly and the default Maven version can change.
5. Build Matrix with Multiple JDK Versions
If you maintain a library that needs to support multiple Java versions, use strategy.matrix to run multiple jobs in parallel:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java-version: [ '17', '21' ]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java-version }}
distribution: 'temurin'
cache: 'maven'
- name: Build and run tests
run: mvn -B clean verify
matrix.java-version lists the versions you want to test. GitHub Actions automatically creates 2 jobs running in parallel: one with JDK 17, one with JDK 21. The variable ${{ matrix.java-version }} gets substituted in each job accordingly.
Results on the Actions tab show 2 separate jobs, e.g. build (17) and build (21), letting you know immediately which Java version your project fails on.
6. Publish Test Report and Check Coverage
By default, test logs only display as text in Actions. For more readable reports, use the dorny/test-reporter action:
- name: Build and run tests
run: mvn -B clean verify
- name: Publish test report
uses: dorny/test-reporter@v1
if: success() || failure()
with:
name: Maven Tests
path: '**/surefire-reports/*.xml'
reporter: java-junit
if: success() || failure(): this step runs even when tests fail (but not if the job is cancelled), so you always see the report. path points to the XML file generated by Surefire after running tests. The report appears as a separate check in the PR, listing each test case as pass/fail.
If you want to measure coverage, add the JaCoCo plugin to pom.xml:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
Running mvn verify automatically generates an HTML report at target/site/jacoco/index.html. You can upload it as an artifact:
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: target/site/jacoco/
This artifact can be downloaded from the workflow run summary page and viewed directly in your browser.
7. Build Status Badge
Once the workflow runs stably, add a badge to your README.md so people can see CI status without going to Actions:

Replace <org>/<repo> with your actual GitHub name. The badge automatically updates green/red based on the most recent run on the default branch.
8. Common Mistakes
- Forgetting to set
java-versionmatchingmaven.compiler.source: build passes locally but CI fails withrelease version X not supported. Always sync JDK version betweenpom.xmland workflow. - Using
mvn testinstead ofmvn verify: if your project has integration tests running in theverifyphase (e.g. Failsafe plugin),mvn testwill skip them, creating false confidence. - Wrong cache key when changing dependencies: if you write cache action manually (
actions/cache) and forget to includepom.xmlin the key hash, stale cache might cause your build to use outdated dependencies. Usecache: 'maven'fromsetup-javato avoid this. - Running CI on self-hosted runner without stable Docker/network: Maven needs to download dependencies from central repository; if the runner has network restrictions, build will hang on the download step. Check your proxy or use an internal Maven mirror.
Note: If your project is very small with just a few simple tests and doesn’t deploy frequently, a full CI workflow with matrix + coverage report might be overkill. Start with the basic workflow in section 3, then expand gradually as your project grows.
9. Conclusion
With just a few dozen lines of YAML, you now have an automated CI pipeline that builds, tests, caches dependencies, and publishes reports for your Maven project. Start with a simple workflow, then add matrix JDK versions, JaCoCo coverage, README badge according to real needs. The most important thing isn’t a complex workflow, but CI that runs reliably and consistently every time you push code.