Setup pnpm with runtime
Install pnpm and a JavaScript runtime (Node.js, Bun, or Deno) in a single GitHub Actions step.
pnpm ships a self-contained release binary — the action downloads it for the runner's platform from the npm registry, refusing anything whose npm signature or checksum does not check out (no Node.js or npm needed) and then uses pnpm runtime set to install the requested runtime. The runtime binary is placed on PATH for subsequent steps, replacing the need for actions/setup-node, oven-sh/setup-bun, or denoland/setup-deno. pnpm install runs automatically when a package.json is present.
[!NOTE]
pnpm/setup@v2installs pnpm v11 and newer only — it relies on pnpm's self-contained release binaries and thepnpm runtimecommand, both available from v11.v1installed pnpm through npm and could set up pnpm 10; if you need pnpm 10 or older, usepnpm/action-setupinstead.
> One caveat: pnpm v11 publishes no binary for Intel macOS (darwin-x64); use v12 or newer on Intel macOS runners.
If your package.json declares devEngines.runtime, the action picks up every runtime and version from there automatically — no inputs required.
When the manifest does not declare Node.js, the action also checks .node-version,
.nvmrc, and .tool-versions in the project directory. Set node-version-file: false
to disable this detection.
Only one version of each runtime can be installed globally. If a runtime name is declared more than once, the action emits a GitHub warning annotation and installs the last declared version while retaining the position of its first declaration.
Inputs
| Name | Description |
|------|-------------|
| version | Version of pnpm to install: an exact version, a semver range (^12.0.0), or a dist-tag (next-12). Must resolve to v11 or newer. Optional when packageManager or devEngines.packageManager is set in package.json. |
| dest | Where to store pnpm files. Defaults to ~/setup-pnpm. |
| runtime | Runtime spec, in or form (e.g. node@22, node@lts, bun@latest, deno@2). Supported names: node, bun, deno. When the version is omitted, falls back to devEngines.runtime, then to lts (for node) / latest. Node.js also supports version files with the precedence described below. If the input itself is omitted, installs every entry in devEngines.runtime and adds Node.js when a version file supplies it. |
| node-version-file | Optional Node.js version file path, relative to working-directory. By default, checks .node-version, .nvmrc, then .tool-versions when the manifest does not declare Node.js. Set to false to disable file detection. An explicit path overrides the manifest; an explicit version in runtime overrides both. |
| cache | Cache the pnpm store directory and restore it before installing the runtimes. Default: false. |
| cache-dependency-path | Path(s) to the pnpm lockfile, used to compute the cache key. Relative to GITHUB_WORKSPACE. Defaults to pnpm-lock.yaml inside working-directory. |
| working-directory | Directory the project lives in, relative to GITHUB_WORKSPACE. Config is read from the manifest there, pnpm install runs there, and node-version-file plus the default cache-dependency-path resolve relative to it. Default: .. |
| package-json-file | Deprecated — use working-directory. Still honoured on its own; the directory containing the file becomes the working directory. |
| install | Run pnpm install after setup. Default: true. Set to false for jobs that only need pnpm itself (e.g. pnpm audit, lockfile-only regeneration). |
| require-lockfile | Fail unless a pnpm-lock.yaml already describes the install; runs pnpm install --frozen-lockfile. Default: false. |
| token | No longer used. pnpm is fetched from the npm registry and verified against npm's signature, so the action makes no GitHub API request. Kept so workflows that pass it keep working. |
Outputs
| Name | Description |
|------|-------------|
| dest | Expanded path of dest. |
| bin-dest | Directory containing the pnpm / pnpx binaries. |
| runtime-name | Name of the first installed runtime, or empty string if none was installed. |
| runtime-version | Resolved version of the first installed runtime, or empty string if none was installed. |
| runtimes | JSON array of every installed runtime in declaration order, as { "name": string, "version": string } objects. Returns [] when none were installed. |
| cache-hit | Whether the restored cache matched the current lockfile exactly, rather than falling back to a store cached for a different lockfile. |
Usage
Install pnpm + Node.js via devEngines.runtime
// package.json
{
"packageManager": "[email protected]",
"devEngines": {
"runtime": { "name": "node", "version": "^22.0.0", "onFail": "download" }
}
}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/setup@v2
- run: node --version
- run: pnpm test
pnpm install runs automatically because the workspace has a package.json.
Install Node.js from a version file
With a .node-version, .nvmrc, or Node.js entry in .tool-versions in the
project directory, no runtime input is needed:
- uses: pnpm/setup@v2
Node.js version selection uses this precedence:
- An explicit version in
runtime, such asnode@22. - An explicit
node-version-filepath. - A Node.js declaration in
devEngines.runtime. - Automatic detection of
.node-version,.nvmrc, then.tool-versionsinworking-directory.
.tool-versions file without a node or nodejs entry is ignored during
detection. Invalid detected versions fail setup instead of silently selecting
another file. If no version source exists, Node.js is not installed unless
runtime: node is set, which defaults to lts.
To use a different file:
- uses: pnpm/setup@v2
with:
node-version-file: config/node-version
To disable version-file detection:
- uses: pnpm/setup@v2
with:
node-version-file: false
This opt-out leaves explicit runtime and devEngines.runtime installation enabled.
Plain version files must contain one selector. .nvmrc comments are accepted,
and common nvm selectors are translated to pnpm's equivalents: node and
stable become latest, lts/* becomes lts, and lts/ becomes the
LTS name. In .tool-versions, the first version after node or nodejs is
used. Values that pnpm cannot install, such as system, path:..., and
ref:..., fail the setup step.
When no runtime input is present, a file can add Node.js alongside Bun or
Deno declarations in devEngines.runtime. An explicit Bun or Deno runtime
ignores Node.js version files.
Matrix: test on multiple Node versions
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [22, 24, 26]
steps:
- uses: actions/checkout@v7
- uses: pnpm/setup@v2
with:
runtime: node@${{ matrix.node }}
- run: pnpm test
Install Bun or Deno
- uses: pnpm/setup@v2
with:
runtime: bun@latest
- uses: pnpm/setup@v2
with:
runtime: deno@2
A project in a subdirectory
When the project is not at the repository root — a site in docs/, an app in
web/ — point the action at it:
- uses: pnpm/setup@v2
with:
working-directory: docs
cache: true
pnpm install then runs in docs, packageManager and devEngines are read
from docs/package.json, node-version-file resolves from docs, and the
cache key comes from docs/pnpm-lock.yaml.
Set cache-dependency-path yourself and it stays relative to the repository
root, as it has always been — only its default follows the working directory.
Without this the install runs at the repository root,
where pnpm finds no manifest, prints Already up to date and exits 0 having
installed nothing — a green setup step followed by a confusing failure later.
A project inside a pnpm workspace does not need this. pnpm locates the
workspace root by walking up from wherever it starts, so an install anywhere in
the workspace installs the whole workspace. Reach for working-directory when
the project's own root is not the repository root.
Cache the pnpm store
- uses: pnpm/setup@v2
with:
cache: true
The cache is restored before the runtimes are installed, so a cached runtime
does not need to be downloaded again. Cache keys include both the requested
runtime selectors and the versions actually installed. Reordering
devEngines.runtime does not change the key — the same set of runtimes
produces the same store.
Lockfile verification cache
pnpm v11 and newer check every lockfile entry before installing it — that each
entry pins an integrity hash, that a pinned tarball URL matches the registry's
own metadata, and, where configured, your minimumReleaseAge and trustPolicy
policies. The verdict is memoized in a sub-kilobyte file, so an unchanged
lockfile is not re-checked against the registry.
The action restores and saves that file on every run, independently of the
cache input, because a job that starts without it pays for the check every
time. On a repository with ~2000 lockfile entries and a warm store:
| | without the log | with it |
| --- | --- | --- |
| minimumReleaseAge + trustPolicy | 13.5s | 1.5s |
| no policies configured | 6.7s | 1.6s |
Reusing a verdict is not a weaker check: pnpm re-verifies whenever the lockfile content changes, and whenever the recorded policy is looser than the one now configured.
The log is uploaded as soon as the install that produced it finishes, not at
the end of the job, so nothing the job runs afterwards — its tests, its build,
any later step — can alter what other jobs restore. Dependency lifecycle
scripts are the exception, since they run inside the install itself, ahead of
the upload: pnpm refuses to run them unless the repository allow-lists the
package through allowBuilds, and a package on that list can already run code
in the job.
Before uploading, the action checks that the log grew the way an install grows it: every record that predated the install still there, and no more new records than installs it ran. A dependency's script that slips an extra record in is caught by that, and the log is not cached — the next job re-verifies, which costs seconds and nothing else.
A job that installs in a step of its own rather than through this action is saved at the end of the job instead, since that is the first moment the log is known to be complete. The record count cannot be bounded there, so only the "nothing disappeared" half of the check applies.
Require a lockfile
- uses: pnpm/setup@v2
with:
require-lockfile: true
Fails unless pnpm-lock.yaml already describes the install, and runs
pnpm install --frozen-lockfile when it does. If no lockfile is found, the
action fails before running pnpm install, saying so directly rather than through an
install that was never going to succeed.
Where it looks is where pnpm reads one: at the workspace root when
working-directory is one of the workspace's projects, and in
working-directory itself when it is not, or when the workspace sets
sharedWorkspaceLockfile: false. The action asks the selected pnpm version
whether a project belongs to a workspace, so excluded projects follow that
version's installation behavior.
This is narrower than it sounds, and worth understanding before reaching for
it. pnpm refuses to update an existing lockfile when it detects CI, and
GitHub Actions always sets CI, so an out-of-date lockfile already fails a
plain install — on pnpm 11 and 12 alike. What that default does not do is
require a lockfile to exist: with none at all, pnpm install resolves from
the registry, writes one and exits 0. Set require-lockfile when a missing
lockfile should fail the job instead of silently installing unpinned
dependencies.
Every action invocation that saves a cache uses its own unique key, including matrix jobs, repeated steps, and workflow re-runs. Restoration looks for the most recent entry for the current lockfile. This means a job that gets cancelled or fails mid-install can never pin a partial store under a key later runs are stuck matching — the next successful run simply publishes a fresher entry.
Each save creates a new cache entry, even when the lockfile is unchanged. Large matrix workflows therefore use more cache storage and can evict older entries sooner.
Private registries
With pnpm 11.10.0 or newer, set pnpm_config__auth to configure registry URLs
and tokens together. Use YAML's | block to keep the JSON readable. For
example, to install @myorg/* packages from GitHub Packages:
- uses: pnpm/setup@v2
env:
pnpm_config__auth: |
{
"https://npm.pkg.github.com": {
"@myorg": {
"authToken": ${{ toJSON(secrets.PACKAGES_TOKEN) }}
}
}
}
Use "@" for a registry-wide default token. This also selects that URL as the
default registry:
- uses: pnpm/setup@v2
env:
pnpm_config__auth: |
{
"https://registry.npmjs.org": {
"@": {
"authToken": ${{ toJSON(secrets.NPM_TOKEN) }}
}
}
}
toJSON quotes and escapes each secret for JSON. You can combine multiple
registries and scopes in the same object; a scope-specific token takes
precedence over the registry-wide default for that scope.
Step-level env covers this action's automatic install. If later steps also
need authentication, set the variable on those steps or at the job level.
See pnpm's _auth documentation for details.
Skip pnpm install
For jobs that only need pnpm itself — e.g. pnpm audit, lockfile-only regeneration — set install: false:
- uses: pnpm/setup@v2
with:
install: false
- run: pnpm audit
How it works
- The action resolves the requested version (exact, range, or dist-tag) against the npm registry, then downloads the matching self-contained release archive for the runner's platform (
pnpm-, or- .tar.gz pnpm-win32-on Windows) from pnpm's GitHub releases. It verifies the archive against the SHA-256 digest GitHub publishes for the asset, extracts the.zip pnpmexecutable (and, for pnpm builds that need it, its bundleddist/), and links thepnpx,pn, andpnxaliases intodest. No Node.js or npm is involved. PNPM_HOMEis exported anddestplus$PNPM_HOME/binare added toPATH.- The action runs
pnpm runtime setfor every requested runtime, which downloads them into-g $PNPM_HOME/binand makes them available to later workflow steps. It then disables context-aware shims for every installed runtime; see Context-aware global shims. - If a
package.jsonexists in the workspace, the action runspnpm install(unlessinstall: falseis set). When runtimes were installed,--no-runtimeis appended because the action has already processeddevEngines.runtime.
Context-aware global shims
pnpm 12 links global runtime bins as context-aware shims: running node inside a project switches to the version that project pins in devEngines.runtime, fetching it on demand. In a workflow that is rarely what you want — a matrix job asking for node@22 would run the repository's pinned version instead, and even when the two versions agree pnpm materializes a second copy outside $PNPM_HOME.
So whenever the action installs a runtime, it exports PNPM_CONFIG_GLOBAL_SHIMS with that runtime disabled ({"node":false}), leaving every other runtime at pnpm's defaults. To keep the switching behaviour, set the variable yourself — the action never overwrites a value the workflow already provides:
- uses: pnpm/setup@v2
env:
PNPM_CONFIG_GLOBAL_SHIMS: '{"node":"auto"}'
with:
runtime: node@22