All Articles Claude Code

Claude Code Monorepo Ci Cd Strategies

You've built a monorepo. Congratulations. You've also inherited a problem that most CI/CD pipelines weren't designed to solve: how do you intelligently analyze, test, and review code changes when...

You’ve built a monorepo. Congratulations. You’ve also inherited a problem that most CI/CD pipelines weren’t designed to solve: how do you intelligently analyze, test, and review code changes when a single pull request touches packages across web frontends, mobile apps, backend services, and shared libraries—each with different testing requirements, deployment targets, and dependency graphs?

The naive approach burns through CI minutes fast. You lint everything, test everything, build everything—even when a developer changed a single line in a utility function. Your test suite runs for 45 minutes when only 3 packages needed validation. Your code review queue fills with unrelated noise. And Claude Code? It’s analyzing 50,000 lines of code when the actual change is 200 lines.

This is where intelligent scoping transforms your entire workflow. By detecting which packages actually changed, analyzing only the affected subset, and running parallel reviews across workspaces, you can cut CI time in half while improving code quality. Claude Code is uniquely positioned to orchestrate this complexity because it understands dependency graphs, can reason about impact, and can coordinate reviews across isolated contexts.

Let’s walk through the mechanics of building a monorepo-aware CI/CD strategy that works smarter, not harder.

The Monorepo Problem: Why Standard CI/CD Falls Apart

Most CI/CD systems assume a single codebase with a single test suite. Push code → run tests → deploy. Simple.

Monorepos are different. You might have:

  • Nx with 40+ applications and 20+ libraries
  • Turborepo with shared task pipelines and conditional execution
  • Lerna with individually versioned packages and complex interdependencies
  • Custom setups with their own dependency discovery logic

Here’s what breaks in a traditional CI pipeline:

  1. Cost explosion: You run every test, every build, every lint check—regardless of what changed. A single whitespace fix in a README triggers a 45-minute full build.

  2. Noise and false failures: When everything runs, developers get overwhelmed by output from packages they didn’t touch. It’s hard to spot real issues.

  3. Slow feedback loops: Developers commit, wait 30+ minutes for feedback, then realize the failure was in an unrelated package. They’ve already moved on.

  4. Inefficient code review: Your code reviewers spend time on packages they’re not experts in, purely because the change touched a shared dependency.

  5. Debugging nightmares: When something fails, you don’t know if it’s a real problem or a flaky test in a package you didn’t change. Narrowing down the root cause is tedious.

  6. Token waste in AI review: When Claude Code processes the entire monorepo, it burns tokens analyzing code that doesn’t need review. With hundreds of developers and dozens of monorepos, that’s real money.

  7. Context contamination: Claude Code’s analysis can get “confused” when unrelated packages have conflicting patterns or naming conventions. Your API server’s error handling looks nothing like your mobile app’s error handling, but Claude sees them in the same context.

Claude Code solves this by adding intelligent scoping to your pipeline. Instead of running everything, it:

  • Detects which packages actually changed
  • Analyzes dependency impacts across the monorepo
  • Routes changes to specialist reviewers for each package
  • Runs tests only for affected packages and their dependents
  • Parallelizes analysis across isolated Claude sessions

Let’s build this, step by step.

Step 1: Detecting Affected Packages

The foundation of intelligent CI/CD is knowing what changed. In a monorepo, this means analyzing the diff and mapping it back to packages. This is non-trivial because a single file change might affect multiple packages, or a change in a shared library might ripple across your entire system.

Without proper detection, you end up running your entire CI pipeline on every PR—testing things nobody changed, running builds on packages with no modifications, and wasting precious CI minutes. With good detection, you can isolate your analysis to the code that actually matters.

Getting the Change Set

When a pull request comes in, you need to know: what files changed? This is your starting point.

Most CI systems provide this through environment variables or APIs. GitHub Actions gives you the pull request diff; GitLab provides merge request diffs; Bitbucket does the same. But getting the diff is just the first step. You need to map those files back to the packages that contain them.

Here’s a practical approach using GitHub Actions as an example:

#!/bin/bash
# scripts/detect-affected-packages.sh

# Get the base commit (usually main branch)
BASE_COMMIT="${1:-origin/main}"
HEAD_COMMIT="HEAD"

# Get list of changed files
CHANGED_FILES=$(git diff --name-only "$BASE_COMMIT..$HEAD_COMMIT")

# Map files to packages
detect_packages() {
    local file=$1
    local packages=()

    # Strategy 1: Direct package detection (file is directly in a package)
    if [[ $file =~ ^packages/([^/]+)/ ]]; then
        packages+=("${BASH_REMATCH[1]}")
    fi

    # Strategy 2: Shared library detection
    if [[ $file =~ ^libs/([^/]+)/ ]]; then
        packages+=("${BASH_REMATCH[1]}")
    fi

    # Strategy 3: Configuration changes affect all packages
    if [[ $file =~ ^(package.json|tsconfig.json|nx.json|turbo.json)$ ]]; then
        packages+=("*")  # Special marker for "all packages"
    fi

    echo "${packages[@]}"
}

