Centralized CI/CD for Java / Spring Boot (Maven) services.
This repository is a reusable GitHub Actions template. Every pipeline stage is a
self-contained reusable workflow
(on: workflow_call), and they are all orchestrated by one master workflow. A consumer
repo makes a single call and gets build, lint, unit + integration tests, multi-layer
security scanning (SAST + secrets + dependency + image), SBOM generation, and signed
container publishing — on hardened, egress-controlled runners.
Add one thin workflow to the downstream Java repo. It delegates everything to the master pipeline:
# .github/workflows/ci.yml (in your Spring Boot repo)
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
# A reusable workflow can only use permissions the *caller* grants. Set these at the
# top level so the pipeline can tag, push to GHCR, upload CodeQL results, and — most
# importantly — mint an OIDC token for keyless image signing (id-token).
permissions:
contents: write # PR build tags
packages: write # push image to GHCR
security-events: write # CodeQL SARIF upload
id-token: write # cosign keyless signing (REQUIRED — defaults to none)
actions: read
checks: write
pull-requests: write
jobs:
pipeline:
uses: pse-wtag/java-ci-cd-template/.github/workflows/master-maven-pipeline.yml@main
secrets: inherit
with:
java-version: "25"That's the whole integration. Everything below is driven from that one call.
Do not add a
concurrency:block to your caller. The master pipeline already groups by${{ github.workflow }}-${{ github.ref }}(PR runs cancel superseded runs;mainruns always finish). If you must add one, give it a different group — otherwise the caller and the reusable workflow share a group and cancel each other.
id-token: writeis not optional if you want signed images. Unlike the other permissions it defaults tononeeven when the repo token is "read and write", so it must be granted explicitly. Without it,docker-publishstill builds and pushes, but the cosign signing step fails.
-
Grant the permissions above (especially
id-token: write). -
Enable Code Scanning (Settings → Security → Code scanning) so CodeQL runs — the stage self-skips cleanly when it's disabled.
-
Add a Maven Dependabot config to your repo (this template ships only the
github-actionshalf). Drop this into.github/dependabot.yml:version: 2 updates: - package-ecosystem: maven directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: "/" schedule: interval: weekly
-
Verify signatures on deploy (or in an admission controller) with cosign:
cosign verify ghcr.io/<org>/<repo>@<digest> \ --certificate-identity-regexp "https://github.com/<org>/<repo>/.github/workflows/.+" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com
Forking into another org? Update the
uses:path in the caller and thepse-wtag/java-ci-cd-template/...@v0.0.3composite-action references inside the reusable workflows to point at your fork.
The master pipeline runs three sequential stages plus a final gate. Each stage is a reusable workflow that fans its jobs out in parallel internally:
buildruns first, alone.- When it passes,
verifyruns — its four jobs (lint, unit-tests, integration-tests, security) all start at once. - When all of verify passes,
releaseruns —taganddocker-publishrun in parallel; on a push tomain,verify-imagethen re-checks the pushed image's cosign signature before the stage is allowed to succeed. - On a push to
main, oncereleasesucceeds,auto-releasecuts the version tag and GitHub Release. Itneeds: release, so a failed build/verify/release never produces a release. Skipped on pull requests. build-gatewaits on all stages and is the single required status check.
auto-release is orchestrated inside the master pipeline (gated on release) — it
is no longer a standalone on: push workflow, so a failing pipeline cannot trigger it.
flowchart TD
T([Trigger: PR or push to main]):::trig
T --> B["<b>1 · build</b><br/>reject .env · compile & package"]:::s1
subgraph P2 ["2 · verify — all run in parallel"]
direction LR
L["lint<br/>Spotless"]:::s2
U["unit-tests<br/>+ JUnit report"]:::s2
I["integration-tests<br/>+ JUnit report"]:::s2
S["security<br/>CodeQL · Gitleaks · Trivy SCA"]:::s3
end
subgraph P3 ["3 · release"]
direction LR
TG["tag<br/>PR → main only"]:::s4
D["docker-publish<br/>build · Trivy · SBOM · push · cosign"]:::s4
V["verify-image<br/>cosign verify · push→main only"]:::s4
D --> V
end
AR["<b>4 · auto-release</b><br/>SemVer tag + GitHub Release<br/>push → main only"]:::ind
G["<b>5 · build-gate</b><br/>required status check"]:::s5
B --> P2
P2 --> P3
P3 --> AR
P3 --> G
AR --> G
B -. gate needs all stages .-> G
classDef trig fill:#f1f5f9,stroke:#334155,color:#0b1324;
classDef s1 fill:#dbeafe,stroke:#1d4ed8,color:#0b1324;
classDef s2 fill:#d1fae5,stroke:#047857,color:#0b1324;
classDef s3 fill:#fee2e2,stroke:#b91c1c,color:#0b1324;
classDef s4 fill:#ede9fe,stroke:#6d28d9,color:#0b1324;
classDef s5 fill:#fef9c3,stroke:#a16207,color:#0b1324;
classDef ind fill:#e5e7eb,stroke:#6b7280,color:#0b1324;
The whole of stage 2 gates stage 3.
releasedoesneeds: verify, so bothtaganddocker-publishwait for all four verify jobs (lint included) to finish.build-gatedoesneeds: [build, verify, release, auto-release]and fails the run if any of them failed or was cancelled (auto-releaseis skipped on PRs, which is not a failure).
- Pull request →
main:build → { lint · unit · integration · security } → { tag · docker-build (no push) } → build-gate. No image is pushed or signed. - Push →
main:build → { lint · unit · integration · security } → docker-publish (image → GHCR, scanned + signed) → verify-image (cosign verify) → auto-release (tag + GitHub Release) → build-gate.tagis skipped (PR-only), andauto-releaseruns only afterreleasesucceeds.
| # | Job | Workflow | Runs when | What it does |
|---|---|---|---|---|
| 1 | build | build.yml |
always | Rejects tracked .env files, compiles & packages (clean package -DskipTests), submits the dependency graph on push. |
| 2 | lint | lint.yml |
always | Verifies code formatting with Spotless (spotless:check). |
| 2 | unit-tests | unit-tests.yml |
always | Runs mvn test and publishes a JUnit report. |
| 2 | integration-tests | integration-tests.yml |
always | Optionally exposes curated extra-secrets (never GITHUB_TOKEN), runs mvn verify -Dsurefire.skip=true, publishes a JUnit report. |
| 2 | security | security.yml |
always | Three parallel scanners — CodeQL SAST (java-kotlin, self-skips when Code Scanning is off), Gitleaks secret scan, and Trivy dependency (SCA) scan that fails on fixable HIGH/CRITICAL CVEs. The Trivy vulnerability DB is cached across runs (actions/cache). |
| 3 | tag | tag.yml |
PR → main |
Tags the PR build (pr-<n>-run-<run>) and prunes old PR tags (keeps the latest 4). |
| 3 | docker-publish | docker.yml |
push, or same-repo PR (pushes only on push) |
Builds an OCI image via Spring Boot Buildpacks, Trivy-scans it (fails on fixable HIGH/CRITICAL; vuln DB cached via actions/cache), emits a CycloneDX SBOM artifact, pushes to GHCR, signs the pushed image with cosign (keyless/OIDC), then prunes old images (keeps the latest 3). |
| 3 | verify-image | release.yml |
push → main |
Runs cosign verify against the exact digest docker-publish pushed and signed (not a mutable tag) — asserting a keyless signature whose certificate identity matches signer-identity-regexp and whose OIDC issuer is GitHub Actions. Fails the release stage if the signature is missing or untrusted. |
| 4 | auto-release | auto-release.yml |
push → main, after release succeeds |
Bumps a SemVer patch tag, creates a GitHub Release with notes, keeps the latest 10. Gated on release, so a failed pipeline never cuts a release. |
| 5 | build-gate | inline | always() |
Fails the run if any of build / verify / release / auto-release failed or was cancelled. The single required status check. |
All inputs pass through master-maven-pipeline.yml. The most useful ones:
| Input | Default | Purpose |
|---|---|---|
java-version |
"25" |
JDK version for every stage. |
cache-type |
"maven" |
Dependency-manager cache key for setup-java. |
build-egress-policy |
"block" |
Harden-Runner egress policy for build / verify (block = enforce allowlists, audit = log-only). |
release-egress-policy |
"block" |
Separate Harden-Runner egress policy for the release phase (docker publish + image verify). Split out from build so the broader registry set that buildpacks reach can be tuned independently. |
*-allowed-endpoints |
see file | Per-stage egress allowlists (build / test / security / docker). |
extra-*-endpoints |
"" |
Append hosts without overriding the defaults (extra-build-endpoints, extra-test-endpoints, extra-security-endpoints, extra-docker-endpoints). |
signer-identity-regexp |
"" |
Certificate-identity regexp verify-image requires of the image's keyless cosign signature. Empty defaults to ^https://github.com/<owner>/ (any workflow under the repo owner). Set it to the specific signing-workflow path for a tighter trust boundary. |
test-args |
"" |
Extra args for integration tests (e.g. -D properties). |
spring-boot-args |
"" |
Extra args for the Spring Boot image build. |
Egress hardening defaults to
block— the allowlists are enforced. Watch the first run's harden-runner summary and append any denied host via theextra-*-endpointsinputs. Setbuild-egress-policy: auditto switch to log-only while you tune them.
The consumer passes secrets with secrets: inherit; the master pipeline forwards only
what each stage needs:
CR_PAT— GHCR push / cleanup token, forwarded explicitly todocker-publish(falls back togithub.tokenwhen unset). Not broadcast to other stages.extra-secrets— optional curated JSON object ({"NAME":"value",...}) exposed as env vars to the integration tests only. Pass just what a test needs against a real external system; prefer Testcontainers so tests need no secrets at all. Never passtoJSON(secrets).
.github/
├── workflows/
│ ├── master-maven-pipeline.yml # Orchestrator — the entry point consumers call
│ ├── build.yml # Reject .env, compile & package, submit dep graph
│ ├── verify.yml # Fan-out wrapper: lint + tests + security
│ ├── lint.yml # Spotless formatting check
│ ├── unit-tests.yml # Unit tests + JUnit report
│ ├── integration-tests.yml # Integration tests + JUnit report
│ ├── security.yml # CodeQL (SAST) + Gitleaks (secrets) + Trivy (SCA)
│ ├── release.yml # Fan-out wrapper: tag + docker-publish
│ ├── tag.yml # PR build tagging
│ ├── docker.yml # Buildpack image → Trivy → SBOM → GHCR → cosign
│ └── auto-release.yml # SemVer tag + GitHub Release (pipeline stage, gated on release)
├── actions/
│ ├── java-setup/ # Composite: Temurin JDK + Maven cache + MAVEN_OPTS
│ └── ghcr-cleanup/ # Composite: GHCR image retention (with retry)
├── dependabot.yml # Weekly github-actions pin bumps
└── CODEOWNERS # Default reviewers for every PR
.githook/
├── pre-commit # Local gitleaks secret scan on staged changes
└── pre-push # actionlint + yamllint when YAML changes are pushed
java-setup— installs the Temurin JDK (default 25), enables Maven dependency caching, and injects tunedMAVEN_OPTS(G1GC, RAM %, string dedup, capped metaspace).ghcr-cleanup— applies a retention policy to GHCR images (keeps the latest 3) with a built-in 60s retry. The owningaccounttype (user/org) is configurable.
Enable them once per clone — make the hooks executable, then point git at .githook:
chmod +x .githook/pre-commit .githook/pre-push
git config core.hooksPath .githookpre-commit— runsgitleaks protect --stagedto block secrets before they commit.pre-push— when pushed commits touch*.yml/*.yaml, runsactionlintandyamllintso broken workflow definitions never reach GitHub.
All three tools degrade gracefully if missing (the hook warns and skips), but install them for full protection:
# macOS / Linux (Homebrew)
brew install gitleaks actionlint yamllint
# Linux (native)
pip install yamllint
go install github.com/rhysd/actionlint/cmd/actionlint@latest
# gitleaks: grab a binary from https://github.com/gitleaks/gitleaks/releases
# Windows (Chocolatey)
choco install gitleaks actionlint
pip install yamllint # not on Chocolatey- Hardened runners — every job that executes real work runs
step-security/harden-runnerwith a configurable egress policy and per-stage endpoint allowlists. Build/verify and release each carry their own policy (build-egress-policy/release-egress-policy), both defaulting toblock. - Pinned actions — third-party actions are pinned to full commit SHAs; Dependabot keeps them current weekly.
- Secret scanning — Gitleaks in CI (
security.yml) and locally (pre-commit). - Static analysis (SAST) — CodeQL for
java-kotlin. - Dependency scanning (SCA) — Trivy flags known CVEs in declared/transitive deps;
the build fails on fixable HIGH/CRITICAL (
ignore-unfixedskips un-actionable ones). - Image scanning — the built OCI image is Trivy-scanned before it ships, on PRs too.
- Cached vuln DB — both Trivy scans cache the vulnerability database via
actions/cache(keyed per day, with a shared restore fallback) to cut scan time and avoid rate limits. - Supply-chain provenance — a CycloneDX SBOM per build, uploaded as an artifact
and attested to the pushed digest with cosign (
cosign attest --type cyclonedx), so the SBOM is cryptographically bound to the image (cosign verify-attestationdownstream). Pushed images are also signed with cosign using keyless OIDC (no long-lived keys). - Signature verification (verify-then-trust) — on push to
main,verify-imagerunscosign verifyagainst the exact digest that was pushed and signed (not the mutable tag), asserting a signature whose certificate identity matchessigner-identity-regexpand whose issuer is GitHub Actions. A missing or untrusted signature fails the release. Downstream deploys should re-run the same check at admission time (see the consumer checklist) rather than trusting the tag alone. .envguard — the build fails if tracked.envfiles are detected.- Least privilege — permissions are scoped per job, not globally;
docker-publishreceives onlyCR_PAT, not the whole secret store.