CI/CD with GitHub Actions for Java Projects

Picture this. Your colleague opens a pull request on Friday afternoon: a small refactor, "just" renaming a few methods. The PR description says "tested locally, all green". A reviewer glances at the diff, it looks sane, and hits merge. Monday morning, three developers pull main and nothing compiles. The refactor renamed a method that a second module calls through an interface — a call site their IDE’s rename didn’t touch because it lives in a different Maven module, which they never rebuilt locally. The full test suite takes six minutes; they ran only the module they touched. Now the whole team is debugging a red main instead of shipping.

This is the failure mode CI exists to kill. "It passed on my laptop" is not evidence — it’s a sample size of one, on one machine, with one set of local state. Continuous Integration is the discipline of rebuilding and retesting every change on a clean, shared, identical machine, automatically, before the change is allowed anywhere near main. Continuous Delivery extends that: once the build is green, the artifact is packaged and ready to deploy — no "works on my machine, fails in staging" surprises.

GitHub Actions is the most common CI system Java teams reach for in 2026, because the code, the pipeline, and the pull requests live in one place. This post builds a complete, working CI pipeline for a Maven Java project from scratch: one minimal workflow, dependency caching, matrix builds across Java versions, quality gates, nightly load tests, artifacts, secrets, and branch protection — plus the four traps that break CI pipelines in practice.

You will need: a GitHub repository containing a Maven Java project (any of this track’s examples will do), and familiarity with running mvn verify locally — the Maven vs Gradle: Builds Demystified post covers the build tool itself, so here we focus on the pipeline around it.

The anatomy of an Actions workflow

A GitHub Actions workflow is a YAML file in .github/workflows/ of your repo. When a configured event fires, GitHub starts the workflow on a fresh virtual machine called a runner. The workflow contains one or more jobs; each job is a sequence of steps. A step is either a run: shell command or a uses: reference to a reusable action — a packaged unit of CI logic maintained by GitHub or the community.

Here is a complete, minimal, working CI workflow for a Maven Java project. Save it as .github/workflows/ci.yml:

name: ci

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

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

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

      - name: Build and test
        run: mvn -B verify

Line by line, what each part does:

  • name: ci — the workflow’s display name in the Actions tab. Keep it short; it shows up on every PR.
  • "on": — the events that trigger the workflow. Here: any push to the main branch, and any pull_request activity. (The quotes matter: in YAML, a bare on is parsed as boolean true. Quoted, it’s the event key. Every Actions file you’ll ever see does this.)
  • jobs: — the work to do. A workflow can run multiple jobs in parallel; this one has a single job named build.
  • runs-on: ubuntu-latest — which runner image to use. GitHub-hosted runners are fresh VMs; ubuntu-latest is the cheapest, fastest, and most common choice for Java builds. Windows and macOS runners exist for when you truly need them.
  • actions/checkout@v4 — clones your repository into the runner. Nothing else can run without this; almost every workflow starts here.
  • actions/setup-java@v4 — installs the JDK. distribution: temurin picks the Eclipse Temurin build (the same vendor this track recommends for your laptop — consistency between local and CI avoids the "but it worked here" class of bugs), java-version: 25 picks the current LTS, and cache: maven turns on dependency caching, covered in the next section.
  • run: mvn -B verify — the actual build. -B is batch mode: Maven never prompts for input, because there is no human at the terminal in CI — without it, a stray interactive prompt hangs the job until it times out. verify runs the full lifecycle: compile, unit tests, integration tests, and packaging. Not test (skips packaging and integration tests), not compile (skips tests entirely).
git push or pull request event ci workflow .yml file spawns runner: ubuntu-latest fresh VM, every run 1. checkout 2. setup-java 3. mvn -B verify green: mergeable red: fix, don’t merge trigger definition clean-room execution verdict

Decision rule: CI’s value comes from the clean room. Every run starts from a fresh VM with nothing cached except what you explicitly cache — no leftover build artifacts, no half-installed dependencies, no "oh, I forgot I had that set locally". The first thing a CI pipeline buys you is reproducibility; the tests are second.

Caching: don’t re-download the internet on every build