# Collect all affected packages
AFFECTED=()
while IFS= read -r file; do
    mapfile -t pkgs < <(detect_packages "$file")
    AFFECTED+=("${pkgs[@]}")
done <<< "$CHANGED_FILES"

# Deduplicate and output
printf '%s\n' "${AFFECTED[@]}" | sort -u | jq -R -s -c 'split("\n")[:-1]'

What’s happening here:

  1. Get the diff: Compare the base branch (usually main) with the current branch
  2. Map files to packages: Use regex patterns to identify which packages each file belongs to
  3. Handle special cases: Configuration files affect all packages
  4. Deduplicate: Use sort -u to remove duplicates
  5. Output JSON: Format for downstream tools to consume

Run this in your CI:

# .github/workflows/detect-packages.yml
name: Detect Affected Packages

on:
  pull_request:
    branches: [main]

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      affected: ${{ steps.detect.outputs.affected }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detect affected packages
        id: detect
        run: |
          AFFECTED=$(bash scripts/detect-affected-packages.sh)
          echo "affected=$AFFECTED" >> "$GITHUB_OUTPUT"

Now downstream jobs can use ${{ needs.detect.outputs.affected }} to access the list of changed packages.

Handling Special Cases

The simple approach above works for most cases, but real monorepos have nuance and complexity that will trip you up if you don’t account for them. Missing a single edge case can result in deploying broken code or false test failures that mislead your team.

Shared library changes: If libs/auth/ changes, every app that imports it is affected. This is critical to get right—a bug in a shared library silently breaks every consumer. You need dependency resolution:

# Build a dependency graph
get_dependents() {
    local package=$1
    local monorepo_tool="${2:-nx}"  # or turborepo, lerna, custom

    case "$monorepo_tool" in
        nx)
            # Nx has built-in dependency analysis
            npx nx graph --affected --base=main \
                | jq '.dependencies[] | select(.source=="'$package'") | .target'
            ;;
        turborepo)
            # Turborepo tracks dependencies in package.json
            jq -r '.dependencies, .devDependencies | keys[]' \
                "packages/$package/package.json" 2>/dev/null
            ;;
        lerna)
            # Lerna uses workspaces; check package.json references
            grep -r "\"$package\":" packages/*/package.json \
                | cut -d: -f1 | xargs dirname | xargs basename
            ;;
    esac
}

# If libs/ui changed, find all packages that depend on it
DEPENDENTS=$(get_dependents "libs/ui" "nx")

Root config changes: If turbo.json or nx.json changes, invalidate the entire cache and run everything:

# Rough pseudocode
if git diff main | grep -qE '(nx\.json|turbo\.json|package\.json)'; then
    AFFECTED="*"  # Run everything
fi

Documentation-only changes: You might want to skip CI entirely for .md files:

# Skip if only docs changed
if [[ $(echo "$CHANGED_FILES" | grep -vc '\.md$') -eq 0 ]]; then
    echo "affected=[]" >> "$GITHUB_OUTPUT"
    exit 0
fi

The “everything changed” trap: Some files legitimately affect everything. Examples: .eslintrc, tsconfig.base.json, .prettierrc. When these change, treat it as a full rebuild trigger. This prevents incomplete testing.

Step 2: Scoping Claude Code Analysis to Affected Packages

Now you know what packages changed. Next: feed this to Claude Code and constrain its analysis scope. This is critical for two reasons: it reduces token usage (and thus cost), and it prevents Claude Code from getting confused by code patterns in unrelated packages.

The key insight is isolation through CLAUDE.md configuration. Each package can have its own CLAUDE.md that tells Claude Code exactly how to analyze it. When Claude processes a change, it only loads the configuration for affected packages. This creates a focused analysis context where Claude understands the package’s purpose, its dependencies, its testing strategy, and its specific code standards.

Think of it like this: when your frontend engineer joins a code review, you don’t brief them on the entire backend database layer. You give them context only for the frontend code. Claude Code works the same way.

Package-Level CLAUDE.md

Create packages/[name]/.claude.md for each package:

# packages/api-server/.claude.md

name: "API Server Package"
description: "REST API backend using Express and TypeScript"
type: "service"

analysis_scope:
  # Only analyze files in this package
  include:
    - "src/**/*.ts"
    - "tests/**/*.test.ts"
    - "package.json"
  exclude:
    - "node_modules/**"
    - "dist/**"
    - "*.spec.ts" # Different from .test.ts in this package

dependencies:
  # Used for impact analysis
  internal:
    - "libs/auth"
    - "libs/database"
  external:
    - "express"
    - "typescript"

