CI Pipelines for a Java App

A 1-hour hands-on session: GitHub Actions and Jenkins, end to end, for a Maven-based Java application.

Contents
  1. Session plan
  2. Prerequisites
  3. Part 1: Sample Java app
  4. Part 2: GitHub Actions
  5. Part 3: Jenkins
  6. Part 4: Trigger, observe, and break things
  7. Part 5: Recap and next steps
  8. Troubleshooting

Session plan

TimeSegment
0:00 to 0:05Prerequisites check and repo setup
0:05 to 0:15Build the sample Java app, tests, and Maven configuration
0:15 to 0:25GitHub Actions workflow
0:25 to 0:45Jenkins setup and Jenkinsfile
0:45 to 0:55Trigger both pipelines, break the build, observe failures
0:55 to 1:00Recap and next steps

Prerequisites

Verify the basics:

java -version
mvn -version
git --version
docker --version

Part 1: Sample Java app (0:05 to 0:15)

Create the project structure:

mkdir -p calc-ci-demo/src/main/java/com/example/calc
mkdir -p calc-ci-demo/src/test/java/com/example/calc
cd calc-ci-demo
git init -b main
src/main/java/com/example/calc/Calculator.java
package com.example.calc;

public final class Calculator {

    public int add(int a, int b) {
        return a + b;
    }

    public double divide(double a, double b) {
        if (b == 0) {
            throw new IllegalArgumentException("Cannot divide by zero");
        }
        return a / b;
    }
}
src/main/java/com/example/calc/App.java
package com.example.calc;

public final class App {

    public static void main(String[] args) {
        Calculator calc = new Calculator();
        System.out.println("2 + 3 = " + calc.add(2, 3));
        System.out.println("10 / 4 = " + calc.divide(10, 4));
    }
}
src/test/java/com/example/calc/CalculatorTest.java
package com.example.calc;

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

import org.junit.jupiter.api.Test;

class CalculatorTest {

    private final Calculator calc = new Calculator();

    @Test
    void add() {
        assertEquals(5, calc.add(2, 3));
    }

    @Test
    void divide() {
        assertEquals(2.5, calc.divide(5, 2));
    }

    @Test
    void divideByZeroThrows() {
        assertThrows(IllegalArgumentException.class, () -> calc.divide(1, 0));
    }
}
pom.xml
<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>com.example</groupId>
  <artifactId>calc-ci-demo</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.release>17</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.10.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <!-- Lint step: -Werror turns every compiler warning into a build failure -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
        <configuration>
          <compilerArgs>
            <arg>-Xlint:all</arg>
            <arg>-Werror</arg>
          </compilerArgs>
        </configuration>
      </plugin>

      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.2.5</version>
      </plugin>

      <plugin>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>0.8.12</version>
        <executions>
          <execution>
            <id>prepare-agent</id>
            <goals><goal>prepare-agent</goal></goals>
          </execution>
          <execution>
            <id>report</id>
            <phase>verify</phase>
            <goals><goal>report</goal></goals>
          </execution>
        </executions>
      </plugin>

      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.4.1</version>
        <configuration>
          <archive>
            <manifest>
              <mainClass>com.example.calc.App</mainClass>
            </manifest>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
.gitignore
target/
.idea/
*.iml
.vscode/
*.class

Run locally to confirm everything works before automating it:

mvn -B compile          # lint: fails on any compiler warning
mvn -B verify           # tests, coverage report, and jar
java -jar target/calc-ci-demo-1.0.0.jar

Test results land in target/surefire-reports/ and the coverage report in target/site/jacoco/index.html.

Commit and push:

git add . && git commit -m "Add sample app, tests, and Maven build"
git remote add origin https://github.com/<your-user>/java-ci-demo.git
git push -u origin main

Part 2: GitHub Actions (0:15 to 0:25)

Create the workflow file at .github/workflows/ci.yml:

name: Java CI

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

