hammerkit

Java

Download dependencies, test and package a Maven project in containers, with the download cached by the POM.

This tutorial downloads the dependencies and plugins of a small Maven project once, then runs mvn test and mvn package against them offline, each in a maven:3.9-eclipse-temurin-21 container and each skipped when its inputs haven't changed. You need hammerkit and Docker; neither a JDK nor Maven has to be installed on your machine.

The finished project is examples/tutorial-java.

The project

tutorial-java/
├── pom.xml
└── src/
    ├── main/java/dev/hammerkit/greet/Greet.java
    └── test/java/dev/hammerkit/greet/GreetTest.java
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>dev.hammerkit</groupId>
  <artifactId>greet</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
    <!-- Surefire otherwise picks its own launcher version at test time -->
    <dependency>
      <groupId>org.junit.platform</groupId>
      <artifactId>junit-platform-launcher</artifactId>
      <version>1.13.4</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <!-- from 3.9.0 on, go-offline also downloads the dependencies of plugins -->
        <artifactId>maven-dependency-plugin</artifactId>
        <version>3.9.0</version>
      </plugin>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
        <dependencies>
          <!-- the JUnit provider Surefire would otherwise download at test time -->
          <dependency>
            <groupId>org.apache.maven.surefire</groupId>
            <artifactId>surefire-junit-platform</artifactId>
            <version>3.5.4</version>
          </dependency>
        </dependencies>
      </plugin>
    </plugins>
  </build>
</project>

Besides JUnit, the POM pins three things so that everything a build needs can be downloaded up front. Surefire, Maven's test runner, normally downloads its JUnit provider and picks a JUnit launcher only when the tests start. Declaring both moves them into the POM. go-offline downloads dependencies declared on plugins only from maven-dependency-plugin 3.9.0 on, so the POM pins that version too.

src/main/java/dev/hammerkit/greet/Greet.java
package dev.hammerkit.greet;

public class Greet {
    public static String hello(String name) {
        return "Hello, " + name + "!";
    }

    public static void main(String[] args) {
        System.out.println(hello(args.length > 0 ? args[0] : "world"));
    }
}
src/test/java/dev/hammerkit/greet/GreetTest.java
package dev.hammerkit.greet;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class GreetTest {
    @Test
    void greetsByName() {
        assertEquals("Hello, hammerkit!", Greet.hello("hammerkit"));
    }
}

1. Download dependencies

A task names the image it runs in, the files it reads (src) and the files it produces (generates). The deps task downloads every dependency and plugin the POM names into a local repository in the project:

.hammerkit.yaml
envs:
  # flags for every mvn call: no progress noise, and the local repository in
  # the project, where the deps task keeps it as its output
  MAVEN_ARGS: --batch-mode -Dmaven.repo.local=.m2

tasks:
  deps:
    description: download dependencies and plugins
    image: maven:3.9-eclipse-temurin-21
    src: [pom.xml]
    generates: [.m2]
    cmds:
      - mvn dependency:go-offline
hammerkit deps

Run it again and hammerkit skips it: pom.xml is unchanged. Only a dependency change downloads again.

MAVEN_ARGS is read by every mvn call. It moves the local repository from ~/.m2/repository into the project, where deps can keep it as a task output.

2. Test and package

Both tasks depend on deps, which mounts its .m2 into their containers:

.hammerkit.yaml
tasks:
  test:
    description: compile and run the unit tests
    image: maven:3.9-eclipse-temurin-21
    deps: [deps]
    src: [src]
    cmds:
      - mvn --offline test

  package:
    description: build the jar
    image: maven:3.9-eclipse-temurin-21
    deps: [deps]
    src: [src]
    generates:
      - path: target
        export: true
    cmds:
      - mvn --offline package -DskipTests

Neither lists pom.xml: a task also sees the sources of the tasks it depends on, and a change to them reruns it, so editing the POM reruns deps, test and package. --offline keeps Maven from reaching the network, so an artifact missing from deps' output fails the build instead of being downloaded on the side.

A container task's outputs stay in a volume hammerkit manages; export: true also copies target into your project, so the jar is at target/greet-1.0.0.jar. -DskipTests doesn't skip any checking: test runs the tests, and ci below requires both.

test and package don't depend on each other, so they run in parallel.

3. One task for CI

A task without cmds groups other tasks. ci names everything a change has to pass. The complete build file:

.hammerkit.yaml
envs:
  # flags for every mvn call: no progress noise, and the local repository in
  # the project, where the deps task keeps it as its output
  MAVEN_ARGS: --batch-mode -Dmaven.repo.local=.m2

tasks:
  deps:
    description: download dependencies and plugins
    image: maven:3.9-eclipse-temurin-21
    src: [pom.xml]
    generates: [.m2]
    cmds:
      - mvn dependency:go-offline

  test:
    description: compile and run the unit tests
    image: maven:3.9-eclipse-temurin-21
    deps: [deps]
    src: [src]
    cmds:
      - mvn --offline test

  package:
    description: build the jar
    image: maven:3.9-eclipse-temurin-21
    deps: [deps]
    src: [src]
    generates:
      - path: target
        export: true
    cmds:
      - mvn --offline package -DskipTests

  ci:
    description: everything CI checks
    deps: [test, package]
hammerkit run ci
Summary:
  ci       executed   34.1s
  deps     executed   31.8s
  package  executed   33.9s
  test     executed   34.1s
  4 executed, 0 cached (0% cache hit), 34.2s total

Run it again without changing anything:

Summary:
  ci       cached     17ms
  deps     skipped    0ms
  package  cached     0ms
  test     cached     0ms
  0 executed, 3 cached, 1 skipped (100% cache hit), 34ms total

deps is skipped: every task that needs it was a cache hit, so the repository isn't needed either. Edit a file in src and test and package run again, against the artifacts deps already has.

Ignore what hammerkit and Maven write into the project:

.gitignore
.hammerkit
.m2
target

Run it in CI

CI runs the same command: install hammerkit and run hammerkit run ci. A fresh runner starts with an empty cache; to reuse what your laptop or the previous run already built, share the cache as described in caching in CI.

Next steps

On this page