Monorepo CI Optimizer
Run only affected builds and tests in monorepos for faster CI.
Affected Detection Strategy
Using Turborepo
.github/workflows/ci.yml
name: CI
on: pull_request: push: branches: [main]
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Need full history for affected detection
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install
- name: Build affected
run: |
pnpm turbo run build --filter='...[origin/main]'
- name: Test affected
run: |
pnpm turbo run test --filter='...[origin/main]'
- name: Lint affected
run: |
pnpm turbo run lint --filter='...[origin/main]'
Using Nx
build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0
- uses: nrwl/nx-set-shas@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Build affected projects
run: npx nx affected:build --base=$NX_BASE --head=$NX_HEAD
- name: Test affected projects
run: npx nx affected:test --base=$NX_BASE --head=$NX_HEAD --parallel=3
- name: Lint affected projects
run: npx nx affected:lint --base=$NX_BASE --head=$NX_HEAD
Remote Caching (Turborepo)
-
name: Setup Turborepo cache uses: actions/cache@v3 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo-
-
name: Build with remote cache run: pnpm turbo run build env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
Nx Cloud Integration
-
name: Setup Nx Cloud run: | npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" env: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
-
name: Run affected run: | npx nx affected --target=build --parallel=3 npx nx affected --target=test --parallel=3 npx nx affected --target=lint --parallel=3
Manual Affected Detection
// scripts/get-affected.ts import * as path from "path"; import { execSync } from "child_process"; import * as fs from "fs";
interface Package { name: string; path: string; dependencies: string[]; }
function getChangedFiles(base: string = "origin/main"): string[] {
const output = execSync(git diff --name-only ${base}...HEAD, {
encoding: "utf-8",
});
return output.split("\n").filter(Boolean);
}
function getPackages(): Package[] {
const packagesDir = path.join(process.cwd(), "packages");
return fs.readdirSync(packagesDir).map((name) => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(packagesDir, name, "package.json"), "utf-8")
);
return {
name: packageJson.name,
path: packages/${name},
dependencies: [
...Object.keys(packageJson.dependencies || {}),
...Object.keys(packageJson.devDependencies || {}),
],
};
});
}
function getAffectedPackages(): string[] { const changedFiles = getChangedFiles(); const packages = getPackages(); const affected = new Set<string>();
// Direct changes changedFiles.forEach((file) => { packages.forEach((pkg) => { if (file.startsWith(pkg.path)) { affected.add(pkg.name); } }); });
// Dependent packages let changed = true; while (changed) { changed = false; packages.forEach((pkg) => { if (!affected.has(pkg.name)) { const hasAffectedDep = pkg.dependencies.some((dep) => affected.has(dep) ); if (hasAffectedDep) { affected.add(pkg.name); changed = true; } } }); }
return Array.from(affected); }
// Output for GitHub Actions
const affected = getAffectedPackages();
console.log(::set-output name=packages::${affected.join(",")});
console.log(Affected packages: ${affected.join(", ")});
Matrix Strategy for Affected Packages
detect-affected: runs-on: ubuntu-latest outputs: packages: ${{ steps.affected.outputs.packages }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0
- id: affected
run: |
PACKAGES=$(node scripts/get-affected.ts)
echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
test-affected: needs: detect-affected if: needs.detect-affected.outputs.packages != '' runs-on: ubuntu-latest strategy: matrix: package: ${{ fromJSON(needs.detect-affected.outputs.packages) }} steps: - uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install
- name: Test ${{ matrix.package }}
run: pnpm --filter ${{ matrix.package }} test
Workspace-aware Caching
- name: Cache workspace builds uses: actions/cache@v3 with: path: | packages//dist packages//node_modules/.cache key: ${{ runner.os }}-workspace-${{ hashFiles('/pnpm-lock.yaml') }}-${{ hashFiles('packages//*.ts') }} restore-keys: | ${{ runner.os }}-workspace-${{ hashFiles('**/pnpm-lock.yaml') }}- ${{ runner.os }}-workspace-
Parallel Execution
-
name: Build all packages in parallel run: pnpm turbo run build --parallel
-
name: Test with controlled parallelism run: pnpm turbo run test --concurrency=3
Optimization Metrics
Before Optimization
- Full build: 25 minutes
- All tests: 15 minutes
- Total: 40 minutes
- Runs on every PR
After Affected Detection
- Affected build: 5 minutes (80% reduction)
- Affected tests: 3 minutes (80% reduction)
- Total: 8 minutes (80% reduction)
- Only runs necessary checks
With Remote Caching
- Cache hit build: 30 seconds (98% reduction)
- Cache hit tests: 1 minute (93% reduction)
- Total: 1.5 minutes (96% reduction)
Best Practices
-
Fetch full history: fetch-depth: 0 for accurate diffs
-
Topological order: Build dependencies first
-
Remote caching: Share cache across CI runs
-
Parallel execution: Run independent tasks concurrently
-
Incremental builds: Only rebuild what changed
-
Dependency graph: Track package relationships
-
Force full build: On main branch merges
Output Checklist
-
Affected detection implemented
-
Turborepo or Nx configured
-
Remote caching enabled
-
Parallel execution optimized
-
Matrix strategy for packages
-
Workspace-aware caching
-
Dependency graph documented
-
Before/after metrics tracked