Level Up Your Java CI: A Fully Automated Monorepo Pipeline with GitHub Actions
Dynamic builds, mixed Java versions, and auto-generated badging for 50+ Quarkus examples
Managing a GitHub repository with more than fifty independent Java and Quarkus projects is rarely smooth. My repository, ejq_substack_articles, is exactly that: a flat monorepo where each folder represents a standalone application that accompanies my tutorials on The Main Thread.
Every project includes its own pom.xml, dependencies, Java version, and Maven configuration. The challenge was simple to describe but difficult to solve:
build only what changed
use the correct Java version per project without hardcoding anything
publish status badges for each project
and update the README automatically
I needed a self-maintaining system. So I built one.
What follows is the complete architecture: a single GitHub Action that detects changes across the repo, builds only what matters, supports mixed Java versions, generates per-project Shields.io badges, and injects an always-up-to-date build table into the README.
This is the “Smart Matrix” workflow.
The Architecture
The workflow is organized into four logical stages:
Detect and parse changed projects.
Build projects using a runtime-generated matrix that includes the required Java version.
Generate per-project build badges.
Update the README with a live Markdown table.
All of this happens inside one YAML file.
Intelligent Project Detection
Hardcoding 100+ folders into a workflow is not an option. Instead, a shell script determines exactly which projects changed using git diff for regular pushes, or a full scan when manually triggered via workflow_dispatch.
The key feature is version detection.
The script reads each project’s pom.xml and extracts its <maven.compiler.release> value:
JAVA_VERSION=$(grep -oP ‘(?<=<maven.compiler.release>).*?(?=</maven.compiler.release>)’ “$dir/pom.xml”)If a project uses an outdated version, a variable, or simply omits the tag, the workflow falls back to a default version.
The output becomes a dynamic JSON matrix:
[
{ “path”: “ai-document-assistant”, “java”: “21” },
{ “path”: “legacy-demo”, “java”: “17” }
]This matrix describes exactly what to build and how.
Step 2: The Polymorphic Build Matrix
The build stage consumes the JSON and spawns a parallel workflow for each project. Every job sets up its own correct Java version:
- name: Set up JDK ${{ matrix.project.java }}
uses: actions/setup-java@v4
with:
java-version: ${{ matrix.project.java }}This allows Java 17 and Java 21 projects to run side-by-side with zero special cases. Slow projects no longer block the rest. Failing builds don’t stop others because fail-fast: false.
In practice, it behaves like a small CI cluster composed of dozens of isolated builds.
Step 3: The “Badge Backend”
GitHub provides only one badge per workflow. Monorepos with multiple independent projects need more than that.
To support per-project badges, the workflow builds its own tiny backend:
After each Maven build, a JSON file is generated:
{ “schemaVersion”: 1, “label”: “build”, “message”: “passing”, “color”: “green” }The file is uploaded as an artifact.
A later job collects all artifacts and pushes them into a dedicated
badgesorphan branch.
Each JSON file is an endpoint for Shields.io:
https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/myfear/ejq_substack_articles/badges/project-name.jsonWith this, every example project in the monorepo has a live status badge.
Step 4: Automating the README
The last part of the system keeps the documentation fresh.
The README.md contains invisible HTML markers reserved for auto-injection:
## Build Status
<!-- BUILD_BADGES_START -->
<!-- BUILD_BADGES_END -->The workflow reads all badge JSON files from the badges branch, builds a Markdown table, and inserts it between those markers using sed.
That means:
When new projects are added, they appear automatically.
When projects are deleted, they disappear.
When builds fail or pass, the table reflects that instantly.
No manual edits. No maintenance tax.
The Complete Workflow Code
Here is the full .github/workflows/monorepo-build.yml workflow, exactly as I use it today, with dynamic detection, mixed Java versions, badge generation, and README injection:
name: Monorepo Build
on:
push:
branches: [ “main” ]
pull_request:
branches: [ “main” ]
workflow_dispatch:
inputs:
build_all:
description: ‘Force build all projects?’
required: true
default: true
type: boolean
permissions:
contents: write # Required to push badges to the orphan branch
jobs:
# ------------------------------------------------------------------
# JOB 1: Detect which projects need building and which Java version
# ------------------------------------------------------------------
detect-changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
has-changes: ${{ steps.set-matrix.outputs.has-changes }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Identify projects and Java versions
id: set-matrix
run: |
# --- Configuration ---
DEFAULT_JAVA=”21”
# Space-separated list of folders to ignore
EXCLUDES=”video-pipeline”
# ---------------------
if [ “${{ github.event_name }}” == “pull_request” ]; then
BASE_SHA=”origin/${{ github.base_ref }}”
else
BASE_SHA=”${{ github.event.before }}”
fi
touch matrix_data.json
# Logic: If manual trigger (and true), find ALL projects. Otherwise, find CHANGED projects.
if [ “${{ github.event_name }}” == “workflow_dispatch” ] && [ “${{ inputs.build_all }}” == “true” ]; then
echo “Manual trigger: Scanning all projects...”
# Find all poms, exclude target folders, get directory names
FIND_CMD=$(find . -maxdepth 2 -name “pom.xml” -not -path ‘*/target/*’ | xargs dirname | sed ‘s|^\./||’ | sort -u)
else
echo “Diffing against $BASE_SHA”
FIND_CMD=$(git diff --name-only $BASE_SHA HEAD | awk -F/ ‘{print $1}’ | sort -u)
fi
# Process the list of directories
echo “$FIND_CMD” | while read dir; do
# Only process if it looks like a directory with a pom.xml
if [[ -d “$dir” ]] && [[ -f “$dir/pom.xml” ]]; then
# Check Exclusions
if [[ “ $EXCLUDES “ =~ “ $dir “ ]]; then
echo “Skipping $dir (Explicitly Excluded)” >&2
continue
fi
# --- Java Version Detection ---
# Priority 1: maven.compiler.release
JAVA_VERSION=$(grep -oP ‘(?<=<maven.compiler.release>).*?(?=</maven.compiler.release>)’ “$dir/pom.xml” | head -1)
# Priority 2: java.version
if [ -z “$JAVA_VERSION” ]; then
JAVA_VERSION=$(grep -oP ‘(?<=<java.version>).*?(?=</java.version>)’ “$dir/pom.xml” | head -1)
fi
# Priority 3: maven.compiler.source
if [ -z “$JAVA_VERSION” ]; then
JAVA_VERSION=$(grep -oP ‘(?<=<maven.compiler.source>).*?(?=</maven.compiler.source>)’ “$dir/pom.xml” | head -1)
fi
# Fallback: Default or if variable found
if [ -z “$JAVA_VERSION” ] || [[ “$JAVA_VERSION” == *’$’* ]]; then
JAVA_VERSION=$DEFAULT_JAVA
fi
# Normalize 1.8 -> 8
if [ “$JAVA_VERSION” == “1.8” ]; then JAVA_VERSION=”8”; fi
echo “Found: $dir (Java $JAVA_VERSION)” >&2
echo “{\”path\”: \”$dir\”, \”java\”: \”$JAVA_VERSION\”}” >> matrix_data.json
fi
done
# Output the JSON matrix
if [ -s matrix_data.json ]; then
JSON_MATRIX=”[$(paste -sd, matrix_data.json)]”
echo “has-changes=true” >> $GITHUB_OUTPUT
echo “matrix=$JSON_MATRIX” >> $GITHUB_OUTPUT
else
echo “No buildable projects found.”
echo “has-changes=false” >> $GITHUB_OUTPUT
echo “matrix=[]” >> $GITHUB_OUTPUT
fi
# ------------------------------------------------------------------
# JOB 2: Build projects in parallel
# ------------------------------------------------------------------
build:
needs: detect-changes
if: needs.detect-changes.outputs.has-changes == ‘true’
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
project: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
name: ${{ matrix.project.path }} (JDK ${{ matrix.project.java }})
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK ${{ matrix.project.java }}
uses: actions/setup-java@v4
with:
java-version: ${{ matrix.project.java }}
distribution: ‘temurin’
cache: ‘maven’
# Build, but don’t crash the job immediately on failure (so we can write the Red badge)
- name: Build with Maven
id: build
working-directory: ${{ matrix.project.path }}
continue-on-error: true
run: |
if [ -f “mvnw” ]; then chmod +x mvnw; CMD=”./mvnw”; else CMD=”mvn”; fi
$CMD package -DskipTests=false
# Create the JSON file for Shields.io
- name: Generate Badge JSON
run: |
SAFE_NAME=$(echo “${{ matrix.project.path }}” | sed ‘s/\//-/g’)
if [ “${{ steps.build.outcome }}” == “success” ]; then
COLOR=”green”
STATUS=”passing”
else
COLOR=”red”
STATUS=”failing”
fi
echo “{ \”schemaVersion\”: 1, \”label\”: \”build\”, \”message\”: \”$STATUS\”, \”color\”: \”$COLOR\” }” > “$SAFE_NAME.json”
- name: Upload Badge Artifact
uses: actions/upload-artifact@v4
with:
name: badge-${{ strategy.job-index }}
path: “*.json”
retention-days: 1
# Fail the job if the build actually failed
- name: Check Build Status
if: steps.build.outcome != ‘success’
run: exit 1
# ------------------------------------------------------------------
# JOB 3: Aggregate badges and push to orphan branch
# ------------------------------------------------------------------
update-badges:
needs: [detect-changes, build]
# Only run if there were changes, AND run even if builds failed (provided the workflow wasn’t cancelled manually)
if: needs.detect-changes.outputs.has-changes == ‘true’ && !cancelled()
runs-on: ubuntu-latest
steps:
- name: Checkout badges branch
uses: actions/checkout@v4
with:
ref: badges
path: badges-branch
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
pattern: badge-*
path: temp-artifacts
merge-multiple: true
- name: Move and Commit Badges
run: |
# Create directories to ensure find commands don’t crash if empty
mkdir -p temp-artifacts
mkdir -p badges-branch
# Only proceed if we actually found JSON files
if [ -n “$(find temp-artifacts -name ‘*.json’ -print -quit)” ]; then
echo “Found badge artifacts. Updating...”
# Copy all json files to the badges branch root
find temp-artifacts -name “*.json” -exec cp {} badges-branch/ \;
cd badges-branch
git config user.name “github-actions[bot]”
git config user.email “github-actions[bot]@users.noreply.github.com”
git add .
if git diff --staged --quiet; then
echo “No changes in badges.”
else
git commit -m “Update build badges [skip ci]”
git push origin badges
fi
else
echo “No artifacts found (maybe all builds were skipped or failed early).”
fi
# ------------------------------------------------------------------
# JOB 4: Update README with badge table
# ------------------------------------------------------------------
update-readme:
needs: update-badges
if: always()
runs-on: ubuntu-latest
steps:
- name: Checkout Main
uses: actions/checkout@v4
with:
ref: main # We are editing the main README
path: main-repo
- name: Checkout Badges Branch
uses: actions/checkout@v4
with:
ref: badges # We need to see what JSON files exist
path: badges-repo
- name: Generate New Badge Table
run: |
cd main-repo
# Start the table
echo “| Project | Status |” > ../badge_table.md
echo “| :--- | :--- |” >> ../badge_table.md
# Iterate over JSON files in the badges branch to build rows
# We use the badges branch as the source of truth for what projects exist
cd ../badges-repo
for file in *.json; do
if [ “$file” == “*.json” ]; then continue; fi # Handle empty case
# remove .json extension
PROJECT_NAME=”${file%.json}”
# Generate the Shield URL
# Note: We must use the raw.githubusercontent URL for the JSON endpoint
SHIELD_URL=”https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/${{ github.repository }}/badges/$file”
# Append row to table
echo “| **$PROJECT_NAME** |  |” >> ../badge_table.md
done
cd ../main-repo
- name: Inject Table into README
run: |
cd main-repo
# Define markers
START_MARKER=”<!-- BUILD_BADGES_START -->”
END_MARKER=”<!-- BUILD_BADGES_END -->”
FILE=”README.md”
# 1. Create a temporary file with the content BEFORE the start marker
sed -n “1,/$START_MARKER/p” “$FILE” > “$FILE.tmp”
# 2. Append the new generated table
cat ../badge_table.md >> “$FILE.tmp”
# 3. Append the content AFTER the end marker
# We use sed to find the end marker, then print from there to end of file
sed -n “/$END_MARKER/,\$p” “$FILE” >> “$FILE.tmp”
# 4. Overwrite original file
mv “$FILE.tmp” “$FILE”
- name: Commit and Push README
run: |
cd main-repo
git config user.name “github-actions[bot]”
git config user.email “github-actions[bot]@users.noreply.github.com”
# Only commit if the README actually changed
if git diff --quiet README.md; then
echo “README is up to date.”
else
git add README.md
git commit -m “docs: Auto-update build status badges”
git push origin main
fiFinal Thoughts
This workflow ended up removing every point of friction that made monorepos feel heavy. New projects register themselves automatically. Old experiments still build with their own Java version. Every project has its own badge. And the README stays accurate without manual work.
A little automation is all it takes to transform a chaotic monorepo into a predictable, self-updating system.





The version detection logic is pretty slick. I've seen teams struggle with the same issue when they try to standardize Java versions across projects but end up with tons of edge cases. The fallback pattern you used (maven.compiler.release -> java.version -> maven.compiler.source -> default) is basically a pragmatic way to handle real-world repos without forcing everyone to refactor.
I will try this right now. Just too good