---
title: "Optimize pnpm Installs in GitHub Actions | StarSling"
description: "Fix the pnpm store cache setup order, pin one pnpm version across workflows, and scope pnpm -r to the packages a job actually touches."
url: https://starsling.dev/github-actions/optimizations/optimize-pnpm-install
canonicalUrl: https://starsling.dev/github-actions/optimizations/optimize-pnpm-install
---

# Optimize pnpm installs in GitHub Actions

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Optimize pnpm installs in GitHub Actions

- [How StarSling works](https://starsling.dev/)

Rule: ci.cache.pnpm-store. Detection mode: static. Last updated: 2026-08-21

A well-configured pnpm install in CI restores the content-addressable store from cache and links packages in seconds; the store cache silently misses every run when actions/setup-node runs before pnpm/action-setup, when workflows pin different pnpm versions, or when a job runs pnpm -r across the whole monorepo instead of the packages it needs.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Setup order decides whether the store cache runs at all](#setup-order)
- [Pin one pnpm version everywhere, not one per workflow](#pin-one-version)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

pnpm's speed in CI comes from its store: a single content-addressable cache of every package version ever installed, which install links into node_modules instead of copying. actions/setup-node's cache: 'pnpm' option finds that store by shelling out to pnpm itself, so if setup-node runs before pnpm/action-setup, there is no pnpm binary yet to ask, and the cache step silently has nothing to key against - every run reinstalls from the registry. Pinning a different pnpm version in each workflow compounds this: the store layout and lockfile resolution can shift between pnpm versions, so workflows that disagree on version stop sharing a warm store even when they cache correctly. And pnpm -r running every script across every package in a monorepo does real work for packages a given job never touches, work a --filter scoped to the affected package or its dependents skips entirely.

_.github/workflows/ci.yml_

```yaml
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
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/ci.yml_

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # setup-node runs first and tries to resolve the pnpm store path for
      # its cache key, but pnpm is not installed yet: the cache step has
      # nothing to key against and silently restores nothing.
      - 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
```

<a id="how-to-detect-it"></a>

## How to detect it

1. Check setup order in every workflow: `grep -n 'pnpm/action-setup\|actions/setup-node' .github/workflows/*.yml` and confirm pnpm/action-setup appears before actions/setup-node in each job; a setup-node step earlier in the same job has no pnpm binary to call when it resolves the store path for its cache.
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.

<a id="tradeoffs"></a>

## 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.

<a id="setup-order"></a>

## Setup order decides whether the store cache runs at all

actions/setup-node's cache: 'pnpm' option does not know where the pnpm store lives on disk; it finds out by invoking pnpm itself (pnpm store path) to compute what to cache. If pnpm/action-setup has not run yet in that job, there is no pnpm binary to call, and the cache step has nothing to key against - not a partial cache, no cache. This only shows up as a cost: the install step succeeds every time, so nothing about a broken cache looks like a failure, it just runs slower than it should on every single run.

_.github/workflows/ci.yml_

```yaml
      # 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'
```

<a id="pin-one-version"></a>

## 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.

<a id="verify"></a>

## 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:

```
Audit this repository's pnpm setup in GitHub Actions and fix what you find.

1. For every workflow file, find the pnpm/action-setup and actions/setup-node steps in each
   job. If actions/setup-node appears before pnpm/action-setup in the same job, move
   pnpm/action-setup earlier so the pnpm binary exists before setup-node resolves the store
   path for its cache key. Confirm every setup-node step that installs pnpm-managed
   dependencies sets 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.

<a id="related-pages"></a>

## Related pages

- [GitHub Actions cache: dependencies, keys, and cache hits](https://starsling.dev/best-practices/github-actions/cache-dependencies)
- [Optimize npm installs in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-npm-install)
- [Optimize Turborepo caching in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-turborepo)

<a id="sources"></a>

## Sources

- [pnpm/action-setup](https://github.com/pnpm/action-setup)
- [GitHub Actions: caching dependencies (pnpm)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
- [pnpm: packageManager field](https://pnpm.io/package_json#packagemanager)
- [pnpm: filtering](https://pnpm.io/filtering)
