Working in a monorepo
Notes on how this repo (apps/*, templates/* as Bun workspaces) keeps CI and Vercel from
rebuilding/redeploying things that didn't change, since every workspace shares one bun.lock.
Workspace layout
- Root
package.jsondeclaresworkspaces: ["apps/*", "templates/*"]— no Turborepo/Nx, just Bun's built-in workspace support. - One lockfile (
bun.lock) for the whole repo, so a dependency bump in any workspace touches the same file every other workspace's install reads from. - Each deployable workspace (
apps/docs,apps/recipes) is its own Vercel project, with the Vercel Root Directory set to that workspace's folder.installCommandin itsvercel.jsondoescd ../.. && bun install --frozen-lockfile --filter <name>to install with deps hoisted from the repo root.
CI: skip unaffected jobs
.github/workflows/build.yml has a changes job that runs
dorny/paths-filter once, with one filter key per
workspace (docs, recipes, tss, ...), and a single build job matrixed over whichever
workspaces actually changed — a dynamic matrix instead of a hardcoded list:
changes:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.filter.outputs.changes }}
steps:
- uses: actions/checkout@v7
- name: Filter packages
uses: dorny/paths-filter@v4
id: filter
with:
filters: |
docs:
- bun.lock
- package.json
- README.md
- apps/docs/**
- content/**
recipes:
- bun.lock
- package.json
- apps/recipes/**
tss:
- bun.lock
- package.json
- templates/tss/**
build:
needs: changes
if: needs.changes.outputs.packages != '[]'
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJson(needs.changes.outputs.packages) }}
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile --filter ${{ matrix.package }}
- run: bun run --filter ${{ matrix.package }} test
- run: bun run --filter ${{ matrix.package }} buildpackagesis paths-filter'schangesoutput — a single JSON array of whichever filter names matched across all workspaces (e.g.["docs"],["recipes","tss"], or[]) — fed straight intofromJson()for the onebuildmatrix, nojqneeded and no per-dimension outputs.- Each filter key must match that workspace's
package.jsonnamefield, sincebuildrunsbun run --filter <name>directly against the matrix value. - Every filter includes
bun.lockand rootpackage.json, since a shared-lockfile bump can affect any workspace's install. lint(rootfmt:check) stays unconditional — it's cheap and repo-wide.
Vercel: skip unaffected deploys
Each deployable project's vercel.json sets an ignoreCommand. Per Vercel's docs, this command
runs with its working directory set to the project's Root Directory (same as
installCommand/buildCommand), and exit code 0 skips the build while any other exit code
(git diff --quiet exits 1 on a diff) lets it continue:
// apps/docs/vercel.json (Root Directory: apps/docs)
"ignoreCommand": "git diff --quiet HEAD^ HEAD -- . ../../content ../../README.md ../../bun.lock ../../package.json",// apps/recipes/vercel.json (Root Directory: apps/recipes)
"ignoreCommand": "git diff --quiet HEAD^ HEAD -- . ../../bun.lock ../../package.json",Paths are relative to that Root Directory, not the repo root — . covers the project's own folder,
and everything else (shared root files, or content/ for docs) is reached with ../../.
Adding a new app/template
- Add a filter key for it in the
changesjob (see the checklist in the repo'sAGENTS.md) — no other CI job needs touching. - If it deploys to Vercel, give it its own
vercel.jsonwith anignoreCommandscoped the same way (own dir + shared root files).
Gotchas
- Any change to
bun.lockor rootpackage.jsonis treated as relevant to every workspace — that's a direct consequence of one shared lockfile, not a bug in the filters. ignoreCommand's cwd is the Root Directory, easy to get wrong if you copy a path from a CI workflow (which runs from the repo root instead).
References
dorny/paths-filter— path-based change detection with a dynamic-matrix-friendlychangesoutput- Vercel
vercel.jsonreference —ignoreCommand - Vercel — Ignored Build Step — confirms the command runs within the project's Root Directory
- Vercel — Skipping unaffected projects in a monorepo