Keeping a large Java monorepo healthy is not difficult because of Java itself. It becomes difficult because most CI pipelines do not scale with the number of independent projects inside the repository.
If you have dozens of standalone Maven projects in a single GitHub repository, a naïve CI setup either wastes resources by rebuilding everything or becomes unmaintainable due to hard-coded path filters and duplicated workflows.
This article explains how to design a single GitHub Actions workflow that builds only what changed, supports mixed Java versions, and still gives you per-project visibility—without fragmenting your CI setup.
The Core Problem
A monorepo with many independent Java projects has three recurring pain points:
- Every push triggers too many builds.
- Different projects require different Java versions.
- GitHub exposes only one build status badge per workflow.
Solving only one of these usually makes the others worse. The approach below addresses all three together.
Architectural Approach
The design is centered around dynamic matrix generation in GitHub Actions.
Instead of defining the matrix statically in YAML, the workflow generates it at runtime based on repository changes. The pipeline is split into four logical stages:
- Detect which projects changed and extract their Java versions.
- Build those projects in parallel using a dynamic matrix.
- Generate per-project build status data.
- Publish that data in a way external tools can consume.
Each stage is isolated but feeds into the next.
Detecting Changed Projects and Java Versions
The first job inspects the repository state and produces a JSON matrix.
For normal pushes, it uses git diff against the previous commit to find touched directories. For manual runs, it can scan the entire repository. Only folders containing a pom.xml are considered buildable projects.
Each pom.xml is then parsed to extract the Java version:
JAVA_VERSION=$(grep -oP '(?<=<maven.compiler.release>).*?(?=</maven.compiler.release>)' "$dir/pom.xml")
If no version is found, or if it resolves to a variable or legacy value, the workflow falls back to a default Java version.
The output is a structured matrix like this:
[
{ "path": "project-a", "java": "21" },
{ "path": "legacy-demo", "java": "17" }
]
This matrix becomes the single source of truth for the rest of the workflow.
Building with a Polymorphic Matrix
The build job consumes the generated JSON and creates one job per project. Each job sets up the correct JDK dynamically:
- uses: actions/setup-java@v4
with:
java-version: ${{ matrix.project.java }}
distribution: temurin
This allows Java 17 and Java 21 builds to run side by side in the same workflow execution. Builds are isolated, run in parallel, and do not block each other if one fails.
The key trade-off here is complexity in the detection script in exchange for a much simpler and more scalable build stage.
Per-Project Build Status Without Multiple Workflows
GitHub Actions exposes only one status badge per workflow file. For a monorepo, that is rarely sufficient.
Instead of relying on GitHub’s built-in badges, each build generates a small JSON file describing its result:
{ "schemaVersion": 1, "label": "build", "message": "passing", "color": "green" }
These files are uploaded as artifacts and later collected into a dedicated orphan branch (for example, badges). Each file becomes a stable endpoint that tools like Shields.io can consume.
This decouples build execution from status presentation and avoids the need for dozens of workflows.
Verifying the Outcome
With this setup in place:
- Only changed projects are built.
- Each project runs with its required Java version.
- Build failures are isolated.
- External tools can show per-project status without additional CI logic.
The workflow remains a single file, regardless of how many projects the repository contains.
Production Notes
A few practical considerations if you adopt this pattern:
- Keep the detection script simple and deterministic. It becomes critical infrastructure.
- Define a clear default Java version and enforce it consistently.
- Treat the badges branch as generated output; never edit it manually.
- Disable
fail-fastin the matrix to avoid masking unrelated failures.
This approach scales well, but it assumes disciplined repository structure and consistent Maven metadata.
Further Reading
This article is part of The Main Thread, a publication focused on modern Java architecture, real-world systems, and production-grade engineering.
Read the full version here:
https://www.the-main-thread.com/p/java-monorepo-dynamic-builds-github-actions-automationBecause modern Java deserves better content.

Top comments (0)