View source on GitHub

Module 55 - CI/CD

What this module covers

A production CI/CD pipeline using GitHub Actions: build → test → JaCoCo coverage → SonarQube static analysis → Docker image build → (on tag) GitHub Release with semantic versioning. The application demonstrates semantic-version validation in service logic, tested with @ParameterizedTest + @ValueSource.


Project structure

module-55-ci-cd/
├── .github/
│   └── workflows/
│       ├── ci.yml        # push/PR pipeline: verify → scan → docker
│       └── release.yml   # tag pipeline: version bump → package → GitHub Release
├── pom.xml               # enforcer, JaCoCo, sonar-maven-plugin
└── src/
    ├── main/java/com/javatraining/cicd/
    │   ├── CicdApplication.java
    │   ├── GlobalExceptionHandler.java   # IllegalArgumentException → 400
    │   └── release/
    │       ├── Release.java              # JPA entity: id, name, version, releasedAt
    │       ├── ReleaseRepository.java
    │       ├── ReleaseService.java       # semver validation + CRUD
    │       └── ReleaseController.java    # POST /releases, GET /releases/{id}
    └── test/java/com/javatraining/cicd/release/
        ├── ReleaseServiceTest.java       # @ParameterizedTest, no Spring (12 tests)
        └── ReleaseControllerTest.java    # @SpringBootTest + MockMvc (2 tests)

CI workflow - ci.yml

on:
  push:
    branches: [main, "release/**"]
  pull_request:
    branches: [main]

jobs:
  build-test-scan:
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # full history for SonarQube blame

      - uses: actions/setup-java@v4
        with:
          java-version: "21"
          distribution: temurin
          cache: maven # caches ~/.m2/repository between runs

      - run: mvn --batch-mode verify # compile → test → JaCoCo report

      - name: SonarQube scan
        if: github.event_name == 'push' # skip on PRs from forks (no secrets)
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
        run: mvn --batch-mode sonar:sonar

      - uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: target/site/jacoco/

  docker:
    needs: build-test-scan
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: docker/build-push-action@v5
        with:
          push: false
          tags: |
            java-training/module-55:${{ github.sha }}
            java-training/module-55:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Design decisions

Decision Reason
fetch-depth: 0 on checkout SonarQube needs full git history for blame annotations (which commit introduced each line)
sonar:sonar guarded by event_name == 'push' Pull requests from forks cannot access repo secrets - running the step on PRs would silently fail or expose the token
cache: maven on setup-java Restores ~/.m2/repository from a GitHub Actions cache key; typically saves 1-3 minutes per run
docker job depends on build-test-scan Ensures broken builds never produce a Docker image
Docker layer cache with type=gha Reuses unchanged image layers across runs via GitHub Actions cache, not a registry push

Release workflow - release.yml

Triggered when a semantic version tag (v1.2.3 or v1.2.3-rc.1) is pushed:

on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"
      - "v[0-9]+.[0-9]+.[0-9]+-*"

steps:
  - name: Extract version
    run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

  - name: Set POM version
    run: mvn versions:set -DnewVersion=${{ steps.version.outputs.VERSION }} -DgenerateBackupPoms=false

  - name: Build
    run: mvn package -DskipTests

  - uses: softprops/action-gh-release@v2
    with:
      files: target/*.jar
      generate_release_notes: true
      prerelease: ${{ contains(github.ref_name, '-') }}

${GITHUB_REF_NAME#v} strips the leading v from the tag using Bash parameter expansion. mvn versions:set updates pom.xml without creating a backup POM. The release JAR is attached to the GitHub Release automatically. Pre-release tags (containing -) are marked accordingly.


Maven plugins

Maven Enforcer

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-enforcer-plugin</artifactId>
    <configuration>
        <rules>
            <requireJavaVersion><version>[21,)</version></requireJavaVersion>
            <requireMavenVersion><version>[3.9,)</version></requireMavenVersion>
        </rules>
    </configuration>
</plugin>

Enforcer runs at validate phase - the very first thing Maven does. A developer on Java 17 or a CI runner with an old Maven will fail immediately with a clear message, not halfway through compilation.

JaCoCo

<execution>
    <id>prepare-agent</id>
    <goals><goal>prepare-agent</goal></goals>    <!-- before test: instruments bytecode -->
</execution>
<execution>
    <id>report</id>
    <phase>verify</phase>
    <goals><goal>report</goal></goals>           <!-- after test: writes target/site/jacoco/ -->
</execution>

mvn verify generates target/site/jacoco/jacoco.xml. SonarQube reads this file (configured via <sonar.coverage.jacoco.xmlReportPaths>) to display line and branch coverage in the SonarQube dashboard.

SonarQube

<!-- pom.xml <properties> -->
<sonar.projectKey>java-training_module-55</sonar.projectKey>
<sonar.coverage.jacoco.xmlReportPaths>target/site/jacoco/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths>

mvn sonar:sonar is NOT bound to a lifecycle phase - it runs only when explicitly invoked. This prevents accidental scans on mvn package and keeps local builds fast.


Semantic versioning in application code

ReleaseService enforces semver on the version field before persisting:

private static final Pattern SEMVER = Pattern.compile("\\d+\\.\\d+\\.\\d+(-[\\w.]+)?");

public Release create(Release release) {
    if (release.getVersion() == null || !SEMVER.matcher(release.getVersion()).matches()) {
        throw new IllegalArgumentException(
                "Version must follow semantic versioning (e.g. 1.2.3 or 1.2.3-rc.1): "
                + release.getVersion());
    }
    return releaseRepository.save(release);
}

GlobalExceptionHandler maps IllegalArgumentException400 Bad Request.


Tests

Parameterized unit tests - ReleaseServiceTest

@ParameterizedTest
@ValueSource(strings = {"1.0.0", "2.3.4", "10.20.30", "1.0.0-rc.1", "3.14.0-beta"})
void create_accepts_valid_semantic_versions(String version) { ... }

@ParameterizedTest
@ValueSource(strings = {"1.0", "1", "v1.0.0", "1.0.0.0", "not-a-version", "", "1.0.0-"})
void create_rejects_invalid_semantic_versions(String version) { ... }

@ParameterizedTest with @ValueSource expands to one test per value - boundary cases are checked exhaustively without writing a separate @Test method per case. The test method name appears in the report as create_accepts_valid_semantic_versions(1.0.0) etc.

Integration tests - ReleaseControllerTest

@Test
void post_with_valid_semver_returns_201_with_location()

@Test
void post_with_invalid_semver_returns_400()
Class Type Tests
ReleaseServiceTest Unit, @ParameterizedTest 12
ReleaseControllerTest @SpringBootTest + MockMvc 2
Total   14

Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test Result: 14/14 pass


Key decisions

Decision Reason
@ParameterizedTest + @ValueSource over individual @Test methods Covers more boundary cases with less code; each value appears as a separate test in CI reports
sonar:sonar not bound to a lifecycle phase Prevents scans on every local mvn verify; CI runs it explicitly after verify
fetch-depth: 0 in CI checkout SonarQube blame requires full git history; shallow clones break the annotation feature
Semver regex in service, not controller Business rule belongs in the service layer; controller stays thin and delegates validation
prerelease: ${{ contains(github.ref_name, '-') }} Tags with - (e.g., -rc.1, -beta) are pre-releases by convention; the expression detects this automatically