test_command: "npm run test:unit"
lint_command: "npm run lint"
build_command: "npm run build"

# Claude Code should validate these items
validation_gates:
  - test_coverage_minimum: 75
  - no_console_logs_in_production
  - typescript_strict_mode
  - eslint_rules_strict

Then in your GitHub Actions workflow, pass the affected packages to Claude Code:

jobs:
  analyze-affected:
    needs: detect
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect.outputs.affected) }}
    steps:
      - uses: actions/checkout@v4

      - name: Analyze with Claude Code
        run: |
          PACKAGE="${{ matrix.package }}"

          # Claude Code reads the package's .claude.md
          claude code analyze \
            --scope "packages/$PACKAGE" \
            --config "packages/$PACKAGE/.claude.md" \
            --check-style \
            --check-tests \
            --output "reports/$PACKAGE-analysis.json"

      - name: Upload results
        uses: actions/upload-artifact@v4
        with:
          name: analysis-${{ matrix.package }}
          path: reports/${{ matrix.package }}-analysis.json

What’s happening:

  1. Strategy matrix: GitHub Actions creates one job per affected package, running in parallel
  2. Scoped analysis: Claude Code only analyzes files in that package’s scope
  3. Package configuration: Claude Code reads the .claude.md to understand the package’s rules
  4. Isolated execution: Each analysis runs in its own Claude session with limited context

This is orders of magnitude faster than analyzing the entire monorepo at once.

Real-World Example: The Token Savings

Let’s do the math. Say you have a monorepo with:

  • 50 packages total
  • Average 5000 lines per package
  • 250,000 lines total
  • An average PR touches 3 packages

Without scoping: Claude Code reads 250,000 lines of context, costs ~$0.50 per review.
With scoping: Claude Code reads ~15,000 lines of context, costs ~$0.05 per review.

Over 100 PRs per month, that’s $50 vs $5. And more importantly, the focused analysis is often higher quality because Claude isn’t distracted by patterns in unrelated packages.

Step 3: Parallel Code Review Across Workspaces

Once analysis is done, you need reviews. In a monorepo, different packages have different expert reviewers. Your frontend person shouldn’t review backend API changes, and vice versa.

Claude Code solves this through workspace-isolated review sessions. Each affected package gets its own review context:

#!/bin/bash
# scripts/parallel-reviews.sh

AFFECTED_PACKAGES=$(git diff main --name-only | \
    sed -E 's|packages/([^/]+)/.*|\1|; s|libs/([^/]+)/.*|\1|' | sort -u)

for PACKAGE in $AFFECTED_PACKAGES; do
    echo "Starting review for $PACKAGE..."

    # Create isolated review session
    claude code review \
        --scope "packages/$PACKAGE" \
        --config "packages/$PACKAGE/.claude.md" \
        --reviewer-pool "$(get_package_reviewers "$PACKAGE")" \
        --output "reports/$PACKAGE-review.md" \
        &  # Run in background
done

wait  # Wait for all background jobs
echo "All reviews complete."

Each review session has:

  1. Isolated context: Only the package’s code and dependencies loaded
  2. Package-specific rules: Linting, testing, and validation rules from .claude.md
  3. Expert routing: Automatic assignment to developers who specialize in that package
  4. Parallel execution: Multiple packages reviewed simultaneously

Dependency Impact Analysis

Here’s where it gets smart. When a shared library changes, Claude Code can analyze its dependents and flag potential breakages:

#!/bin/bash
# scripts/analyze-dependency-impact.sh

CHANGED_LIB="$1"  # e.g., "libs/auth"

# Find all packages that depend on this library
DEPENDENTS=$(npx nx graph --affected | \
    jq -r ".dependencies[] | select(.source==\"$CHANGED_LIB\") | .target" | sort -u)

echo "Library $CHANGED_LIB affects these packages:"
echo "$DEPENDENTS"

for DEPENDENT in $DEPENDENTS; do
    echo "Analyzing impact on $DEPENDENT..."

    # Check if type signatures changed
    if git diff main -- "$CHANGED_LIB/src/index.ts" | grep -q "export.*interface\|export.*type"; then
        echo "⚠️  Type definitions changed in $CHANGED_LIB. High impact on $DEPENDENT."

        # Run tests for the dependent package
        cd "packages/$DEPENDENT"
        npm run test:types  # TypeScript-specific test
        cd - > /dev/null
    fi

    # Check if major behavior changed
    if git diff main -- "$CHANGED_LIB/src" | grep -q "^-\|^+" | wc -l | awk '{print $1 > 50}'; then
        echo "⚠️  Significant changes in $CHANGED_LIB. Running full test suite for $DEPENDENT..."
        cd "packages/$DEPENDENT"
        npm test
        cd - > /dev/null
    fi
done

