Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Only one version of each runtime can be installed globally. If a runtime name is
|------|-------------|
| `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`. |
| `registry` | npm registry to resolve and download pnpm from, e.g. a mirror. Defaults to `https://registry.npmjs.org/`. See [Registry mirrors](#registry-mirrors). |
| `dest` | Where to store pnpm files. Defaults to `~/setup-pnpm`. |
| `dest` | Directory used as `PNPM_HOME`, where runtimes and global binaries are installed. The `pnpm` executable itself is kept in the runner's tool cache (or in `dest` when `RUNNER_TOOL_CACHE` is not set). Defaults to `~/setup-pnpm`. |
| `runtime` | Runtime spec, in `<name>` or `<name>@<version>` 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`. |
Expand Down Expand Up @@ -57,7 +57,7 @@ against the manifest through pnpm.
| Name | Description |
|------|-------------|
| `dest` | Expanded path of `dest`. |
| `bin-dest` | Directory containing the `pnpm` / `pnpx` binaries. |
| `bin-dest` | Directory containing the `pnpm` / `pnpx` binaries: a directory in the runner's tool cache, or `dest` when `RUNNER_TOOL_CACHE` is not set. |
| `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. |
Expand Down Expand Up @@ -396,8 +396,10 @@ For jobs that only need pnpm itself — e.g. `pnpm audit`, lockfile-only regener

## How it works

1. The action hands the requested version (exact, range, or dist-tag) to [`get-pnpm`](https://github.com/pnpm/get.pnpm.io/tree/main/get-pnpm), which resolves it against the registry and downloads the self-contained executable for the runner's platform. The download is checked against the checksum the registry published for it, and that checksum against npm's signature. Requests that fail transiently (a reset connection, a timeout, a 429 or 5xx) are retried. The `pnpm` executable (and, for pnpm builds that need it, its bundled `dist/`) lands in `dest`, next to the `pnpx`, `pn`, and `pnx` aliases. No Node.js or npm is involved.
2. `PNPM_HOME` is exported and `dest` plus `$PNPM_HOME/bin` are added to `PATH`.
1. The action hands the requested version (exact, range, or dist-tag) to [`get-pnpm`](https://github.com/pnpm/get.pnpm.io/tree/main/get-pnpm), which resolves it against the registry and downloads the self-contained executable for the runner's platform. The download is checked against the checksum the registry published for it, and that checksum against npm's signature. Requests that fail transiently (a reset connection, a timeout, a 429 or 5xx) are retried. The `pnpm` executable (and, for pnpm builds that need it, its bundled `dist/`) is added to the runner's [tool cache](https://github.com/actions/toolkit/tree/main/packages/tool-cache) under `$RUNNER_TOOL_CACHE/pnpm/<version>/<arch>`, next to the `pnpx`, `pn`, and `pnx` aliases. When an exact version is requested and the tool cache already has it, nothing is downloaded. A range or dist-tag is resolved by downloading it, so it is downloaded on every run. When `RUNNER_TOOL_CACHE` is not set, the executable lands in `dest` instead. No Node.js or npm is involved.
2. `PNPM_HOME` is exported as `dest`, and the tool cache directory, `dest`, and `$PNPM_HOME/bin` are added to `PATH`.

GitHub-hosted runners start every job with an empty tool cache, so there pnpm is still downloaded on each run. Self-hosted runners keep their tool cache between jobs and reuse the cached copy.
3. The action runs `pnpm runtime set <name> <version> -g` for every requested runtime, which downloads them into `$PNPM_HOME/bin` and makes them available to later workflow steps. It then disables context-aware shims for every installed runtime; see [Context-aware global shims](#context-aware-global-shims).
4. If a `package.json` exists in the workspace, the action runs `pnpm install` (unless `install: false` is set). When runtimes were installed, `--no-runtime` is appended because the action has already processed `devEngines.runtime`.

Expand Down
8 changes: 6 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ inputs:
for `pnpm install`.
required: false
dest:
description: Where to store pnpm files
description: |
Directory used as `PNPM_HOME`, where runtimes and global binaries are
installed. The pnpm executable itself is kept in the runner's tool
cache and reused when the same exact version is requested again. When
RUNNER_TOOL_CACHE is not set, it is downloaded into this directory.
required: false
default: ~/setup-pnpm
runtime:
Expand Down Expand Up @@ -155,7 +159,7 @@ outputs:
dest:
description: Expanded path of inputs#dest
bin-dest:
description: Location of `pnpm` and `pnpx` command
description: Location of `pnpm` and `pnpx` command, inside the runner's tool cache (or inputs#dest when RUNNER_TOOL_CACHE is not set)
runtime-name:
description: Name of the first installed runtime (`node`, `bun`, or `deno`), or empty if none was installed
runtime-version:
Expand Down
338 changes: 169 additions & 169 deletions dist/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@actions/core": "^3.0.1",
"@actions/exec": "^3.0.0",
"@actions/glob": "^0.7.0",
"@actions/tool-cache": "^4.0.0",
"@types/expand-tilde": "^2.0.2",
"@types/node": "^26.6.5",
"@types/semver": "^7.8.0",
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

169 changes: 169 additions & 0 deletions src/install-pnpm/download.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, test } from 'node:test'
import { fileURLToPath } from 'node:url'

import { build } from 'esbuild'

// The real tool cache runs against a temporary RUNNER_TOOL_CACHE. get-pnpm is
// stubbed with one that writes a fake executable and records each download.
const { outputFiles } = await build({
entryPoints: [fileURLToPath(new URL('./download.ts', import.meta.url))],
bundle: true, platform: 'node', format: 'cjs', write: false,
plugins: [{
name: 'download-fixtures',
setup (build) {
build.onResolve({ filter: /^(get-pnpm|\.\/musl\.js|@actions\/core)$/ }, args =>
args.path === '@actions/core' && !args.importer.endsWith('download.ts') ? undefined : { path: args.path, namespace: 'fixture' })
build.onLoad({ filter: /.*/, namespace: 'fixture' }, args => ({ contents:
args.path === '@actions/core' ? 'export const info = () => {}' :
args.path === './musl.js' ? 'export const isMusl = () => globalThis.musl === true' : `
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
export async function downloadPnpm ({ versionSpec, dest }) {
const version = /^\\d+\\.\\d+\\.\\d+$/.test(versionSpec) ? versionSpec : globalThis.resolvesTo
globalThis.downloads.push(versionSpec)
const binPath = path.join(dest, process.platform === 'win32' ? 'pnpm.exe' : 'pnpm')
writeFileSync(binPath, 'pnpm ' + version)
mkdirSync(path.join(dest, 'dist'))
writeFileSync(path.join(dest, 'dist', 'pnpm.cjs'), '')
writeFileSync(path.join(dest, 'package.json'), '{}')
return { version, binPath }
}
`,
}))
},
}],
})
const bundled = { exports: {} }
new Function('require', 'module', 'exports', outputFiles[0].text)(createRequire(import.meta.url), bundled, bundled.exports)
const { installPnpm } = bundled.exports

const registry = 'https://registry.npmjs.org/'
const exe = name => process.platform === 'win32' ? `${name}.exe` : name
const aliases = ['pnpx', 'pn', 'pnx']

let root
let toolCache
let dest
const previousEnv = { RUNNER_TOOL_CACHE: process.env.RUNNER_TOOL_CACHE, RUNNER_TEMP: process.env.RUNNER_TEMP }

beforeEach(() => {
root = mkdtempSync(path.join(os.tmpdir(), 'setup-download-'))
toolCache = path.join(root, 'tool-cache')
dest = path.join(root, 'dest')
mkdirSync(path.join(root, 'temp'))
process.env.RUNNER_TOOL_CACHE = toolCache
process.env.RUNNER_TEMP = path.join(root, 'temp')
globalThis.downloads = []
globalThis.musl = false
globalThis.resolvesTo = undefined
})

afterEach(() => {
rmSync(root, { recursive: true, force: true })
for (const [name, value] of Object.entries(previousEnv)) {
if (value === undefined) delete process.env[name]
else process.env[name] = value
}
delete globalThis.downloads
delete globalThis.musl
delete globalThis.resolvesTo
})

/** Creates a complete tool cache entry the way `cacheDir` leaves one. */
function seedCacheEntry (version, arch = os.arch()) {
const entry = path.join(toolCache, 'pnpm', version, arch)
mkdirSync(entry, { recursive: true })
writeFileSync(path.join(entry, exe('pnpm')), `cached pnpm ${version}`)
writeFileSync(`${entry}.complete`, '')
return entry
}

test('a miss downloads pnpm and adds it to the tool cache', async () => {
const installed = await installPnpm('12.8.1', registry, dest)

const entry = path.join(toolCache, 'pnpm', '12.8.1', os.arch())
assert.deepEqual(installed, { version: '12.8.1', binPath: path.join(entry, exe('pnpm')) })
assert.deepEqual(globalThis.downloads, ['12.8.1'])
assert.ok(existsSync(`${entry}.complete`))
assert.ok(existsSync(path.join(entry, 'dist', 'pnpm.cjs')))
assert.ok(!existsSync(path.join(entry, 'package.json')))
for (const alias of aliases) {
assert.equal(readFileSync(path.join(entry, exe(alias)), 'utf8'), 'pnpm 12.8.1')
}
// The staging directory is removed, and nothing is written to dest.
assert.deepEqual(readdirSync(process.env.RUNNER_TEMP), [])
assert.ok(!existsSync(dest))
})

test('a hit uses the cached pnpm without downloading', async () => {
await installPnpm('12.8.1', registry, dest)
const installed = await installPnpm('12.8.1', registry, dest)

assert.equal(installed.binPath, path.join(toolCache, 'pnpm', '12.8.1', os.arch(), exe('pnpm')))
assert.deepEqual(globalThis.downloads, ['12.8.1'])
})

test('aliases are added to a cached entry, and existing ones are left alone', async () => {
const entry = seedCacheEntry('12.8.1')
writeFileSync(path.join(entry, exe('pnpx')), 'existing pnpx')

await installPnpm('12.8.1', registry, dest)

assert.deepEqual(globalThis.downloads, [])
assert.equal(readFileSync(path.join(entry, exe('pnpx')), 'utf8'), 'existing pnpx')
for (const alias of ['pn', 'pnx']) {
assert.equal(readFileSync(path.join(entry, exe(alias)), 'utf8'), 'cached pnpm 12.8.1')
}
})

test('musl hosts use the <arch>-musl tool cache entry', async () => {
seedCacheEntry('12.8.1')
globalThis.musl = true

const installed = await installPnpm('12.8.1', registry, dest)

assert.equal(installed.binPath, path.join(toolCache, 'pnpm', '12.8.1', `${os.arch()}-musl`, exe('pnpm')))
assert.deepEqual(globalThis.downloads, ['12.8.1'])
})

test('a range is downloaded and cached under the version it resolved to', async () => {
globalThis.resolvesTo = '12.8.2'

const installed = await installPnpm('^12.0.0', registry, dest)

assert.deepEqual(installed, { version: '12.8.2', binPath: path.join(toolCache, 'pnpm', '12.8.2', os.arch(), exe('pnpm')) })
assert.deepEqual(globalThis.downloads, ['^12.0.0'])
})

test('a range that resolves to a cached version keeps the cached entry', async () => {
const entry = seedCacheEntry('12.8.2')
globalThis.resolvesTo = '12.8.2'

const installed = await installPnpm('latest', registry, dest)

assert.equal(installed.binPath, path.join(entry, exe('pnpm')))
assert.equal(readFileSync(installed.binPath, 'utf8'), 'cached pnpm 12.8.2')
})

test('without RUNNER_TOOL_CACHE, pnpm is downloaded into dest', async () => {
delete process.env.RUNNER_TOOL_CACHE

const installed = await installPnpm('12.8.1', registry, dest)

assert.deepEqual(installed, { version: '12.8.1', binPath: path.join(dest, exe('pnpm')) })
assert.ok(!existsSync(path.join(dest, 'package.json')))
for (const alias of aliases) {
assert.ok(existsSync(path.join(dest, exe(alias))))
}
assert.ok(!existsSync(toolCache))
})

test('an exact version older than v11 is rejected before downloading', async () => {
await assert.rejects(installPnpm('10.0.0', registry, dest), /only installs pnpm v11 or newer/)
assert.deepEqual(globalThis.downloads, [])
})
Loading