Questions
Our monorepo builds everything on every change. Implement efficient path-based workflows.
The Scenario
Your monorepo structure:
monorepo/
├── apps/
│ ├── web/ # React frontend
│ ├── api/ # Node.js backend
│ ├── admin/ # Admin dashboard
│ └── mobile/ # React Native app
├── packages/
│ ├── ui/ # Shared UI components
│ ├── utils/ # Shared utilities
│ └── config/ # Shared configurations
└── infrastructure/
├── terraform/
└── k8s/
Current problem: Every commit triggers builds for ALL 7 packages, even when only one file changed. CI takes 45 minutes and wastes thousands of dollars monthly.
The Challenge
Implement efficient path-based workflows that only build affected packages while correctly handling dependencies between packages.
A junior engineer might just add path filters to each workflow without considering dependencies, create separate repos for each package, or skip CI for 'small changes'. These approaches miss changes that affect dependents, lose the benefits of monorepo, or risk breaking production.
A senior engineer implements smart change detection that understands package dependencies, uses matrix builds for affected packages, caches aggressively across the monorepo, and ensures shared package changes trigger dependent package builds.
Step 1: Detect Changed Packages
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
web: ${{ steps.changes.outputs.web }}
api: ${{ steps.changes.outputs.api }}
admin: ${{ steps.changes.outputs.admin }}
ui: ${{ steps.changes.outputs.ui }}
utils: ${{ steps.changes.outputs.utils }}
matrix: ${{ steps.matrix.outputs.packages }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: changes
with:
filters: |
web:
- 'apps/web/**'
- 'packages/ui/**'
- 'packages/utils/**'
- 'packages/config/**'
api:
- 'apps/api/**'
- 'packages/utils/**'
- 'packages/config/**'
admin:
- 'apps/admin/**'
- 'packages/ui/**'
- 'packages/utils/**'
ui:
- 'packages/ui/**'
utils:
- 'packages/utils/**'
- name: Build matrix
id: matrix
run: |
PACKAGES=()
if [ "${{ steps.changes.outputs.web }}" == "true" ]; then
PACKAGES+=("web")
fi
if [ "${{ steps.changes.outputs.api }}" == "true" ]; then
PACKAGES+=("api")
fi
if [ "${{ steps.changes.outputs.admin }}" == "true" ]; then
PACKAGES+=("admin")
fi
if [ "${{ steps.changes.outputs.ui }}" == "true" ]; then
PACKAGES+=("ui")
fi
if [ "${{ steps.changes.outputs.utils }}" == "true" ]; then
PACKAGES+=("utils")
fi
# Convert to JSON array
JSON=$(printf '%s\n' "${PACKAGES[@]}" | jq -R . | jq -s -c .)
echo "packages=$JSON" >> $GITHUB_OUTPUT
build:
needs: detect-changes
if: needs.detect-changes.outputs.matrix != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.detect-changes.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build package
run: npm run build --workspace=${{ matrix.package }}
- name: Test package
run: npm run test --workspace=${{ matrix.package }}Step 2: Using Turborepo for Smart Builds
name: CI with Turborepo
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Need for turbo diff
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci
# Turborepo remote cache
- name: Setup Turborepo cache
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
# Build only affected packages
- name: Build
run: npx turbo run build --filter='...[origin/main]'
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
- name: Test
run: npx turbo run test --filter='...[origin/main]'// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"deploy": {
"dependsOn": ["build", "test"],
"outputs": []
}
}
}Step 3: Using Nx for Affected Detection
name: CI with Nx
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for Nx
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci
# Nx cloud for distributed caching
- name: Setup Nx
uses: nrwl/nx-set-shas@v4
# Only run for affected projects
- name: Build affected
run: npx nx affected --target=build --base=$NX_BASE --head=$NX_HEAD
- name: Test affected
run: npx nx affected --target=test --base=$NX_BASE --head=$NX_HEAD
- name: Lint affected
run: npx nx affected --target=lint --base=$NX_BASE --head=$NX_HEADStep 4: Deploy Only Affected Apps
name: Deploy
on:
push:
branches: [main]
jobs:
detect:
runs-on: ubuntu-latest
outputs:
apps: ${{ steps.detect.outputs.apps }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect affected apps
id: detect
run: |
# Get changed files
CHANGED=$(git diff --name-only HEAD~1)
APPS=()
# Check each app
if echo "$CHANGED" | grep -qE "^apps/web/|^packages/"; then
APPS+=("web")
fi
if echo "$CHANGED" | grep -qE "^apps/api/|^packages/utils/"; then
APPS+=("api")
fi
if echo "$CHANGED" | grep -qE "^apps/admin/|^packages/"; then
APPS+=("admin")
fi
JSON=$(printf '%s\n' "${APPS[@]}" | jq -R . | jq -s -c .)
echo "apps=$JSON" >> $GITHUB_OUTPUT
deploy:
needs: detect
if: needs.detect.outputs.apps != '[]'
runs-on: ubuntu-latest
strategy:
matrix:
app: ${{ fromJSON(needs.detect.outputs.apps) }}
environment: production-${{ matrix.app }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- run: npm ci
- name: Build ${{ matrix.app }}
run: npm run build --workspace=apps/${{ matrix.app }}
- name: Deploy ${{ matrix.app }}
run: |
case "${{ matrix.app }}" in
web)
npx vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
;;
api)
aws eks update-kubeconfig --name prod-cluster
kubectl set image deployment/api api=ghcr.io/${{ github.repository }}/api:${{ github.sha }}
;;
admin)
aws s3 sync apps/admin/dist s3://admin-bucket --delete
aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DIST_ID }} --paths "/*"
;;
esacStep 5: Efficient Caching for Monorepos
name: CI with Optimized Caching
on: [push, pull_request]
env:
CACHE_VERSION: v1
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
# Cache entire node_modules with workspace awareness
- name: Cache node_modules
uses: actions/cache@v4
id: cache
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
key: ${{ env.CACHE_VERSION }}-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: npm ci
build:
needs: install
runs-on: ubuntu-latest
strategy:
matrix:
package: [ui, utils, config]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
# Restore cached node_modules
- uses: actions/cache@v4
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
key: ${{ env.CACHE_VERSION }}-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# Cache build outputs per package
- uses: actions/cache@v4
with:
path: packages/${{ matrix.package }}/dist
key: build-${{ matrix.package }}-${{ hashFiles(format('packages/{0}/**', matrix.package)) }}
- name: Build ${{ matrix.package }}
run: npm run build --workspace=packages/${{ matrix.package }}
apps:
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
app: [web, api, admin]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- uses: actions/cache@v4
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
key: ${{ env.CACHE_VERSION }}-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# Restore built packages
- uses: actions/cache@v4
with:
path: packages/*/dist
key: build-packages-${{ github.sha }}
restore-keys: |
build-packages-
- name: Build ${{ matrix.app }}
run: npm run build --workspace=apps/${{ matrix.app }}
- name: Test ${{ matrix.app }}
run: npm run test --workspace=apps/${{ matrix.app }}Step 6: Complete Monorepo Workflow
name: Monorepo CI/CD
on:
push:
branches: [main, develop]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.affected.outputs.packages }}
apps: ${{ steps.affected.outputs.apps }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- run: npm ci
- name: Get affected
id: affected
run: |
# Using Nx to detect affected
PACKAGES=$(npx nx show projects --affected --type=lib | jq -R -s -c 'split("\n")[:-1]')
APPS=$(npx nx show projects --affected --type=app | jq -R -s -c 'split("\n")[:-1]')
echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
echo "apps=$APPS" >> $GITHUB_OUTPUT
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- run: npm ci
- run: npx nx affected --target=lint
test-packages:
needs: analyze
if: needs.analyze.outputs.packages != '[]'
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJSON(needs.analyze.outputs.packages) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- run: npm ci
- run: npx nx test ${{ matrix.package }}
build-apps:
needs: [analyze, test-packages]
if: always() && needs.analyze.outputs.apps != '[]'
runs-on: ubuntu-latest
strategy:
matrix:
app: ${{ fromJSON(needs.analyze.outputs.apps) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- run: npm ci
- run: npx nx build ${{ matrix.app }}
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.app }}-build
path: apps/${{ matrix.app }}/dist
deploy:
needs: build-apps
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
strategy:
matrix:
app: ${{ fromJSON(needs.analyze.outputs.apps) }}
environment: production-${{ matrix.app }}
steps:
- uses: actions/download-artifact@v4
with:
name: ${{ matrix.app }}-build
- name: Deploy ${{ matrix.app }}
run: echo "Deploying ${{ matrix.app }}" Monorepo Tool Comparison
| Tool | Affected Detection | Remote Cache | Learning Curve |
|---|---|---|---|
| Turborepo | Via git diff | Vercel/custom | Low |
| Nx | Built-in graph | Nx Cloud/custom | Medium |
| Lerna | Limited | No | Low |
| Manual | paths-filter | GitHub Cache | Low |
Practice Question
In a monorepo, why should path filters for an app include its dependent packages, not just the app directory?