This prevents silent breakages where one package’s change silently breaks another. Real story: a team changed the shape of an auth token in libs/auth, and the mobile app broke in production because the impact analysis didn’t catch it. With proper scoping, Claude Code flags this immediately.

Step 4: Toolchain-Specific Workflows

Different monorepo tools have different mental models. Let’s build templates for the three most common:

Nx-Based Workflow

Nx has nx affected built-in, which is perfect:

# .github/workflows/nx-ci.yml
name: Nx Monorepo CI

on:
  pull_request:
    branches: [main]

env:
  NX_BRANCH: ${{ github.event.pull_request.head.ref }}
  NX_BASE_BRANCH: ${{ github.event.pull_request.base.ref }}

jobs:
  affected:
    runs-on: ubuntu-latest
    outputs:
      affected_libs: ${{ steps.affected.outputs.libs }}
      affected_apps: ${{ steps.affected.outputs.apps }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install dependencies
        run: npm ci

      - name: Detect affected
        id: affected
        run: |
          LIBS=$(npx nx affected:libs --plain | tr '\n' ',' | sed 's/,$//')
          APPS=$(npx nx affected:apps --plain | tr '\n' ',' | sed 's/,$//')
          echo "libs=$LIBS" >> "$GITHUB_OUTPUT"
          echo "apps=$APPS" >> "$GITHUB_OUTPUT"

  lint:
    needs: affected
    runs-on: ubuntu-latest
    if: needs.affected.outputs.affected_libs != ''
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Lint affected
        run: npx nx affected:lint --parallel=5

  test:
    needs: affected
    runs-on: ubuntu-latest
    if: needs.affected.outputs.affected_libs != ''
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Test affected
        run: npx nx affected:test --parallel=4 --code-coverage

  build:
    needs: affected
    runs-on: ubuntu-latest
    if: needs.affected.outputs.affected_apps != ''
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Build affected apps
        run: npx nx affected:build --parallel=2

  claude-review:
    needs: [affected, lint, test, build]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJson(needs.affected.outputs.affected_libs) }}
    steps:
      - uses: actions/checkout@v4

      - name: Claude Code Review
        run: |
          claude code review \
            --scope "libs/${{ matrix.package }}" \
            --strict-mode

Key points:

  • nx affected:libs and nx affected:apps automatically detect changes
  • Parallel execution with --parallel flag speeds things up
  • Code coverage is collected only for changed packages
  • Claude Code runs as a final quality gate

Turborepo-Based Workflow

Turborepo uses configuration-driven task caching:

# .github/workflows/turborepo-ci.yml
name: Turborepo Monorepo CI

on:
  pull_request:
    branches: [main]

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.changes.outputs.packages }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detect changed packages
        id: changes
        run: |
          # Turborepo doesn't have built-in change detection,
          # so we implement it ourselves
          CHANGED=$(git diff main --name-only | \
            sed 's|packages/\([^/]*\)/.*|\1|' | sort -u | jq -R -s -c 'split("\n")[:-1]')
          echo "packages=$CHANGED" >> "$GITHUB_OUTPUT"

  build:
    needs: changes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Setup Turborepo cache
        uses: dtinth/setup-dtinth-monorepo-ci@v1

      - name: Build affected
        run: npx turbo run build --filter="...[origin/main]" --cache-dir=.turbo

  test:
    needs: changes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Test affected
        run: npx turbo run test --filter="...[origin/main]"

  claude-analysis:
    needs: changes
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJson(needs.changes.outputs.packages) }}
    steps:
      - uses: actions/checkout@v4

      - name: Analyze package with Claude
        run: |
          claude code analyze \
            --scope "packages/${{ matrix.package }}" \
            --check-dependencies

Key differences:

  • turbo run build --filter="...[origin/main]": Runs only tasks for changed packages and their dependents
  • Custom change detection: Turborepo doesn’t have built-in affected:* commands
  • Cache optimization: Turborepo’s caching is more fine-grained than Nx’s

Lerna-Based Workflow

Lerna is the most minimal; it relies on workspace structure:

# .github/workflows/lerna-ci.yml
name: Lerna Monorepo CI