The first time your workflow runs, Maven downloads every dependency — Spring Boot, JUnit, Jackson, and their transitive friends — into ~/.m2 on the runner. That’s a few hundred megabytes. On a fresh VM, it happens on every single run unless you cache it, and builds stretch to 8–12 minutes of pure downloading.

The cache: maven line in setup-java fixes this with one word: it saves ~/.m2 between runs, keyed on a hash of your pom.xml. Dependencies are only re-downloaded when your dependency list actually changes — the overwhelmingly common case is a cache hit, and the build starts compiling in seconds. The Gradle equivalent is cache: gradle.

A build with a warm cache typically looks like this in the Actions log:

Cache restored from key: setup-java-Linux-maven-9a4f2c...
...
[INFO] Building shop-api 1.0.0
[INFO] --- maven-compiler-plugin:3.13.0:compile (default-compile) ---
[INFO] Changes detected - recompiling the module! :source
[INFO] Compiling 47 source files with javac [debug release 25] to target/classes
[INFO] --- maven-surefire-plugin:3.2.5:test (default-test) ---
[INFO] Tests run: 132, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time:  01:24 min

Compare that to the cold-cache version, where the same run spends its first six minutes printing Downloading from central: https://repo.maven.apache.org/... hundreds of times. Decision rule: if your CI builds are slow, check the cache before you reach for faster runners. Missing dependency caching is the single most common reason Java builds take ten minutes in CI and ninety seconds locally.

Matrix builds: one workflow, every environment you support

Your laptop runs Temurin 25 on macOS. Production runs Temurin 21 on Linux. A contributor opens a PR built on Windows with Java 25. A pipeline that only tests your combination silently approves code that breaks everyone else’s — the classic example is a test that assumes File.separator is /, or code that depends on a garbage-collector default that changed between Java 21 and 25.

A matrix runs the same job once per combination of variables. One compact block turns your single build job into four parallel jobs:

name: ci

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

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        java: [21, 25]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: ${{ matrix.java }}
          cache: maven
      - name: Build and test
        run: mvn -B verify
build job matrix: os x java ubuntu-latest, Java 21 ✓ green ubuntu-latest, Java 25 ✓ green windows-latest, Java 21 ✓ green 4 parallel jobs, one verdict: the PR is green only if ALL four pass (windows + Java 25 omitted for space — same idea)

The ${{ ... }} syntax is an expression: GitHub evaluates it before the job starts, so runs-on: ${{ matrix.os }} and java-version: ${{ matrix.java }} pick up each combination. In the Actions UI, each run appears as build (ubuntu-latest, 21), build (ubuntu-latest, 25), and so on — and the PR shows one combined status that stays yellow until all four finish green. That’s the point: the merge button stays honest.

Decision rule: matrix-test the combinations you support, not every combination that exists. Java 21 + 25 covers the two LTS releases your users run; adding 17, 23, macOS, and three more JDK vendors gives you 24 jobs of mostly-identical signal and a CI bill to match. Start with what you promise, expand when someone asks.

Add a quality-gate job

Tests prove your code does what you think. Static analysis proves your code doesn’t contain what you didn’t think — dead stores, null dereferences, style violations the team agreed on. The track’s Static Analysis for Java: SpotBugs, Checkstyle & SonarQube post covers the tools themselves; in CI, they become a separate job that fails the build when the code degrades:

  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 25
          cache: maven
      - name: Static analysis
        run: mvn -B verify -Panalysis

Two deliberate choices here. First, the quality job is separate from the build job, so it runs in parallel — your test feedback doesn’t wait for the linter, and vice versa. Second, the checks live behind a Maven profile (-Panalysis), so a developer iterating locally with mvn verify doesn’t pay for the full analysis suite on every save; CI always pays for it, because CI is the gate.

Load tests belong on a schedule, not on every PR

A load test takes minutes, needs a realistic staging environment, and produces noisy numbers — run it on every pull request and you get slow builds, flaky red PRs that train developers to ignore CI, and a cloud bill for hammering staging 40 times a day. Load tests are trend instruments, not per-change instruments: run them nightly, compare tonight’s numbers against last week’s, and investigate regressions in the morning.

