CI that keeps up

How do I run Maven builds the same way locally and in CI?

A Maven build gives the same result on a laptop and in CI when both places use the same JDK and Maven version and run mvn verify as the verdict. Both places also need a dependency cache that persists, Surefire reports when tests fail, settings.xml credentials outside the repository, and a way to run one test class. You can meet these requirements by hand with the Maven Wrapper and your CI platform’s cache. Dagger meets the same requirements in one place. Its official Maven module runs ./mvnw verify in a container with a pinned JDK and a cached local repository, on your machine and in Dagger Cloud.

What a reproducible Maven build needs

A Maven build is reproducible when its inputs and its verdict are the same everywhere. Each requirement below comes from Maven’s own documentation:

  1. A pinned JDK and Maven version. The Maven Wrapper records the Maven version in .mvn/wrapper/maven-wrapper.properties, and ./mvnw downloads that version. The wrapper selects Maven, not the JDK, so you must pin the JDK separately.
  2. The full lifecycle through mvn verify as the verdict. The Maven lifecycle guide says that “the preferred phase to call is mvn verify” when you are uncertain. It runs every earlier phase, and also the integration tests and quality checks that the POM binds.
  3. A dependency cache that persists between runs. Maven keeps downloaded artifacts in the local repository, ~/.m2/repository by default (Settings Reference). Without that directory, each run downloads every dependency again.
  4. Surefire reports kept when tests fail. Surefire writes XML and text reports to target/surefire-reports (surefire:test). A failed build must still give you those files.
  5. settings.xml credentials kept out of the repository and the logs. Maven’s settings reference says that the username and password “should not be distributed along with the pom.xml”. They belong in settings.xml on the build machine.
  6. One test class run without the whole suite. Surefire runs one class with -Dtest=ClassName, so you can repeat one failure quickly.

Meet the requirements by hand

Most teams commit the Maven Wrapper, run ./mvnw verify on their machines, and run the same command in a CI job.

A pinned JDK and Maven version. Run mvn wrapper:wrapper -Dmaven=<version> once, and commit mvnw, mvnw.cmd, and .mvn/wrapper/ (Maven Wrapper). In CI, pin the JDK with a setup step, such as java-version on actions/setup-java. On laptops, each developer installs a JDK.

mvn verify as the verdict. Run ./mvnw --batch-mode verify in the CI job. GitHub’s Java with Maven guide uses the same phase.

A dependency cache. On GitHub Actions, set cache: maven on actions/setup-java. It caches ~/.m2/repository and the wrapper distributions. Its key is a hash of **/pom.xml, **/.mvn/wrapper/maven-wrapper.properties, and **/.mvn/extensions.xml (setup-java caching). On a self-hosted build server, keep a persistent ~/.m2, or set localRepository in settings.xml to a shared path (Settings Reference).

Surefire reports. Upload **/target/surefire-reports/ with actions/upload-artifact in a step with if: always(), so the step also runs after a failure (expressions).

Credentials. Store the credentials as CI secrets, and write settings.xml at the start of the job. actions/setup-java can write the <servers> entries from environment variables (setup-java).

One test class. In a multi-module reactor, select the module and the class:

./mvnw -pl app -am -Dtest=CheckoutTest -Dsurefire.failIfNoSpecifiedTests=false test

-am also builds the modules that app depends on (Maven CLI options). -Dsurefire.failIfNoSpecifiedTests=false stops those modules from failing because they do not contain the class (surefire:test).

A GitHub Actions workflow that meets the CI side of these requirements:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      - run: ./mvnw --batch-mode verify
      - if: always()
        uses: actions/upload-artifact@v7
        with:
          name: surefire-reports
          path: '**/target/surefire-reports/'

Laptops and CI set up the JDK and credentials separately

The hand-built version keeps each requirement in a different place, and each place can change on its own:

  • The JDK. The JDK version is in the workflow file. On laptops, it is the JDK that each developer installed. The wrapper pins Maven but not the JDK.
  • The cache. The cache is a CI platform feature. A laptop’s ~/.m2 also holds artifacts that CI never saw, such as SNAPSHOT builds that a local mvn install put there (lifecycle).
  • Reproduction. To reproduce a CI failure, a developer reads the workflow file and sets up the same JDK, flags, and settings by hand.
  • Credentials. Credentials need two setups: a settings.xml on each laptop, and secrets with a setup step in CI.