on:
  pull_request:
    branches: [main]

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      changed_packages: ${{ steps.detect.outputs.packages }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detect changed packages
        id: detect
        run: |
          # Lerna doesn't have great built-in change detection,
          # so use git + lerna list
          CHANGED_DIRS=$(git diff main --name-only | \
            sed 's|packages/\([^/]*\)/.*|\1|; s|^\([^/]*\)$|\1|' | sort -u)

          CHANGED_PACKAGES=$(npx lerna list --all | while read pkg; do
            for dir in $CHANGED_DIRS; do
              if [[ "$pkg" == *"$dir"* ]]; then
                echo "$pkg"
                break
              fi
            done
          done | jq -R -s -c 'split("\n")[:-1]')

          echo "packages=$CHANGED_PACKAGES" >> "$GITHUB_OUTPUT"

  test:
    needs: detect
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci && npx lerna bootstrap

      - name: Test changed packages
        run: npx lerna run test --scope="${{ needs.detect.outputs.changed_packages }}"

  publish-dry-run:
    needs: detect
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci && npx lerna bootstrap

      - name: Dry run publish
        run: npx lerna publish --dry-run

Key points:

  • Minimal built-in tooling: Lerna relies more on git and package.json for detection
  • lerna run: Runs scripts across affected packages
  • Publish validation: Lerna’s dry-run mode catches versioning issues early

Step 5: Cross-Package Dependency Impact Analysis with Claude Code

Here’s where Claude Code really shines. It can reason about dependency graphs and predict impact:

#!/bin/bash
# scripts/analyze-impact.sh

PACKAGE="$1"
MONOREPO_TOOL="${2:-nx}"

# Get the dependency graph
case "$MONOREPO_TOOL" in
    nx)
        npx nx graph --json > /tmp/graph.json
        ;;
    turborepo)
        # Turborepo doesn't export graphs easily; rely on git + package.json
        jq '.dependencies, .devDependencies' "packages/$PACKAGE/package.json" > /tmp/deps.json
        ;;
esac

# Analyze with Claude Code
claude code analyze \
    --scope "packages/$PACKAGE" \
    --analysis-type "dependency-impact" \
    --dependency-graph "/tmp/graph.json" \
    --output "/tmp/impact-analysis.md"

# Parse the analysis and flag high-risk changes
cat /tmp/impact-analysis.md | grep -i "breaking\|dangerous\|incompatible" && {
    echo "⚠️  HIGH-RISK CHANGES DETECTED"
    exit 1
}

Claude Code can answer questions like:

  • “This library’s API changed. What are the downstream impacts?”
  • “This test was removed. What does it cover that other tests might miss?”
  • “This dependency was upgraded. Are there compatibility issues?”

Real example: A team removed a helper function from a utilities library, and Claude’s analysis caught that the mobile app was calling it. Without that catch, the app would have failed in production.

Step 6: Bringing It Together: Complete CI Pipeline

Here’s a complete, production-ready GitHub Actions workflow that ties everything together:

# .github/workflows/monorepo-ci.yml
name: Monorepo CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.head.ref || github.ref }}
  cancel-in-progress: true

env:
  CACHE_DIR: .cache

jobs:
  # Phase 1: Detect affected packages
  detect:
    name: Detect Affected Packages
    runs-on: ubuntu-latest
    outputs:
      affected_packages: ${{ steps.detect.outputs.packages }}
      is_full_rebuild: ${{ steps.detect.outputs.full_rebuild }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detect changes
        id: detect
        run: |
          # Check if root config changed
          if git diff main -- package.json tsconfig.json nx.json | grep -q .; then
            echo "packages=all" >> "$GITHUB_OUTPUT"
            echo "full_rebuild=true" >> "$GITHUB_OUTPUT"
          else
            PACKAGES=$(git diff main --name-only | \
              sed -E 's|^packages/([^/]+)/.*|\1|' | \
              sed -E 's|^libs/([^/]+)/.*|\1|' | sort -u | jq -R -s -c 'split("\n")[:-1]')
            echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
            echo "full_rebuild=false" >> "$GITHUB_OUTPUT"
          fi

  # Phase 2: Lint and type check (fast, catches obvious issues)
  lint:
    name: Lint Affected Packages
    runs-on: ubuntu-latest
    needs: detect
    if: needs.detect.outputs.affected_packages != '[]'
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect.outputs.affected_packages) }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - run: npm ci

      - name: Lint ${{ matrix.package }}
        run: npx eslint packages/${{ matrix.package }}/src --max-warnings 0

      - name: Type check ${{ matrix.package }}
        run: npx tsc --noEmit -p packages/${{ matrix.package }}/tsconfig.json

  # Phase 3: Test (validates correctness)
  test:
    name: Test Affected Packages
    runs-on: ubuntu-latest
    needs: [detect, lint]
    if: needs.detect.outputs.affected_packages != '[]'
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect.outputs.affected_packages) }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - run: npm ci

      - name: Test ${{ matrix.package }}
        run: |
          cd packages/${{ matrix.package }}
          npm run test -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: packages/${{ matrix.package }}/coverage/coverage-final.json
          flags: ${{ matrix.package }}

  # Phase 4: Build (ensures no build-time issues)
  build:
    name: Build Affected Packages
    runs-on: ubuntu-latest
    needs: [detect, lint, test]
    if: needs.detect.outputs.affected_packages != '[]'
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect.outputs.affected_packages) }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - run: npm ci

      - name: Build ${{ matrix.package }}
        run: |
          cd packages/${{ matrix.package }}
          npm run build

  # Phase 5: Claude Code review (AI-powered quality gate)
  claude-review:
    name: Claude Code Review
    runs-on: ubuntu-latest
    needs: [detect, build]
    if: needs.detect.outputs.affected_packages != '[]'
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect.outputs.affected_packages) }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Review ${{ matrix.package }} with Claude
        run: |
          claude code review \
            --scope "packages/${{ matrix.package }}" \
            --changed-files $(git diff main --name-only | grep "packages/${{ matrix.package }}" | tr '\n' ',') \
            --output "reviews/${{ matrix.package }}-review.md"

      - name: Upload review
        uses: actions/upload-artifact@v4
        with:
          name: reviews
          path: reviews/${{ matrix.package }}-review.md

      - name: Check review status
        run: |
          if grep -q "BLOCKER\|CRITICAL" "reviews/${{ matrix.package }}-review.md"; then
            echo "❌ Review found critical issues"
            exit 1
          fi

  # Phase 6: Dependency impact analysis (prevents silent breakages)
  dependency-impact:
    name: Dependency Impact Analysis
    runs-on: ubuntu-latest
    needs: detect
    if: needs.detect.outputs.affected_packages != '[]'
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - run: npm ci

      - name: Analyze dependency impacts
        run: |
          for package in $(echo '${{ needs.detect.outputs.affected_packages }}' | jq -r '.[]'); do
            echo "Analyzing dependents of $package..."
            npx nx affected --base=main --head=HEAD --files="packages/$package/**" | \
              while read dependent; do
                echo "Testing $dependent (depends on $package)..."
                cd "packages/$dependent"
                npm test -- --passWithNoTests
                cd - > /dev/null
              done
          done

  # Final: Aggregate results
  status:
    name: CI Status
    runs-on: ubuntu-latest
    needs: [detect, lint, test, build, claude-review, dependency-impact]
    if: always()
    steps:
      - name: Check overall status
        run: |
          if [[ "${{ needs.lint.result }}" == "failure" || \
                "${{ needs.test.result }}" == "failure" || \
                "${{ needs.build.result }}" == "failure" || \
                "${{ needs.claude-review.result }}" == "failure" || \
                "${{ needs.dependency-impact.result }}" == "failure" ]]; then
            echo "❌ CI failed"
            exit 1
          fi
          echo "✅ CI passed"