jobs:
  test:
    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:
          distribution: temurin
          java-version: ${{ matrix.java-version }}
          cache: maven

      - name: Lint (compile with warnings as errors)
        run: mvn -B compile

      - name: Test and coverage
        run: mvn -B verify

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: surefire-reports-java${{ matrix.java-version }}
          path: target/surefire-reports/

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
          cache: maven

      - name: Package application
        run: mvn -B -DskipTests package

      - name: Upload jar
        uses: actions/upload-artifact@v4
        with:
          name: calc-jar
          path: target/*.jar

Commit and push, then check the Actions tab in GitHub. The matrix produces two test jobs, one for JDK 17 and one for JDK 21. The build job runs only after both pass.


Part 3: Jenkins (0:25 to 0:45)

3.1 Run Jenkins

The lts-jdk17 image already includes a JDK, so no Java install is needed:

docker run -d --name jenkins \
  -p 8080:8080 -p 50000:50000 \
  -v jenkins_home:/var/jenkins_home \
  jenkins/jenkins:lts-jdk17

Get the initial admin password and open the UI:

docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword

Go to http://localhost:8080, paste the password, choose Install suggested plugins, and create an admin user.

3.2 Install Maven and Git in the Jenkins container

docker exec -u root jenkins bash -c "apt-get update && apt-get install -y maven git"

Verify both tools:

docker exec jenkins java -version
docker exec jenkins mvn -version
The Maven build must run on a JDK that matches maven.compiler.release in the pom. If you change that value to 21, use a Jenkins image with JDK 21 (for example jenkins/jenkins:lts-jdk21).

3.3 Add the Jenkinsfile to the repository

Create Jenkinsfile in the repo root:

pipeline {
    agent any

    options {
        timestamps()
        disableConcurrentBuilds()
        buildDiscarder(logRotator(numToKeepStr: '10'))
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Lint') {
            steps {
                sh 'mvn -B compile'
            }
        }

        stage('Test') {
            steps {
                sh 'mvn -B verify'
            }
            post {
                always {
                    junit 'target/surefire-reports/*.xml'
                    archiveArtifacts artifacts: 'target/site/jacoco/**', allowEmptyArchive: true
                }
            }
        }

        stage('Package') {
            steps {
                sh 'mvn -B -DskipTests package'
                archiveArtifacts artifacts: 'target/*.jar', fingerprint: true
            }
        }
    }

    post {
        always {
            cleanWs()
        }
    }
}

Commit and push the Jenkinsfile:

git add Jenkinsfile && git commit -m "Add Jenkinsfile" && git push

3.4 Create the Jenkins pipeline job

  1. On the Jenkins dashboard, click New Item.
  2. Name it java-ci-demo, select Pipeline, and click OK.
  3. Under Build Triggers, check Poll SCM and enter H/5 * * * *. A GitHub webhook is the better option for production, but it requires a publicly reachable Jenkins URL.
  4. Under Pipeline, set Definition to Pipeline script from SCM.
  5. Set SCM to Git, enter your repository URL (https://github.com/<your-user>/java-ci-demo.git), and set Branch Specifier to */main.
  6. Keep Script Path as Jenkinsfile and click Save.
  7. Click Build Now and open the console output to follow the run.

Part 4: Trigger, observe, and break things (0:45 to 0:55)

Introduce failures on purpose and watch both systems catch them.

Create a feature branch:

git checkout -b feature/break-build

Make two changes:

  1. In CalculatorTest.java, change assertEquals(5, calc.add(2, 3)); to assertEquals(6, calc.add(2, 3));. This causes a test failure.
  2. In App.java, add a raw type inside main:
    java.util.List raw = new java.util.ArrayList();
    This causes a compiler warning, which -Werror turns into a build failure at the lint step.

Commit, push, and open a pull request:

git add . && git commit -m "Introduce failures" && git push -u origin feature/break-build

Observe:

Remove the raw type, fix the assertion, push again, and confirm both pipelines go green. Then merge the PR into main.

To see the test failure on its own, remove only the raw type and keep the wrong assertion. Lint passes, the test fails, and the JUnit report in both systems shows the failing add test.


Part 5: Recap and next steps (0:55 to 1:00)

Key concepts covered:

Suggested next steps:

  1. Add Checkstyle or SpotBugs for deeper static analysis, bound to the verify phase.
  2. Enforce a coverage minimum with the JaCoCo check goal.
  3. Build a container image with Jib or a Dockerfile and push it to Azure Container Registry or Docker Hub.
  4. Store secrets in GitHub Secrets and Jenkins Credentials, not in the repository.
  5. Add a deployment stage gated on the main branch, with a manual approval step in Jenkins.
  6. Use the Maven Docker image (maven:3.9-eclipse-temurin-17) as a Jenkins agent so each build runs in a clean container.

Troubleshooting quick reference

SymptomLikely cause and fix
[ERROR] warnings found and -Werror specifiedThe lint step caught a compiler warning. Read the warning in the log, fix the code, or remove the -Werror argument if the team agrees to allow warnings.
invalid target release: 17 or class file has wrong versionThe JDK on the agent doesn't match maven.compiler.release. Use a JDK 17 or later image, or change the release value.
mvn: not found in JenkinsRe-run the apt-get install step in section 3.2
JUnit report not shown in JenkinsConfirm the JUnit plugin is installed and the path is target/surefire-reports/*.xml
Jenkins can't clone the repoUse a public repo, or add a GitHub credential under Manage Jenkins, then Credentials
Dependencies download slowly in CIConfirm cache: maven is set in setup-java, and that the first run has completed to populate the cache
Workflow doesn't triggerCheck the branches: filter and confirm the file is in .github/workflows/
JaCoCo report missingConfirm the verify phase ran (not just test), since the report goal is bound to verify