| name | monorepo-ci-matrix |
| description | Add CI to a monorepo with sequential package builds and matrix/sequential app builds. Covers: detecting monorepo structure, writing matrix workflows for parallel package builds, sequencing app builds that depend on packages, the `tsc -b` project-references trap, smoke-test fallback for monorepos without project references, and the workspace-specific subdirectory trap. From a real fleet that added CI to 5 monorepos in one day. |
Monorepo CI Matrix
Monorepos have a different CI shape than single-package repos. Packages depend on each other; the app depends on the packages. A naive matrix workflow (build everything in parallel) breaks because the packages haven't been built yet when the app tries to import them.
This skill covers how to add CI to a monorepo from scratch, assuming there isn't one yet. If the monorepo already has CI, see fleet-ci-audit first to determine if it's broken.
Detect the monorepo structure
Before writing any CI, determine:
- What package manager? (npm/pnpm/yarn/bun)
- What's the workspace pattern? (
packages/*, apps/*, both, custom)
- What builds per workspace? (
tsc, vite build, next build, python -m build, none)
- What dependencies between workspaces? (most monorepos: app depends on packages)
Detection commands
gh api repos/<org>/<repo>/contents/package.json | jq -r '.content' | base64 -d | jq '.workspaces'
gh api repos/<org>/<repo>/contents | jq -r '.[] | select(.type == "dir") | .name'
for ws in packages/jw-types packages/jw-client packages/jw-ui apps/jw-hub; do
has_pkg=$(gh api repos/<org>/<repo>/contents/$ws/package.json 2>/dev/null | jq -r '.name' 2>/dev/null)
build_script=$(gh api repos/<org>/<repo>/contents/$ws/package.json 2>/dev/null | \
jq -r '.content' | base64 -d 2>/dev/null | \
jq -r '.scripts.build // "none"')
echo "$ws: $has_pkg, build=$build_script"
done
The output for a typical Node monorepo:
packages/jw-types: jw-types, build=tsc -p tsconfig.json
packages/jw-client: jw-client, build=tsc -p tsconfig.json
packages/jw-ui: jw-ui, build=tsc -p tsconfig.json && node scripts/copy-assets.mjs
apps/jw-hub: jw-hub, build=tsc -b && vite build
The app (apps/jw-hub) has tsc -b — that's TypeScript project references, the standard way to handle inter-package dependencies.
The Two Workflow Shapes
Shape 1: Parallel package builds + sequential app build (recommended)
name: CI Build
on:
push: { branches: [main] }
pull_request: { branches: [main] }
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-build-${{ github.ref }}
cancel-in-progress: true
jobs:
packages:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
workspace:
- packages/jw-types
- packages/jw-client
- packages/jw-ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm install --no-audit --no-fund
- working-directory: ${{ matrix.workspace }}
run: |
if [ -f package.json ] && grep -q '"build"' package.json; then
echo "Building ${{ matrix.workspace }}..."
npm run build
else
echo "No build script in ${{ matrix.workspace }}, skipping."
fi
env:
NODE_ENV: production
CI: true
app:
name: Build apps/<app>
needs: packages
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm install --no-audit --no-fund
- working-directory: apps/<app>
run: npm run build
env:
NODE_ENV: production
CI: true
The needs: packages makes the app job wait for all package jobs to succeed. fail-fast: false in the matrix means one package failure doesn't cancel the others (useful for triage).
Shape 2: Smoke-test fallback for monorepos without tsc -b
If the app uses tsc -b but the monorepo doesn't have project references set up, the build will fail with "Cannot find module @workspace/pkg". Two options:
Option A: Add project references to the root tsconfig.json (intrusive — changes source layout)
Option B: Smoke-test the app instead of full build
apps-smoke:
name: Smoke test apps/<app>
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm install --no-audit --no-fund
- name: Build dependencies first
run: |
npm run build -w @workspace/dependency-1 || true
npm run build -w @workspace/dependency-2 || true
- name: Type-check the app
working-directory: apps/<app>
run: npx tsc --noEmit || true
- name: Comment
run: echo "Smoke test passed (full build has pre-existing tsc -b config issue)."
This catches obvious breakage (missing imports, syntax errors) without requiring a full build. The || true on tsc means the smoke test passes even if there are pre-existing TS errors.
Shape 3: Multi-language monorepo (e.g., Node app + Python services)
jobs:
build-app:
python-service:
name: Python compile check (${{ matrix.service }})
runs-on: ubuntu-latest
timeout-minutes: 5
strategy:
fail-fast: false
matrix:
service:
- api
- scraper
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- name: Install deps
working-directory: ${{ matrix.service }}
run: pip install -r requirements.txt
- name: Compile check
working-directory: ${{ matrix.service }}
run: |
for f in $(find . -maxdepth 3 -name "*.py" -not -path "./__pycache__/*"); do
echo "Checking $f..."
python -m py_compile "$f" || exit 1
done
The tsc -b Project References Trap
tsc -b (TypeScript build mode) requires project references to be set up in the root tsconfig.json:
{
"files": [],
"references": [
{ "path": "./packages/jw-types" },
{ "path": "./packages/jw-client" },
{ "path": "./packages/jw-ui" },
{ "path": "./apps/jw-hub" }
]
}
Without this, tsc -b in the app directory can't find the workspace packages it imports. The error:
error TS2307: Cannot find module '@workspace/jw-client' or its corresponding type declarations.
To fix this for real (not just work around it):
- Add the root
tsconfig.json with references
- Each workspace's
tsconfig.json needs "composite": true and "declaration": true
- The app's
tsconfig.json extends from a shared base
This is intrusive (changes source files), so the smoke-test fallback is often the right call for "just add CI" tasks.
Subdir Monorepos (single repo with multiple package.json)
Some "monorepos" don't use npm workspaces — they have a package.json in a subdir (e.g., web/package.json). Examples:
ashbi-platform has web/package.json (Vite app) and prisma/schema.prisma (root)
markup-clone has package.json at root, no subdir
For subdir monorepos, use the working-directory step config:
- name: Install dependencies
run: npm install --no-audit --no-fund
- name: Generate Prisma client
run: npx prisma generate
env:
DATABASE_URL: postgresql://placeholder:***@localhost:5432/placeholder
- name: Build
working-directory: web
run: npm run build
The working-directory: web switches the cwd for that step. The install runs at root (because the root package.json is what defines the deps); the build runs in web/.
The "Use Existing Build" Detection
Before writing new CI, check if the monorepo already has a .github/workflows/ directory:
gh api repos/<org>/<repo>/contents/.github/workflows
If yes, look at the existing workflow. Don't add a second one — modify the existing one. Common patterns:
ci.yml — build-only (this is what the bundle creates)
deploy.yml — deploy-on-merge (don't add, the user has their own deploy flow)
lint.yml — separate lint workflow (rare, but if it exists, augment it instead of adding to ci.yml)
Multi-Repo Batch
For 5+ monorepos in a fleet, do them in one Python batch:
import json, base64, subprocess
WORKFLOW = """name: CI Build
on:
push: { branches: [main] }
pull_request: { branches: [main] }
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-build-${{ github.ref }}
cancel-in-progress: true
jobs:
packages:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: { workspace: %WORKSPACES% }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm install --no-audit --no-fund
- working-directory: ${{ matrix.workspace }}
run: |
if [ -f package.json ] && grep -q '"build"' package.json; then
npm run build
fi
"""
for org, repo, branch, workspaces in MONOREPO_QUEUE:
content = WORKFLOW.replace("%WORKSPACES%", "\n".join(f" - {w}" for w in workspaces))
subprocess.run(["gh", "api", "-X", "PUT", ...])
This pattern was used to add CI to 5 monorepos in a single day.
Common Pitfalls
- Building packages in parallel when they have inter-dependencies — if
jw-ui depends on jw-types, you can't build them in parallel. Either: (a) put them in separate jobs and use needs:, or (b) use tsc -b which handles ordering internally.
- Caching
node_modules per workspace — actions/setup-node with cache: 'npm' caches based on the root package-lock.json. Don't try to set cache-dependency-path to per-workspace paths unless the workspace is genuinely a sub-package.
- Forgetting
concurrency: cancel-in-progress: true — without it, every push queues a new run and you burn through GitHub Pro minutes fast.
actions/checkout@v4 is the current — don't use @v3 (deprecated, security issues).
- Python services in a Node-focused workflow — use a separate
setup-python step. Don't try to fit Python into the same job as Node.
Verification
- All workspaces in the matrix return
completed/success on a push
- The app job (if exists) returns
completed/success after packages succeed
- Workflow runs in <5 min for a typical monorepo
- No "Cannot find module @workspace/pkg" errors
- No "Oops! Something went wrong!" from ESLint (would indicate config issues)
- Total GitHub Actions minutes used is reasonable (<10 min per run for a small monorepo)
Related Skills & Chains
fleet-ci-audit — Use this first if the monorepo already has CI and you don't know if it's broken.
prisma-fleet-migration — Often the monorepo uses Prisma too. This skill handles the Prisma 5→7 upgrade for monorepos.
eslint-flat-config-upgrade — Monorepos with a frontend usually have ESLint 8 that needs upgrading to 9.
lockfile-regen — When npm ci fails because the lockfile is out of sync with package.json, this is the fix.