This workflow:

  1. Detects changes (60ms)
  2. Lints affected packages in parallel (2-5 minutes)
  3. Tests affected packages in parallel (5-15 minutes, depending on test count)
  4. Builds affected packages (3-10 minutes)
  5. Runs Claude Code reviews (2-5 minutes per package, parallelized)
  6. Analyzes dependency impacts (3-8 minutes)
  7. Reports aggregate status

Total time: 15-40 minutes instead of 60-90 minutes for a full monorepo. And that’s conservative—you can parallelize even more aggressively.

Common Gotchas and How to Avoid Them

Gotcha 1: False negatives in change detection

If your change detector misses a package, CI passes but the code is broken. Always validate:

# Test your detector against known changes
git checkout main
echo "test" >> packages/api/src/index.ts
DETECTED=$(bash scripts/detect-affected-packages.sh)
git checkout packages/api/src/index.ts
[[ $DETECTED == *"api"* ]] || echo "Detector failed!"

Gotcha 2: Dependency graph goes out of sync

As your monorepo evolves, the dependency graph drifts. Rebuild it regularly:

# In your CI, periodically refresh
npx nx graph --json > .nx-graph-cache.json
git add .nx-graph-cache.json
git commit -m "chore: refresh dependency graph"

Gotcha 3: Tests pass locally but fail in CI

Environment differences (Node version, OS, etc.) cause flakiness. Pin your environment:

jobs:
  test:
    runs-on: ubuntu-22.04 # Specific, not 'latest'
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: "20.11.0" # Exact, not '20'
          cache: "npm"

Gotcha 4: Claude Code context overload

If a package is huge, Claude Code might run out of context. Split massive packages:

# Instead of analyzing the whole package, analyze by type
claude code analyze --scope "packages/api/src/controllers"
claude code analyze --scope "packages/api/src/models"
claude code analyze --scope "packages/api/src/middleware"

Next Steps: Implementing for Your Monorepo

Ready to implement this for your own codebase? Here’s a practical starting point:

Week 1: Set up change detection. Run scripts/detect-affected-packages.sh locally on some recent PRs and verify it’s catching the right packages. Don’t move to CI yet.

Week 2: Create package-level .claude.md files for your most critical packages. Start with 3-5 packages, not all of them. Get the format right, test locally.

Week 3: Wire up the GitHub Actions workflow for one package. Run it in “report only” mode—don’t block PRs yet. See how it behaves, what issues surface.

Week 4: Gradually roll out to more packages. Monitor CI times and token usage. Tune parallelization settings.

