Fastest CI for pnpm Workspaces on GitHub Actions
Last updated
In a pnpm workspace you can get most of the speed of a build system with no extra tool: cache the pnpm store, install once, and run scripts only for packages that changed with pnpm --filter "...[origin/main]". This guide covers the filters, the checkout settings they need, and when to add Turborepo or Nx on top.
Install once, cache the store
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
Install at the repository root. pnpm installs every workspace package in one pass and links them. Details on store paths and keys are in caching pnpm in GitHub Actions.
Run only changed packages
pnpm's --filter accepts a changed-since selector. From the pnpm filtering docs (checked October 2026):
# packages changed since origin/main
pnpm --filter "[origin/main]" test
# changed packages plus everything that depends on them
pnpm --filter "...[origin/main]" test
# changed packages plus their dependencies
pnpm --filter "[origin/main]..." build
Use "...[origin/main]" for test and build in CI. A change to packages/ui must retest apps/web, which depends on it. The ... before the selector adds dependents.
Two useful options from the same docs:
--test-pattern="test/*"stops dependents being added when only test files changed, so a test edit does not retest the world.--changed-files-ignore-pattern="**/README.md"ignores changes that do not matter, such as docs.
pnpm --filter "...[origin/main]" \
--changed-files-ignore-pattern="**/*.md" \
run test
The docs say the since ref is compared against the merge base, the commit where your branch forked, and that in a shallow clone without the merge base pnpm compares against the ref directly. The selector needs Git 2.24 or newer. GitHub-hosted and compiler.dev images ship a newer Git.
Fetch-depth
origin/main must exist locally. Checkout fetches the PR merge commit only, so the base branch ref may be missing. Two fixes:
- uses: actions/checkout@v6
with:
fetch-depth: 0
or fetch only what you need:
- uses: actions/checkout@v6
with:
fetch-depth: 1
- run: git fetch --no-tags --depth=200 origin ${{ github.base_ref }}
A shallow fetch works if 200 commits cover the merge base; otherwise results get less precise. Full history is simpler and, with filter: tree:0 on checkout, still quick.
For push events on main, origin/main is the current commit, so the filter selects nothing. Either use "...[HEAD^1]" for pushes or run the full suite on main and filtered runs only on PRs.
Parallelism inside pnpm
pnpm runs scripts in topological order and can parallelise. With -r:
pnpm -r --workspace-concurrency=8 run test
--workspace-concurrency defaults to 4. On an 8-core machine, 8 is a sensible start. Combine with filters:
pnpm --filter "...[origin/main]" --workspace-concurrency=8 run test
Run with --if-present so packages without the script are skipped instead of failing. Use --workspace-concurrency=1 only to debug ordering problems.
Splitting across jobs
pnpm does not distribute work between machines. Use a GitHub matrix by package or task group:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package: [web, api, worker]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile --filter "...${{ matrix.package }}"
- run: pnpm --filter "${{ matrix.package }}" test
pnpm install --filter with ...name installs only that package and its dependencies, which shortens each job. Balance the matrix so no one job dominates; see test sharding for splitting a single large suite and the shard calculator for how many shards pay off.
When to add Turborepo or Nx
pnpm filters tell you which packages changed. They do not cache task output. If the same package is rebuilt on every PR from scratch, a task cache helps: Turborepo and Nx hash inputs and replay outputs, locally and remotely. See fastest CI for Turborepo and fastest CI for Nx. They work fine on top of pnpm workspaces and use the same packages globs from pnpm-workspace.yaml.
A complete workflow
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: [self-hosted, Linux, compiler-ci] # or ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: PR: changed packages only
if: github.event_name == 'pull_request'
run: |
pnpm --filter "...[origin/${{ github.base_ref }}]" \
--workspace-concurrency=8 run --if-present lint test build
- name: main: everything
if: github.event_name == 'push'
run: pnpm -r --workspace-concurrency=8 run --if-present lint test build
The concurrency block cancels superseded runs on the same branch, which saves minutes too; see reduce GitHub Actions bill. On compiler.dev, the runs-on line puts the job on an 8 vCPU machine at $0.012 per minute with dependency caches ready; see pricing.
Common mistakes
- No
origin/mainref. The filter finds nothing or everything. - Forgetting the dependents.
[origin/main]without...skips packages affected through dependencies. - Filtering the install. Filter install only if you are sure each job needs a subset; a missed dependency shows up as a confusing error.
- Root-level changes. A change to
pnpm-lock.yamlor root config touches every package. Consider running the full suite when those change.
FAQ
How does pnpm decide a package changed?
By git diff since the ref, mapped to package directories. pnpm also counts a package as changed when a catalog version it uses changes in pnpm-workspace.yaml.
Can I use pnpm filters with Turborepo?
Yes, but Turborepo has its own --filter with similar syntax. Use one of them per command.
Is pnpm install --filter safe?
It is for tight matrix jobs. Install everything when in doubt; the cached store makes it cheap.
What fetch-depth should I use?
0 is safe. A shallow fetch of the base branch works if it covers the merge base.
Related
- Cache pnpm Dependencies in GitHub ActionsCache the pnpm store in GitHub Actions with pnpm/action-setup, setup-node or actions/cache. Store path, keys, workspaces and how to verify hits.
- Fastest CI for Bazel Monorepos on GitHub ActionsFast Bazel CI on GitHub Actions. Target determination for affected builds, remote cache flags, read-only PR uploads, sharding and a complete workflow.
- Fastest CI for Nx Monorepos on GitHub ActionsMake Nx CI fast on GitHub Actions. nx affected with nx-set-shas, Nx remote cache, task distribution, fetch-depth and a complete workflow.
- Fastest CI for Turborepo Monorepos on GitHub ActionsSpeed up Turborepo CI on GitHub Actions. --filter for changed packages, remote cache with TURBO_TOKEN, fetch-depth, sharding and a complete workflow.