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
67 changes: 67 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,73 @@ jobs:
esac
shell: bash

node-version-file:
name: 'Node version file (${{ matrix.format }} / ${{ matrix.os }})'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
format: node-version
file: .node-version
version: 22.22.0
runtime: ''
- os: ubuntu-latest
format: tool-versions
file: .tool-versions
version: 24.19.0
runtime: ''
- os: windows-latest
format: nvmrc
file: .nvmrc
version: 22.22.0
runtime: node
steps:
- uses: actions/checkout@v7

- name: Write version file in a subdirectory project
env:
FORMAT: ${{ matrix.format }}
VERSION_FILE: ${{ matrix.file }}
VERSION: ${{ matrix.version }}
run: |
set -e
mkdir project
if [ "${FORMAT}" = "tool-versions" ]; then
printf 'ruby 3.4.5\nnodejs %s\n' "${VERSION}" > "project/${VERSION_FILE}"
else
printf 'v%s # project runtime\n' "${VERSION}" > "project/${VERSION_FILE}"
fi
shell: bash

- id: pnpm
uses: ./
with:
version: '12.0.0'
working-directory: project
runtime: ${{ matrix.runtime }}
node-version-file: ${{ matrix.file }}
install: false

- name: 'Test: version file selected Node.js'
env:
EXPECTED: v${{ matrix.version }}
OUTPUT_NAME: ${{ steps.pnpm.outputs.runtime-name }}
OUTPUT_VERSION: ${{ steps.pnpm.outputs.runtime-version }}
run: |
set -e
actual="$(node --version)"
if [ "${actual}" != "${EXPECTED}" ]; then
echo "Expected ${EXPECTED}, got ${actual}"
exit 1
fi
if [ "${OUTPUT_NAME}" != "node" ] || [ "v${OUTPUT_VERSION}" != "${EXPECTED}" ]; then
echo "Unexpected runtime outputs: ${OUTPUT_NAME}@${OUTPUT_VERSION}"
exit 1
fi
shell: bash

install-false:
# `install: false` skips the auto-install step even though a manifest is
# present.
Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ 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`. |
| `dest` | Where to store pnpm files. 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` in `package.json`, then to `lts` (for `node`) / `latest`. If the input itself is omitted, the action installs every entry in `devEngines.runtime` from `package.json`. |
| `node-version-file` | File containing the Node.js version to install, relative to `working-directory`. Supports plain files such as `.node-version` and `.nvmrc`, plus the `node` or `nodejs` entry in `.tool-versions`. An explicit version in `runtime` takes precedence. |
| `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 `cache-dependency-path` resolves relative to it. Default: `.`. |
| `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`. |
Expand Down Expand Up @@ -66,6 +67,25 @@ jobs:

`pnpm install` runs automatically because the workspace has a `package.json`.

### Install Node.js from a version file

```yaml
- uses: pnpm/setup@v2
with:
node-version-file: .node-version
```

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/<name>` 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, the file adds Node.js and leaves any Bun or
Deno declarations in `devEngines.runtime` intact. `runtime: node` takes its
version from the file, while `runtime: node@22` remains authoritative.

### Matrix: test on multiple Node versions

```yaml
Expand Down Expand Up @@ -108,7 +128,8 @@ When the project is not at the repository root — a site in `docs/`, an app in
```

`pnpm install` then runs in `docs`, `packageManager` and `devEngines` are read
from `docs/package.json`, and the cache key comes from `docs/pnpm-lock.yaml`.
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,
Expand Down
15 changes: 13 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ inputs:
disabled, so later steps run exactly this version instead of one a
project pins. Set the variable in the workflow to override.
required: false
node-version-file:
description: |
File containing the Node.js version to install. Relative to
`working-directory`. Supports plain version files such as `.node-version`
and `.nvmrc`, plus the `node` or `nodejs` entry in `.tool-versions`.