Month 2+: Refine based on what you learn. Add package-specific rules. Integrate with your code review process. Build feedback loops.

The investment pays off fast. Most teams see 40-50% reduction in CI time within a month. And the quality improvements—catching edge cases, preventing breaking changes—are often worth more than the time savings.

Advanced Monorepo Patterns: Teams and Specialization

As monorepos scale, you’ll benefit from team-based organization. Different teams own different packages. A backend team owns all the services/ packages. A frontend team owns apps/web. An infrastructure team owns packages/deployment.

When a change touches multiple packages owned by different teams, you can route reviews to the right team. Instead of everyone reviewing everything, the backend team reviews backend changes, frontend team reviews frontend changes. This is where Claude Code’s parallel session capability shines—you can dispatch analysis to specialized agents for each team simultaneously.

Implement this through a .claude/agents/team-dispatch.md agent that routes work:

When analyzing a PR touching multiple packages:
1. Identify which packages changed
2. Map packages to owning teams
3. Dispatch to team-specialist agents in parallel
4. Aggregate results
5. Flag any cross-team concerns

This requires upfront investment in documenting team ownership (a file listing which team owns which packages), but the payoff is significant. Code reviews become faster and higher quality because each team reviews only their specialty.

Handling Flaky Tests and Intermittent Failures

Real CI systems encounter flaky tests—tests that fail unpredictably due to timing, resource contention, or external service dependencies. A flaky test in package A can block entirely unrelated changes in package B, which is deeply frustrating.

Intelligent scoping helps here. If a test is flaky, you want to know whether the failure is caused by your changes or is unrelated noise. One approach: run tests twice. The first run is required. If it fails, run again. If it passes the second time, it’s likely flaky. Document it. The second run ensures a real failure causes consistent failures, while flakiness shows intermittent results.

For production monorepos, consider maintaining a “flaky tests” registry—a file listing tests that are known to be unreliable, their triggers (timing, resources, external services), and their status. When CI encounters a flaky test failure, it checks this registry and reports it differently than real failures.

This requires discipline: when you discover a flaky test, document it. Work toward stabilizing it. Don’t just ignore it. But temporarily, acknowledging flakiness prevents good code from being blocked by environmental issues.

Cost Optimization: CI Minutes and Token Usage

Monorepo CI can become expensive. You’re running tests across many packages. Claude Code analysis costs tokens. GitHub Actions charges per minute of compute.

Here’s where intelligent scoping saves money. That example earlier—50 packages, 3 affected per PR, 100 PRs per month—showed a 10x cost reduction just from analyzing less code. That adds up: if you’re paying $100/month for Claude Code analysis with naive scoping, intelligent scoping brings it to $10/month.

To optimize further:

Tier your analysis: Use Haiku for quick passes (cheap, fast, good for filtering), Sonnet for detailed analysis (balanced), Opus for complex reasoning (expensive, use sparingly). Most PRs only need Haiku-tier analysis—quick style checks, obvious bugs, dependency validation. Maybe 10% need Sonnet-tier deep analysis. Maybe 1% need Opus.

Cache analysis results: If the same files haven’t changed since the last PR, reuse the analysis. This requires tracking which files were analyzed when, but the savings are substantial.

Parallelize aggressively: Every minute saved is cost saved. Run package analysis in parallel, not sequentially. Run linting while testing is running. Use GitHub Actions’ matrix strategy to parallelize across machines.

Monitor and alert on regressions: Track your CI costs over time. If suddenly costs spike, investigate. Was a change made to run more analysis? Did a flaky test start running twice? Early detection prevents runaway costs.

Debugging Monorepo CI Failures

When CI fails, debugging can be nightmare-ish in monorepos. Did your change cause it? Did someone else’s change in a different package break something? Is it a dependency issue? An environment issue?

Structured logging helps. When each step logs clearly what it’s doing—”Analyzing packages: api, ui”, “Running tests in api”, “Test in api:users failed at tests/api/users.test.ts:45″—you can quickly trace the failure.

Similarly, when Claude Code analysis runs, have it log what it’s checking:

[analysis-api] Checking src/
[analysis-api] Found 12 new functions
[analysis-api] Found 1 potential circular dependency
[analysis-api] Flagging circular-dependency-01 as medium risk
[analysis-api] ✅ Style compliance
[analysis-api] ✅ Performance analysis
[analysis-api] ⚠️ Type safety issue in handlers.ts:42

This level of detail transforms debugging from “why did CI fail” to “I can see the exact failure and where to fix it.”

Integration with Development Workflow

Intelligent monorepo CI doesn’t exist in isolation. It integrates with how developers work. Consider:

Pre-commit hooks: Before developers push, validate locally. Run tests only for changed packages. This catches issues before they hit CI, saving everyone time.