Meet the requirements with Dagger

Dagger runs builds and tests in containers on its engine. A module is a package of functions that build and test a project. The official Maven module, dagger.io/java/maven, finds the POM projects in your repository, uses your Maven wrapper, and runs Maven in a container. Install it from the project root:

dagger module install dagger.io/java/maven
Created workspace config in /work
Installed module "maven" in /work/dagger.toml

The command records the module in dagger.toml, the workspace configuration file. It also writes dagger.lock, which pins the module and images to exact versions (dagger.toml). Commit both files. A standard Maven project needs nothing else, so you do not write a module of your own.

mvn verify as the verdict

The module has one check, a function that passes or fails: verify. It runs mvn verify on every reactor that the module finds. The project in this guide is a two-module reactor, library and app, with JUnit 5 tests and a Maven wrapper:

dagger check -l
MODULE  MAVEN-PROJECT  CHECK   DESCRIPTION
maven   *              verify  Run through verify: unit tests, packaging, integration tests, and quality gates.
# Use --all to expand collections and list each item.
dagger check
CHECKS  ✔ 1 passed
✔ dag://?check=maven/projects/verify 6.0s OK

The module does not add plugins to your POM. The check runs the plugins that your POM binds to the lifecycle, such as Failsafe integration tests and their post-integration-test cleanup.

Reactor discovery and roots for nested projects

The module finds reactors on its own. It selects the outermost directories that contain a pom.xml, so it does not build a reactor member twice. Maven still decides which modules are in the reactor and in which order they build.

A nested project that builds on its own needs the roots setting. An example is tools/migration below a root POM that does not list it. When you set roots, list every reactor root, the workspace root . too. Edit dagger.toml:

[modules.maven.settings]
roots = [".", "tools/migration"]
dagger check -l --all
MODULE  MAVEN-PROJECT    CHECK   DESCRIPTION
maven   .                verify  Run through verify: unit tests, packaging, integration tests, and quality gates.
maven   tools/migration  verify  Run through verify: unit tests, packaging, integration tests, and quality gates.

A pinned JDK and Maven version

The module uses the maven:3.9.16-eclipse-temurin-21 image by default. If the project root has mvnw, the module runs sh ./mvnw, so the Maven version in maven-wrapper.properties applies. If the wrapper fails, the build fails. The module does not change to another Maven version. The javaVersion setting selects the Temurin JDK. If the project needs Java 25, set it:

dagger module settings maven javaVersion 25

The setting goes into dagger.toml, so every developer and every CI run uses that JDK. In this guide’s project, the wrapper keeps Maven 3.9.11 and the JDK changes to 25:

dagger -c 'maven | project --path=. | run --goals=--version | stdout'
Apache Maven 3.9.11 (3e54c93a704957b63ee3494413a2b544fd3d825b)
Maven home: /maven/wrapper/dists/apache-maven-3.9.11/a2d47e15
Java version: 25.0.4.1, vendor: Eclipse Adoptium, runtime: /opt/java/openjdk
Default locale: en_US, platform encoding: UTF-8
OS name: "linux", version: "7.1.4", arch: "amd64", family: "unix"

Keep maven.compiler.release in the POM for the language target. javaVersion selects the JDK that compiles the code, not the bytecode level.

A dependency cache that persists between runs

The module keeps the local repository and the wrapper distributions in a cache volume, a directory whose contents persist across Dagger runs. Each workspace and reactor gets its own volume by default. The container gets the project source without host target directories or local dependency caches. Thus a SNAPSHOT in your laptop’s ~/.m2 cannot change the result. Dagger also caches function results by their inputs. A run with unchanged inputs returns the earlier result, and Maven does not run again (Glossary).

In this guide’s project, the first dagger check with an empty dependency cache took 25.0 seconds. The base image was already on the machine. A rerun with no changes took 0.2 seconds. After a one-line change to a source file in app, Maven ran again with the cached dependencies, and the check took 8.6 seconds. These durations come from one run on one machine, as dagger check printed them.