When `runtime` is omitted, this installs Node.js alongside any non-Node
runtimes declared in `devEngines.runtime`. `runtime: node` reads its
version from this file. A version included directly in `runtime` takes
precedence, and an explicit Bun or Deno runtime ignores this input.
required: false
cache:
description: |
Whether to cache the pnpm store directory, keyed on the lockfile's
Expand All @@ -57,8 +68,8 @@ inputs:
Directory the project lives in, relative to the repository root
(GITHUB_WORKSPACE). The action reads `packageManager` and
`devEngines` from the manifest there, runs `pnpm install` there, and
resolves `cache-dependency-path` relative to it. Defaults to the
repository root.
resolves `node-version-file` and the default `cache-dependency-path`
relative to it. Defaults to the repository root.

pnpm finds the workspace root itself by walking up, so a project
inside a pnpm workspace does not need this — point it at a project
Expand Down
224 changes: 112 additions & 112 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"build:bundle": "esbuild src/index.ts --bundle --platform=node --target=node24 --format=cjs --minify --outfile=dist/index.js",
"build": "pnpm run build:bundle",
"start": "pnpm run build && sh ./run.sh",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs"
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs src/install-runtime/*.test.mjs"
},
"dependencies": {
"@actions/cache": "^6.2.0",
Expand Down
3 changes: 3 additions & 0 deletions src/inputs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface Inputs {
/** The manifest to read config from, relative to GITHUB_WORKSPACE. */
readonly packageJsonFile: string
readonly runtime?: RuntimeInput
/** Node version file path, relative to `workingDirectory`. */
readonly nodeVersionFile?: string
readonly install: boolean
/** Whether a lockfile must already exist and fully describe the install. */
readonly requireLockfile: boolean
Expand Down Expand Up @@ -132,6 +134,7 @@ export const getInputs = (): Inputs => ({
cache: getBooleanInput('cache'),
...resolveProjectPaths(),
runtime: parseRuntime(),
nodeVersionFile: getInput('node-version-file').trim() || undefined,
install: getBooleanInput('install'),
requireLockfile: getBooleanInput('require-lockfile'),
token: getInput('token') || undefined,
Expand Down
27 changes: 22 additions & 5 deletions src/install-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'path'
import util from 'util'
import { parse as parseYaml } from 'yaml'
import { Inputs, RuntimeName } from '../inputs'
import { readNodeVersionFile } from './node-version-file'

const SUPPORTED_RUNTIMES: ReadonlySet<RuntimeName> = new Set(['node', 'bun', 'deno'])

Expand All @@ -25,15 +26,31 @@ export interface RuntimeRequest {

export function resolveRuntimeRequests(inputs: Inputs): RuntimeRequest[] {
// Explicit `runtime` input always wins. `runtime.version` falls back to
// devEngines.runtime if not provided — useful for matrix workflows that
// pick the runtime but keep the version pinned in the manifest.
// node-version-file for Node, then devEngines.runtime if not provided.
if (inputs.runtime) {
const { name } = inputs.runtime
const version = inputs.runtime.version ?? readDevEngineVersion(inputs, name) ?? defaultVersionFor(name)
if (inputs.nodeVersionFile && (name !== 'node' || inputs.runtime.version)) {
warning(
name === 'node'
? '`node-version-file` is ignored because `runtime` already includes a Node.js version.'
: `\`node-version-file\` is ignored because \`runtime\` explicitly selects ${name}.`,
)
}
const version = inputs.runtime.version
?? (name === 'node' ? readNodeVersionFile(inputs) : undefined)
?? readDevEngineVersion(inputs, name)
?? defaultVersionFor(name)
return [{ name, version }]
}

return readDevEngineRuntimes(inputs)
const runtimes = readDevEngineRuntimes(inputs)
const nodeVersion = readNodeVersionFile(inputs)
if (!nodeVersion) return runtimes

return [
{ name: 'node', version: nodeVersion },
...runtimes.filter(runtime => runtime.name !== 'node'),
]
}

export async function installRuntime(
Expand Down Expand Up @@ -123,7 +140,7 @@ export function keepInstalledRuntimesAuthoritative(runtimes: readonly InstalledR
}

export function logSkippedRuntime() {
info('No runtime requested (no `runtime` input and no `devEngines.runtime` in package.json). Skipping runtime install.')
info('No runtime requested (no `runtime`, `node-version-file`, or `devEngines.runtime`). Skipping runtime install.')
}

function defaultVersionFor(name: RuntimeName): string {
Expand Down
90 changes: 90 additions & 0 deletions src/install-runtime/node-version-file.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { parseNodeVersionFile, readNodeVersionFile } from './node-version-file.ts'

test('plain version files accept comments and normalize common nvm selectors', () => {
assert.equal(parseNodeVersionFile('\uFEFF v24.19.0 # current release\r\n', '.nvmrc'), '24.19.0')
assert.equal(parseNodeVersionFile('node\n', '.nvmrc'), 'latest')
assert.equal(parseNodeVersionFile('lts/*\n', '.nvmrc'), 'lts')
assert.equal(parseNodeVersionFile('lts/jod\n', '.nvmrc'), 'jod')
assert.equal(parseNodeVersionFile('>=22 <25\n', '.node-version'), '>=22 <25')
})

test('.tool-versions reads node or nodejs and uses the first declared version', () => {
assert.equal(
parseNodeVersionFile('ruby 3.4.5\nnodejs 24.19.0 22.22.0 system\n', '.tool-versions'),
'24.19.0',
)
assert.equal(parseNodeVersionFile('node 22.22.0 # project runtime\n', '.tool-versions'), '22.22.0')
})

test('invalid or unsupported version files fail clearly', () => {
assert.throws(
() => parseNodeVersionFile('ruby 3.4.5\n', '.tool-versions'),
/does not declare a Node\.js version/,
)
assert.throws(
() => parseNodeVersionFile('22.22.0\n24.19.0\n', '.node-version'),
/must contain exactly one/,
)
assert.throws(
() => parseNodeVersionFile('system\n', '.node-version'),
/pnpm cannot install/,
)
assert.throws(
() => parseNodeVersionFile('lts/\n', '.nvmrc'),
/invalid Node\.js version selector/,
)
})

test('node-version-file resolves relative to working-directory', t => {
const workspace = createWorkspace(t)
mkdirSync(path.join(workspace, 'web'))
writeFileSync(path.join(workspace, 'web', '.node-version'), '24.19.0\n')

assert.equal(readNodeVersionFile(inputs({ workingDirectory: 'web', nodeVersionFile: '.node-version' })), '24.19.0')
})

test('a missing node-version-file fails with its resolved path', t => {
const workspace = createWorkspace(t)
const expectedPath = path.join(workspace, 'web', '.node-version')

assert.throws(
() => readNodeVersionFile(inputs({ workingDirectory: 'web', nodeVersionFile: '.node-version' })),
error => {
assert.equal(error.message, `The specified Node version file does not exist: ${expectedPath}`)
return true
},
)
})

function createWorkspace(t) {
const workspace = mkdtempSync(path.join(tmpdir(), 'pnpm-setup-node-version-'))
const previousWorkspace = process.env.GITHUB_WORKSPACE
process.env.GITHUB_WORKSPACE = workspace
t.after(() => {
if (previousWorkspace === undefined) {
delete process.env.GITHUB_WORKSPACE
} else {
process.env.GITHUB_WORKSPACE = previousWorkspace
}
rmSync(workspace, { recursive: true, force: true })
})
return workspace
}

function inputs(overrides = {}) {
return {
dest: 'setup-pnpm',
cache: false,
cacheDependencyPath: 'pnpm-lock.yaml',
workingDirectory: '.',
packageJsonFile: 'package.json',
install: false,
requireLockfile: false,
...overrides,
}
}
Loading