GitHub Actions schedules workflows with cron syntax. A nightly load-test job using the Gatling setup from Load Testing with Gatling: Prove Your API Survives Traffic looks like this:

name: nightly-load-test

"on":
  schedule:
    - cron: "0 3 * * *"   # 3am UTC, when nobody is deploying

jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 25
          cache: maven
      - name: Run load test against staging
        run: mvn -B gatling:test -Dgatling.simulationClass=com.acme.perf.ProductApiSimulation
      - name: Keep the HTML report
        uses: actions/upload-artifact@v4
        with:
          name: gatling-report
          path: target/gatling/*/

Note that this workflow lives in its own file with its own trigger — schedule instead of push/pull_request. The cron string is in UTC ("0 3 * * *" is 3:00 AM UTC daily), and scheduled runs always execute the main branch. The report is uploaded as an artifact so the morning investigation starts from the HTML report, not from a raw log.

Decision rule: per-PR checks answer "is this change correct?"; scheduled checks answer "is the system still healthy?". Mixing them means slow PRs and weak trends. Give each its own workflow and its own cadence.

Nightly benchmarks: catching the slow creep

The same nightly pattern carries one more job: microbenchmarks. JMH: Microbenchmarking Done Right shows how to write benchmarks that measure what you think they measure; running them nightly turns a one-off experiment into a regression detector. A 3% slowdown in a hot method is invisible in any single benchmark run, but unmistakable as a slope across thirty nightly runs. The job is cheap — reuse the same schedule trigger, run the JMH suite, upload the results as an artifact, and let the trend line do the talking.

Testcontainers just work — Docker is on the runners

If the words "integration tests need a database" made you nervous about CI, relax: GitHub-hosted ubuntu-latest runners ship with Docker preinstalled. The Testcontainers tests from Testcontainers: Real Databases in Tests — the ones that spin up a real Postgres in a container during the test run — execute in CI exactly as they do on your laptop, with zero extra workflow configuration. Testcontainers detects the Docker daemon, pulls the image on first use (warm the cache and it’s fast), and cleans up after itself.

This is a bigger deal than it sounds. The historical alternative was maintaining a shared "CI database" service that every build fought over — flaky, stateful, and a constant source of "the tests failed but nobody changed anything". Containerized databases per test run made that pattern obsolete; CI runners with Docker built in made it effortless. Decision rule: if a test needs infrastructure, let the test provision it (Testcontainers), not the pipeline. The pipeline stays dumb and the tests stay portable.

Artifacts, secrets, and branch protection: closing the loop

A green build that leaves nothing behind is only half useful. Three finishing touches turn a build script into a real delivery pipeline.

Artifacts. When mvn verify finishes, the useful outputs — the built jar, the Surefire test reports, the Gatling HTML report — live on a VM that GitHub deletes within minutes. actions/upload-artifact@v4 preserves them: each artifact is downloadable from the workflow run’s page and expires automatically (90 days by default; set retention-days lower for bulky reports). A typical build job ends with:

      - name: Keep the jar
        uses: actions/upload-artifact@v4
        with:
          name: shop-api-jar
          path: target/shop-api-*.jar
      - name: Keep the test reports
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: surefire-reports
          path: target/surefire-reports/

The if: failure() line is a small kindness with a big payoff: test reports are uploaded only when the build fails — precisely when someone needs them. Nobody digs through 300 green runs’ reports; everybody digs through the red one’s.

Secrets. Sooner or later a step needs a credential — deploying to a staging server, pushing an image to a registry, calling a paid API in an integration test. GitHub provides GITHUB_TOKEN automatically (it can read your repo and write artifacts), and anything else goes in repository secrets (Settings → Secrets and variables → Actions), referenced as ${{ secrets.DEPLOY_KEY }}. The iron rule: never print a secret. GitHub masks exact secret values in logs, but only exact matches — a base64-encoded, truncated, or concatenated secret sails right through the mask and into the permanent log. Don’t echo it, don’t pass it via -D flags that Maven prints in debug output, and treat any accidental leak as a rotation event: the secret is compromised the moment it hits a log, masking or not.

Branch protection. All of this machinery is advisory until you make it mandatory. In Settings → Branches → Add branch protection rule, require the CI status checks to pass before merging: select your build job (and quality), and optionally require at least one human review. From that moment, the merge button on a red PR is disabled — the Monday-morning scenario from the opening paragraph becomes structurally impossible instead of merely unlikely. Decision rule: an unenforced pipeline is documentation; an enforced pipeline is engineering. Flip the switch the day the workflow goes green.

pull request opened / updated build ✓ tests green quality ✓ analysis clean artifacts saved jar + reports merge allowed protection rule both must be green — in parallel red check = merge button disabled

When it breaks: 4 CI traps

Trap 1 — No dependency caching: the ten-minute build

Symptom: every build takes 8–12 minutes, and the log’s first several hundred lines are Downloading from central. Locally the same build takes ninety seconds. Diagnosis: the workflow installs the JDK but never caches ~/.m2, so each fresh runner re-downloads the entire dependency tree. Fix: add cache: maven (or cache: gradle) to the setup-java step — one line, and the cold-download phase disappears on every run after the first. If builds are still slow after that, check whether the cache key actually hits: a pom.xml that changes on every commit (a timestamp in a version string, say) defeats the cache by design.

Trap 2 — Secrets printed into the logs

Symptom: a deploy credential appears in plain text in a workflow log — which is permanent, searchable, and visible to anyone with repo access. Diagnosis: someone echoed a secret for debugging, or passed it as a Maven -D property that Surefire printed in a failure dump, or concatenated it into a URL that got logged. GitHub’s masking only redacts exact secret values, so any transformation slips through. Fix: reference secrets only through ${{ secrets.NAME }}, never interpolate them into strings that get printed, and run a one-time audit of existing logs. Then rotate the leaked secret immediately — a masked-after-the-fact log doesn’t un-expose it, and assuming otherwise is how breaches happen quietly.

Trap 3 — pull_request vs push: the PR that never runs CI

Symptom: a contributor’s pull request shows no checks at all — not red, not yellow, just absent — and the merge button can’t be enabled because the required check never reports. Diagnosis: the workflow triggers only on push. PRs from forks don’t push to your repo — they push to the contributor’s fork and open a PR against yours — so no push event fires in your repository and the workflow never starts. Fix: trigger on pull_request as well as push. The mirror-image mistake is triggering on pull_request only and wondering why direct pushes to main skip CI; most projects want both. (One caution: pull_request_target runs in the context of your base branch with access to secrets — useful for commenting on PRs, dangerous if it also checks out untrusted fork code. Stick with plain pull_request unless you know exactly why you need the other one.)

Trap 4 — Floating action versions: green last night, red this morning

Symptom: the build breaks overnight with zero code changes — the diff between the last green run and the first red run is empty. Diagnosis: a step pins an action to a floating ref like @latest, @master, or even a bare branch name, and the action’s maintainer ships a breaking change. Your pipeline silently upgraded underneath you. Fix: pin every uses: to at least a major version tag (actions/checkout@v4, actions/setup-java@v4) — major tags receive backward-compatible fixes but not breaking rewrites. For maximum determinism, pin to the full commit SHA (actions/checkout@c2f74d6948dd... # v4.2.2); the comment keeps it human-readable. Decision rule: major-version pins for everyday pipelines, SHA pins for release-critical ones, floating refs never.

What’s next

You now have the full CI/CD skeleton: a triggered workflow that builds and tests on a clean runner, dependency caching that keeps it fast, a matrix that proves your code works across the Java versions and operating systems you support, quality gates that fail the build on bad code, nightly load tests and benchmarks that watch the trends, artifacts that preserve the evidence, secrets that stay secret, and a branch protection rule that makes the whole thing mandatory. The opening scenario — a red main on Monday morning — is now something that happens to other teams.

Field check before you move on: take a real Maven Java project, add the minimal ci.yml from this post, push it to a GitHub repo, and watch the Actions tab. Then deliberately break something small — change a test assertion to something false — push again, and confirm the PR goes red. Finally, enable the branch protection rule requiring the build check. If you can make the pipeline go green, then red, then green again, and the merge button obeys the color, you understand CI.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number