Surefire reports kept when tests fail

When a test fails, the verify check fails and prints Maven’s output:

  ┃ [INFO] Reactor Summary for shop 1.0.0-SNAPSHOT:
  ┃ [INFO] 
  ┃ [INFO] shop ............................................... SUCCESS [  0.001 s]
  ┃ [INFO] library ............................................ SUCCESS [  1.540 s]
  ┃ [INFO] app ................................................ FAILURE [  0.549 s]
  ┃ [INFO] ------------------------------------------------------------------------
  ┃ [INFO] BUILD FAILURE

The reports function runs verify with -Dmaven.test.failure.ignore=true. It returns the Surefire and Failsafe report directories with their module paths. Export them to the host:

dagger -c 'maven | project --path=. | reports | export ./reports'

The exported files:

reports/app/target/surefire-reports/TEST-com.example.CheckoutTest.xml
reports/app/target/surefire-reports/com.example.CheckoutTest.txt
reports/library/target/surefire-reports/TEST-com.example.PricesTest.xml
reports/library/target/surefire-reports/com.example.PricesTest.txt

A compilation error still makes reports fail. Use the verify check for the verdict, and reports for the files.

settings.xml credentials as a Dagger secret

The settings setting takes a Dagger secret. Dagger keeps a secret out of logs, container layers, and the cache. The module mounts the secret as settings.xml, outside the source tree and the dependency cache. To keep the value out of the repository, store the setting in your user configuration with -g, not in dagger.toml:

dagger module settings -g maven settings file:$HOME/maven-settings.xml

The value is a secret provider URI. Dagger can also read secrets from 1Password, Vault, and cloud secret managers (Secrets). A user-level setting applies only on the machine that holds it. In this guide’s project, mvn help:effective-settings shows that Maven reads the server entry, and Dagger hides the password:

  <servers>
    <server>
      <username>ci</username>
      <password>***</password>
      <id>internal</id>
    </server>
  </servers>

The module’s deploy function publishes to the repository in the POM’s distributionManagement with these credentials. You call it explicitly. It never runs as a check.

One test class run without the whole suite

Each reactor has a tests collection of test classes. The module finds them in src/test/java with Surefire’s default file name patterns. It lists them without a Maven run:

dagger -c 'maven | project --path=. | tests | keys'
app/com.example.CheckoutTest
library/com.example.PricesTest

A key is the module directory and the fully qualified class name. Run one class:

dagger -c 'maven | project --path=. | tests | get app/com.example.CheckoutTest | test | stdout'
[INFO] Running com.example.CheckoutTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.059 s -- in com.example.CheckoutTest
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

The module builds library first, because app depends on it, and runs only the selected class. If the class produces no test cases, the run fails. Class selection needs a recent Surefire, such as 3.5.4, and its standard XML reports. The verify check still runs the full suite. If your tests use another source directory or other file names, set testSourceDirectory and testIncludes.

Tests that need a real database or Kafka broker are a separate topic. See How do I run integration tests against real databases?

Run the same check locally and in CI

On a laptop, dagger check runs the verify check with the JDK, the Maven version, and the settings from dagger.toml. In CI, Cloud Checks runs the same checks on Dagger’s Cloud Engines for each push and pull request to a GitHub repository. You do not write a CI workflow file. From the project root, run:

dagger cloud checks on

The command connects the repository to Dagger Cloud. Commit dagger.toml and dagger.lock, and push. The results show as GitHub check statuses and in Dagger Cloud. If a check fails, run dagger check on your machine to reproduce it. Cloud Engines bill the CPU, memory, and data that each pipeline uses (pricing).

Bottom line: Commit the Maven Wrapper, pin the JDK, run mvn verify as the verdict, cache the local repository, keep Surefire reports after a failure, and keep settings.xml credentials in a secret store. By hand, these live in the workflow file, the CI cache, and each laptop. The dagger.io/java/maven module keeps them in dagger.toml, and dagger check runs ./mvnw verify the same way locally and in Cloud Checks.