Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 086 / 100

GitHub Actions CI for Maven projects: a practical guide

Build, test and publish on every push.

Expona Admin8 min read

Building reliable CI pipelines for Maven projects

We've all been there: a developer pushes code that passes locally but breaks in production. GitHub Actions CI for Maven projects: a practical guide helps us prevent exactly that. By automating builds, tests, and deployments on every push, we catch issues early and maintain confidence in our codebase. Let's walk through a production-ready workflow that handles testing with Testcontainers, image publishing, and report generation.

Setting up actions/setup-java with Maven cache

The foundation of any Maven CI pipeline is a reliable Java environment. GitHub's setup-java action does the heavy lifting, but we need to configure it thoughtfully to keep builds fast.

Start with a basic workflow that caches Maven dependencies. Here's what we typically use:

java
name: Maven Build

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up JDK 21
        uses: actions/setup-java@v3
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven
      
      - name: Build with Maven
        run: mvn clean package

The cache: maven parameter is critical. It automatically caches the Maven local repository, typically saving 30-60 seconds per build. On our team's e-commerce platform, this meant the difference between a 2-minute build and a 5-minute build across dozens of daily commits.

For larger monorepos or when you need more control, you can use explicit cache actions:

java
      - name: Cache Maven packages
        uses: actions/cache@v3
        with:
          path: ~/.m2/repository
          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
          restore-keys: |
            ${{ runner.os }}-maven-

This approach lets you share caches across workflows and gives you visibility into what's being cached. The hashFiles('**/pom.xml') ensures we invalidate the cache when dependencies change, preventing stale artifacts from causing obscure bugs.

Running tests with Testcontainers on ubuntu runners

Unit tests are a start, but integration tests catch real issues. We use Testcontainers to spin up databases, message queues, and other services during CI runs. The Ubuntu runners work well for this because Docker comes pre-installed.

Here's a practical test configuration that we use across our SaaS products:

java
      - name: Run integration tests with Testcontainers
        run: mvn verify -Dgroups="integration"
        env:
          TESTCONTAINERS_HOST_OVERRIDE: localhost

In your test code, you'd have something like this:

java
@SpringBootTest
@Testcontainers
public class UserRepositoryIntegrationTest {
    
    @Container
    static PostgreSQLContainer postgres = 
        new PostgreSQLContainer<>("postgres:15")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
    
    @DynamicPropertySource
    static void datasourceProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void shouldPersistUserSuccessfully() {
        User user = new User("alice@example.com", "Alice");
        User saved = userRepository.save(user);
        
        assertThat(saved.getId()).isNotNull();
        assertThat(saved.getEmail()).isEqualTo("alice@example.com");
    }
}

Testcontainers automatically manages the container lifecycle—no manual cleanup needed. On Ubuntu runners, this typically adds 15-30 seconds per test suite. We tag tests using JUnit 5's @Tag annotation and run them conditionally to keep feedback loops tight. Fast unit tests run on every PR, integration tests run on merge to main.

One important detail: make sure your pom.xml includes Testcontainers with the appropriate profile:

java
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.19.6</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.19.6</version>
    <scope>test</scope>
</dependency>

Building and pushing the image on main

After tests pass, we build a Docker image and push it to a registry. We only do this on the main branch to avoid cluttering your registry with every feature branch.

Here's a complete workflow step that builds and pushes to Docker Hub or Amazon ECR:

java
  publish:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up JDK 21
        uses: actions/setup-java@v3
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven
      
      - name: Build JAR
        run: mvn clean package -DskipTests
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      
      - name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: myorg/myapp:${{ github.sha }},myorg/myapp:latest
          cache-from: type=registry,ref=myorg/myapp:buildcache
          cache-to: type=registry,ref=myorg/myapp:buildcache,mode=max

The needs: build ensures this job only runs after the build job succeeds. Using github.sha as a tag gives us immutable references to every build. We tag with both the commit SHA and latest so developers can quickly reference the most recent version.

For ECR, substitute the login action:

java
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: us-east-1
      
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1
      
      - name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ${{ steps.login-ecr.outputs.registry }}/myapp:${{ github.sha }}

Docker layer caching dramatically speeds up subsequent builds. The cache-from and cache-to parameters ensure we reuse layers across CI runs. This can reduce build time from 5 minutes to 1-2 minutes when dependencies haven't changed.

Uploading JaCoCo and test reports

Code coverage metrics matter, but only if we actually track them. JaCoCo integrates seamlessly with Maven and GitHub Actions. We upload reports for visibility and trend tracking.

First, configure JaCoCo in your pom.xml:

java
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.10</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>

Then add these steps to your workflow:

java
      - name: Run tests and generate coverage
        run: mvn clean verify
      
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./target/site/jacoco/jacoco.xml
          flags: unittests
          name: codecov-umbrella
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: test-results
          path: |
            target/surefire-reports/
            target/site/jacoco/

The if: always() ensures we upload reports even if tests fail—critical for debugging. Codecov integrates with GitHub and shows coverage trends over time. We set coverage gates in our branch protection rules so we can't merge PRs that decrease coverage below thresholds.

For Surefire test reports, many teams use this action to get inline comments on PRs:

java
      - name: Publish test results
        if: always()
        uses: EnricoMi/publish-unit-test-result-action@v2
        with:
          files: target/surefire-reports/**/*.xml

This gives developers immediate feedback without leaving GitHub.

Common mistakes

Skipping tests on publish: We often see -DskipTests when building images. This defeats the purpose of CI. If tests pass on the build job, the publish job can safely skip them—use -DskipTests=true only when you explicitly need to rebuild a tested artifact.

Cache invalidation issues: If your pom.xml references snapshot versions or your Maven cache doesn't clear stale artifacts, you'll get mysterious "works locally, fails in CI" scenarios. Always hash your pom.xml files and consider periodic cache clears.

Missing Docker credentials: Pushing to private registries requires proper authentication. Store credentials as GitHub secrets and never commit them. Rotate tokens regularly.

No failure notifications: A workflow that silently fails is worse than no workflow. Add notifications to Slack or email so the team knows when main is broken.

Ignoring coverage trends: A coverage report nobody reads is useless. Set up alerts when coverage drops below thresholds and make coverage visible in your team's sprint reviews.

Putting it together: complete workflow example

Here's a production-ready workflow that combines everything:

java
name: CI/CD Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up JDK 21
        uses: actions/setup-java@v3
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven
      
      - name: Build and test
        run: mvn clean verify
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./target/site/jacoco/jacoco.xml
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: test-results
          path: target/surefire-reports/
  
  publish:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    permissions:
      contents: read
      packages: write
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up JDK 21
        uses: actions/setup-java@v3
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven
      
      - name: Build JAR
        run: mvn clean package -DskipTests
      
      - name: Log in to Container Registry
        uses: docker/login-action@v2
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Build and push image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

Key takeaways

  • Use actions/setup-java with Maven caching enabled to accelerate builds; hash your pom.xml files for reliable cache invalidation.
  • Run integration tests with Testcontainers on Ubuntu runners to catch database and service integration issues before production.
  • Publish Docker images only from main after all tests pass; tag with both commit SHA and latest for traceability.
  • Upload JaCoCo coverage reports to Codecov and test results as artifacts; use inline comments on PRs for immediate developer feedback.
  • Always run tests before publishing—use -DskipTests only when rebuilding a previously tested artifact, never to avoid CI delays.
  • Make coverage trends visible and set enforcement gates so the team takes code quality seriously.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles