Skip to main content
ship it and sleep

Monorepo Implications for CI/CD

4 min read Chapter 58 of 66

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.

Monorepo path-based filtering

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 PathBuildTest
services/catalog/**catalogcatalog
services/checkout/**checkoutcheckout
libs/shared-types/**all services using shared-typesall
infra/**nonenone (separate pipeline)
docs/**nonenone
.github/workflows/**allall

Build Caching Layers

LayerCache KeyHit Rate
Dependencieslockfile hash90%+
Build artifactssource hash60-80%
Container layersDockerfile + deps70-85%
Test resultssource + deps hash50-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.