Optimize pnpm installs in GitHub Actions

Cache the pnpm store, use a consistent pnpm version, and scope recursive commands to the packages the job needs. With cache: 'pnpm' enabled, setup-node must be able to run pnpm to resolve its store; a missing executable fails the setup step.

ci.cache.pnpm-storestatic · checkable from the repo
How StarSling works

AI agents open the PR

StarSling agents run this exact audit on your workflows, apply the fix, and open a reviewable PR automatically.

Do this

actions/setup-node v4 resolves the pnpm cache directory by running pnpm store path --silent. Install pnpm before that step, or use a runner with the intended pnpm version already on PATH. A missing executable or failed store-path command causes a setup error. Once caching works, consistent pnpm versions help workflows share compatible store contents. Workspace scope is a separate optimization: use --filter when a job needs only part of a monorepo, so it avoids running scripts for unrelated packages.

.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # pnpm/action-setup runs BEFORE setup-node, so the pnpm binary exists
      # when setup-node resolves the store path and computes its cache key.
      # No explicit version: it reads packageManager from package.json, so
      # every workflow in the repo resolves the same version automatically.
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      # scoped to the package under test and anything that depends on it
      - run: pnpm --filter "./packages/api..." build
      - run: pnpm --filter "./packages/api..." test

Avoid this

.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # On a runner without pnpm on PATH, this cache setup fails
      # while trying to resolve the store path.
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - uses: pnpm/action-setup@v4
        with:
          version: 8.15.0   # hardcoded here; a sibling workflow pins 9.1.0
      - run: pnpm install --frozen-lockfile
      # every package's build and test script runs, even packages this job
      # never needs for the change under test
      - run: pnpm -r build
      - run: pnpm -r test

How to detect it

  1. Check each setup-node step using cache: 'pnpm'. Confirm the intended pnpm version is on PATH before it runs, through pnpm/action-setup or the runner image. A preinstalled executable can make the step work even when pnpm/action-setup appears later; inspect availability and version before changing the order.

  2. Confirm the cache option is set: grep -n "cache: 'pnpm'\|cache: \"pnpm\"" .github/workflows/*.yml - a setup-node step with no cache option does not restore the store at all, regardless of order.

  3. Check for version drift: grep -n 'version:' .github/workflows/*.yml | grep -B2 -A2 pnpm alongside grep -n packageManager package.json; different explicit versions across workflows, or a version that does not match packageManager, is a finding.

  4. Check for unscoped recursive runs: grep -rn 'pnpm -r\|pnpm --recursive\|pnpm run -r' .github/workflows/*.yml and confirm each one actually needs every package - a job that only builds or tests one package's dependency graph is a candidate for --filter.

Tradeoffs and safety

  • packageManager auto-detection requires the field to actually be present and current in package.json; a repo that has never set it should add it once rather than relying on pnpm/action-setup's fallback version.

  • --filter needs an accurate dependency graph (workspace protocol references, correct package.json dependencies) to know what depends on what; a mis-declared dependency means --filter silently skips a package that does need rebuilding.

  • A release or publish workflow that intentionally builds every package before tagging a version should keep pnpm -r there; the finding is an UNNECESSARY full-repo run in a job scoped to one package's change, not recursive runs in general.

  • Cache scoping still follows GitHub's branch rules: a pull request restores the store from its own branch or the default branch, so a brand-new branch or a first run after a lockfile change still pays a cold install once.

Setup order decides whether the store cache runs at all

setup-node v4 invokes pnpm store path --silent to locate the store. The intended pnpm version must already be on PATH, supplied by pnpm/action-setup or the runner image. If pnpm is unavailable or the command fails, setup-node fails during cache setup. Inspect the executable and version before treating action order as a defect.

.github/workflows/ci.yml
      # correct order: pnpm exists before setup-node asks it where the store is
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

Pin one pnpm version everywhere, not one per workflow

pnpm/action-setup with no version input reads the packageManager field in package.json and installs that exact version, so every workflow in the repo lands on the same pnpm without anyone maintaining version strings in multiple YAML files. When workflows instead hardcode different versions, they end up computing potentially different lockfile resolutions and store layouts, which is one more way a store cache warmed by one workflow stops being useful to another. Set packageManager: "pnpm@<version>" once in package.json and drop the version input from every pnpm/action-setup call.

Verify it worked

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR.

Prompt for your coding agent
Audit this repository's pnpm setup in GitHub Actions and fix what you find.

1. For each setup-node step using cache: 'pnpm', verify that the intended pnpm version
   is already on PATH. If it is unavailable, install it before setup-node resolves the
   store path. Preserve a working preinstalled setup; action order alone is not a failure.
   Confirm every dependency-install job that should cache the store enables cache: 'pnpm'.
2. Collect every explicit pnpm version pinned across workflows (grep for version: near
   pnpm/action-setup) and compare against the packageManager field in package.json. If
   workflows disagree, or no packageManager field exists, add or correct packageManager in
   package.json and remove the hardcoded per-workflow versions so pnpm/action-setup
   auto-detects one version everywhere.
3. Find every pnpm -r, pnpm --recursive, or pnpm run -r invocation in workflows. For each,
   check whether the job's actual purpose (build one service, test one package) only needs
   a subset of the monorepo. Where it does, replace the recursive command with
   pnpm --filter targeting that package and its dependents, preserving the original script
   name and any flags.
4. Read the pnpm/action-setup, GitHub Actions caching, and pnpm filtering docs linked on
   this page before making changes.
5. Show the full diff and open a pull request rather than applying changes blindly. In the
   PR body, list each change with file and line, and state how to verify: re-run the same
   commit and check the setup-node step's log for a cache restore, and confirm the filtered
   commands still cover every package that should rebuild.

Confirm the change landed

  1. Re-run the same commit and check the setup-node step's log for 'Cache restored from key' instead of 'Cache not found'; a hit there confirms the store is resolving and restoring.

  2. Diff the pnpm version each workflow resolves: grep -n 'version:' .github/workflows/*.yml | grep -B2 pnpm should show either no explicit version anywhere, or the same version everywhere, matching packageManager in package.json.

  3. Compare wall-clock time of the install step before and after on an unchanged lockfile; a warm store turns a registry-fetching install into a link-only one.

  4. For a --filter change, confirm the filtered command still touches every package that should rebuild: run pnpm --filter "<same filter>" list --depth -1 and check the package list against what actually changed.

Go further

One fix, all of them, or forever.

You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.

  1. Fix this one thing

    Copy the prompt above

    Hand OPT5 / OPT7 / OPT54 to your coding agent and fix it in your repo today.

  2. Fix everything, once

    Install the ci-speedup skill

    One prompt audits your whole repo against all 73 ci-speedup patterns (these 3 plus 70 more) and hands your agent every fix at once. Open source, MIT, runs locally.

  3. Keep it fixed, forever

    Install the StarSling GitHub App

    Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.

Sources

1setup-node v4: pnpm store resolution (opens in new tab)

2pnpm/action-setup (opens in new tab)

3GitHub Actions: caching dependencies (pnpm) (opens in new tab)

4pnpm: packageManager field (opens in new tab)

5pnpm: filtering (opens in new tab)

Last updated 2026-09-08