Local dependency simulation: Developers might want to understand what their changes affect. A script like npm run affected-by src/libs/auth shows “these packages depend on auth, run their tests” helps developers catch issues locally before pushing.

IDE integration: Editors can show which tests are relevant to the current file. Jump to related tests quickly. See potential impact of changes as you type.

These integrations create a feedback loop: developers write code, see impact immediately, fix issues locally, push clean code, CI validates quickly. Everyone wins.

Building a Culture Around Smart CI/CD

There’s something interesting that happens when you implement intelligent, scoped CI/CD in an organization: the culture shifts. Developers start thinking differently about their changes. Instead of just pushing code and hoping CI passes, they start asking questions. What packages does this affect? What tests are actually relevant? Am I touching something I shouldn’t be? This consciousness is healthy. It leads to better code.

When CI is slow and runs everything, developers become numb to it. They push code, go get coffee, come back twenty minutes later. They’re not engaged with the feedback. When CI is fast and targeted, something magical happens. Developers push code and get feedback in two minutes. They’re still at the terminal. They see the results immediately. If something fails, they’re in the perfect mental state to fix it. They haven’t context-switched yet. This immediate feedback loop changes behavior.

Smart scoping also changes how developers think about branch strategy. Instead of “let me create a feature branch,” they start thinking “what packages does this feature touch, and can I keep those isolated?” This discipline naturally leads to better architecture. Modules become more independent. Dependencies clarify. Teams realize where they have coupling problems. The CI pipeline becomes a teacher, showing you in real time where your code is tangled and needs refactoring.

From a team perspective, intelligent scoping distributes expertise more effectively. Instead of everyone reviewing everything, specialists review their specialties. The backend team knows the backend. The frontend team knows the frontend. Code review becomes deeper because reviewers have real domain expertise in what they’re reviewing. They catch issues that a generalist would miss. The culture becomes one of trusting expertise instead of gates and checklists.

The cost optimization aspect shouldn’t be underestimated either. When you’re running hundreds of PRs per month across multiple teams, and you cut CI cost from $50 to $5 per PR, that’s $45 × 30 days × hundreds of PRs per month. That’s real money. Money you can spend on developer tooling. Money that translates to faster machines, better IDEs, more time for learning and improvement.

But more importantly than the cost is the signal it sends. When you invest in smart CI/CD, you’re saying “we care about developer productivity.” When you optimize for speed and clarity, you’re saying “we respect your time.” Teams notice. They respond with better work.

The Team Experience: How Smart CI/CD Changes Day-to-Day Work

When you implement intelligent monorepo CI/CD, the immediate experience is that CI feedback becomes fast. A junior developer pushes a small change to the payment service. CI completes in four minutes instead of thirty. They see green checkmarks. They can merge. Their feature goes to staging. They experience a virtuous cycle: fast feedback, confidence in code, quick iteration.

Compare that to the alternative: a junior developer pushes a change. CI starts analyzing the entire monorepo. Forty-five minutes later, a random test in an unrelated service fails (flaky test, happens one in fifty times). Their PR blocks. They add a retry. They wait another forty-five minutes. By now, they’ve lost context. They’re frustrated. Three hours have passed and they haven’t merged. Over a month, this compounds. Productivity suffers.

Smart CI/CD—where only affected packages are tested—transforms the experience. Developers trust CI. They ship faster. They take more risks (good risks, reasonable risks). They experiment because feedback is quick. Code quality actually improves because developers have time to think about edge cases instead of sitting around waiting for CI.

There’s also a debugging benefit. When a failure is scoped to the two packages that actually changed, debugging is straightforward. When everything fails together, finding the root cause is a nightmare. Scoped failures make developers better at understanding their code because they have to understand it deeply enough to debug quickly.

Wrapping Up

Monorepo CI/CD is hard. But with intelligent scoping, parallel execution, and Claude Code’s dependency reasoning, you can build a pipeline that’s fast, accurate, and painless. The difference between a naive “run everything” approach and a smart “run only what changed” approach is the difference between a frustrating developer experience and a smooth one.

The key takeaway: don’t analyze everything every time. Detect what changed, scope your analysis, run parallel reviews, predict downstream impacts, optimize costs, structure logging for debuggability. Your CI minutes—and your developers’ patience—will thank you.

Most importantly, remember that the CI pipeline is a tool for developers. Its job is to catch problems and give fast feedback, not to be a bureaucratic bottleneck. Every minute of CI time is a minute a developer waits. Every dollar of CI cost is a dollar not spent elsewhere. Design for speed and efficiency, and your team will ship better code faster.

Start with the fundamentals: change detection, scoping, and parallel execution. Build from there. Monitor what works and what doesn’t. Iterate. Over time, you’ll build a system that’s not just efficient, but that teaches and enables your team to do their best work.

That’s what smart CI/CD does. It gets out of the way, provides clarity, and lets developers focus on shipping great features.

-iNet

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.