Monorepo Implications for CI/CD
Monorepo Implications for CI/CD
Some teams put all five services in one repository. The code review is simpler: one PR updates the checkout API and the frontend that consumes it. The trade-off is CI complexity. A change to checkout should not trigger the catalog tests. A change to shared libraries should trigger all tests.
The Failure
The team moved all five services into one repository. Every push triggered every pipeline: 5 builds, 5 test suites, 5 container images. A one-line README fix took 25 minutes to pass CI. Developers started ignoring CI results because the feedback loop was too long. A bug slipped through because the developer merged before CI finished.
Path-based filtering ensures only affected services are built and tested.
The Mechanism
Path-Based Trigger Matrix
| Changed Path | Build | Test |
|---|---|---|
services/catalog/** | catalog | catalog |
services/checkout/** | checkout | checkout |
libs/shared-types/** | all services using shared-types | all |
infra/** | none | none (separate pipeline) |
docs/** | none | none |
.github/workflows/** | all | all |
Build Caching Layers
| Layer | Cache Key | Hit Rate |
|---|---|---|
| Dependencies | lockfile hash | 90%+ |
| Build artifacts | source hash | 60-80% |
| Container layers | Dockerfile + deps | 70-85% |
| Test results | source + deps hash | 50-70% |
The Implementation
Path-Based Workflow
# .github/workflows/ci.yml
# HARDENED: Path-based filtering for monorepo
name: CI
on:
pull_request:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
catalog: ${{ steps.filter.outputs.catalog }}
checkout: ${{ steps.filter.outputs.checkout }}
inventory: ${{ steps.filter.outputs.inventory }}
payments: ${{ steps.filter.outputs.payments }}
frontend: ${{ steps.filter.outputs.frontend }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
catalog:
- 'services/catalog/**'
checkout:
- 'services/checkout/**'
inventory:
- 'services/inventory/**'
payments:
- 'services/payments/**'
frontend:
- 'services/frontend/**'
shared:
- 'libs/**'
- '.github/workflows/**'
build-catalog:
needs: detect-changes
if: needs.detect-changes.outputs.catalog == 'true' || needs.detect-changes.outputs.shared == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build catalog
working-directory: services/catalog
run: |
npm ci --cache ~/.npm
npm run build
npm test
build-checkout:
needs: detect-changes
if: needs.detect-changes.outputs.checkout == 'true' || needs.detect-changes.outputs.shared == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build checkout
working-directory: services/checkout
run: |
go build ./...
go test ./...
# ... similar for other services
Dependency Caching
build-catalog:
steps:
- uses: actions/checkout@v4
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-catalog-${{ hashFiles('services/catalog/package-lock.json') }}
restore-keys: npm-catalog-
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: go-${{ hashFiles('services/checkout/go.sum') }}
restore-keys: go-
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('services/payments/gradle/wrapper/gradle-wrapper.properties', 'services/payments/build.gradle.kts') }}
Docker Layer Caching
- name: Build container with cache
uses: docker/build-push-action@v5
with:
context: services/catalog
push: true
tags: ghcr.io/acme/catalog-service:${{ github.sha }}
cache-from: type=gha,scope=catalog
cache-to: type=gha,mode=max,scope=catalog
Selective Test Execution
test-integration:
needs: detect-changes
if: >
needs.detect-changes.outputs.checkout == 'true' ||
needs.detect-changes.outputs.payments == 'true' ||
needs.detect-changes.outputs.shared == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start dependent services
run: docker compose -f docker-compose.test.yml up -d
- name: Run integration tests
run: |
if [[ "${{ needs.detect-changes.outputs.checkout }}" == "true" ]]; then
cd services/checkout && go test -tags=integration ./...
fi
if [[ "${{ needs.detect-changes.outputs.payments }}" == "true" ]]; then
cd services/payments && ./gradlew integrationTest
fi
The Gate
Path-based filtering is the efficiency gate. It ensures CI time is proportional to change scope. A change to one service completes in 3-5 minutes. A change to shared libraries runs all pipelines but still completes faster than building everything unconditionally because of caching.
The Recovery
Path filter misses a dependency: A service depends on a shared library but the path filter does not include the library’s path. Audit dependencies and update the filter. Use shared as a catch-all that triggers everything.
Cache misses cause slow builds: Check cache key composition. If the key changes too often (includes a timestamp or unstable value), cache hit rate drops. Use stable keys: lockfile hashes, dependency checksums.
PR touches all services: Shared library changes trigger all builds. This is correct behavior. If shared library changes are frequent, consider extracting the library into its own package with versioned releases.