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
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,27 @@ Only one version of each runtime can be installed globally. If a runtime name is
| `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`. |
| `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: `.`. |
| `cache-dependency-path` | Path(s) to the pnpm lockfile, used to compute the cache key. Relative to `GITHUB_WORKSPACE`. Defaults to the shared workspace lockfile for members, or `pnpm-lock.yaml` inside `working-directory` otherwise. |
| `working-directory` | Directory the project lives in, relative to `GITHUB_WORKSPACE`. `pnpm install` runs there and `node-version-file` resolves relative to it. Package-manager selection and shared-lockfile caching follow the workspace root for members. 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. |

When `version` is omitted and the manifest specifies a range, setup checks the
package-manager document in `pnpm-lock.yaml` beside the project manifest. If the
locked pnpm version satisfies that range, setup installs it instead of resolving
a newer release from npm. This follows pnpm's version-switching rules: the
recorded specifier may differ, and prereleases are included when checking the
range. For workspace members, setup finds the nearest `pnpm-workspace.yaml`
within the checkout, checks its `packages` patterns and exclusions, and reads
the root manifest and shared lockfile. With `sharedWorkspaceLockfile: false`,
the member's lockfile supplies the locked version. Excluded projects keep their own manifest and
lockfile. An explicit `version` input still takes precedence. Custom lockfile
locations and workspace roots outside the checkout retain the existing
version-selection behavior. Frozen installs still validate the lockfile
against the manifest through pnpm.

## Outputs

| Name | Description |
Expand Down Expand Up @@ -167,7 +181,8 @@ When the project is not at the repository root — a site in `docs/`, an app in
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.
root, as it has always been — its default follows the project's shared
workspace lockfile, or its own lockfile for standalone and per-project installs.
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.
Expand Down
11 changes: 6 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,16 @@ inputs:
description: |
File path to the pnpm lockfile, whose contents hash is used as a cache
key. Relative to the repository root (GITHUB_WORKSPACE). Defaults to
`pnpm-lock.yaml` inside `working-directory`.
the shared workspace lockfile for workspace members, or `pnpm-lock.yaml`
inside `working-directory` for standalone and per-project installs.
required: false
working-directory:
description: |
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 `node-version-file` and the default `cache-dependency-path`
relative to it. Defaults to the repository root.
(GITHUB_WORKSPACE). The action runs `pnpm install` there and resolves
`node-version-file` relative to it. For workspace members, package-manager
selection and shared-lockfile caching follow the workspace root.
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
312 changes: 156 additions & 156 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 src/install-runtime/*.test.mjs src/pnpm-install/*.test.mjs src/pnpm-commands.test.mjs"
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs src/install-pnpm/*.test.mjs src/install-runtime/*.test.mjs src/pnpm-install/*.test.mjs src/pnpm-commands.test.mjs"
},
"dependencies": {
"@actions/cache": "^6.2.0",
Expand Down
17 changes: 14 additions & 3 deletions src/cache-restore/run.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ const mocks = {
export const info = () => {}
`,
'@actions/exec': String.raw`export const getExecOutput = async () => ({ stdout: '/pnpm-store\n' })`,
'@actions/glob': `export const hashFiles = async () => 'lockfile-hash'`,
'../lockfile-verification-cache': `export const restoreVerificationCache = async () => {}`,
'@actions/glob': `import { mock } from 'node:test'; export const hashFiles = mock.fn(async () => 'lockfile-hash')`,
'../lockfile-verification-cache': `import { mock } from 'node:test'; export const restoreVerificationCache = mock.fn(async () => {})`,
}
const bundle = await build({
stdin: {
Expand All @@ -31,6 +31,8 @@ const bundle = await build({
export { runSaveCache } from '../cache-save/run.ts'
export * as cache from '@actions/cache'
export * as core from '@actions/core'
export * as glob from '@actions/glob'
export * as verification from '../lockfile-verification-cache'
`,
resolveDir: fileURLToPath(new URL('.', import.meta.url)),
},
Expand All @@ -48,7 +50,7 @@ const bundle = await build({
},
}],
})
const { runRestoreCache, finalizeCache, runSaveCache, cache, core } = await import(
const { runRestoreCache, finalizeCache, runSaveCache, cache, core, glob, verification } = await import(
`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}`
)

Expand All @@ -63,6 +65,15 @@ beforeEach(() => {
cache.restoreCache.mock.resetCalls()
cache.restoreCache.mock.mockImplementation(async () => undefined)
cache.saveCache.mock.resetCalls()
glob.hashFiles.mock.resetCalls()
verification.restoreVerificationCache.mock.resetCalls()
})

test('verification caching remains enabled when store caching and installation are disabled', async () => {
await runRestoreCache({ ...inputs, cache: false, install: false }, runtimes)
assert.deepEqual(glob.hashFiles.mock.calls[0].arguments, ['pnpm-lock.yaml'])
assert.deepEqual(verification.restoreVerificationCache.mock.calls[0].arguments, ['lockfile-hash'])
assert.equal(cache.restoreCache.mock.callCount(), 0)
})

test('restore asks for the current lockfile before the broader runtime fallback', async () => {
Expand Down
16 changes: 15 additions & 1 deletion src/cache-restore/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { getExecOutput } from '@actions/exec'
import { hashFiles } from '@actions/glob'
import { randomUUID } from 'crypto'
import os from 'os'
import path from 'path'
import { Inputs } from '../inputs'
import { RuntimeRequest } from '../install-runtime'
import { restoreVerificationCache } from '../lockfile-verification-cache'
import { lockfileDir } from '../pnpm-install/lockfile'
import { removeWindowsExtendedPathPrefix } from '../windows-path'
import { getCacheKeyPrefix, getRestoreKeys, getSaveCacheKey, isLockfileExactHit } from './keys'

Expand All @@ -19,7 +21,7 @@ export async function runRestoreCache(
inputs: Inputs,
runtimes: readonly RuntimeRequest[],
): Promise<RestoredCache | undefined> {
const fileHash = await hashFiles(inputs.cacheDependencyPath)
const fileHash = await hashFiles(resolveCacheDependencyPath(inputs))
if (!fileHash) {
// Both caches are keyed on the lockfile, so neither can be restored
// without one. Only the store cache was asked for by name.
Expand All @@ -40,6 +42,18 @@ export async function runRestoreCache(
return runRestoreStoreCache(fileHash, runtimes)
}

/**
* Ask the installed pnpm which lockfile it uses, including workspace membership
* and per-project configuration. Explicit paths retain checkout-relative semantics.
*/
export function resolveCacheDependencyPath(inputs: Inputs): string {
if (inputs.cacheDependencyPath) return inputs.cacheDependencyPath
const checkout = process.env.GITHUB_WORKSPACE ?? process.cwd()
const project = path.resolve(checkout, inputs.workingDirectory)
const pnpmBin = path.join(inputs.dest, process.platform === 'win32' ? 'pnpm.exe' : 'pnpm')
return path.relative(checkout, path.join(lockfileDir(project, pnpmBin), 'pnpm-lock.yaml'))
}

async function runRestoreStoreCache(
fileHash: string,
runtimes: readonly RuntimeRequest[],
Expand Down
46 changes: 18 additions & 28 deletions src/inputs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface Inputs {
readonly version?: string
readonly dest: string
readonly cache: boolean
readonly cacheDependencyPath: string
readonly cacheDependencyPath?: string
/** Where the project lives, relative to GITHUB_WORKSPACE. */
readonly workingDirectory: string
/** The manifest to read config from, relative to GITHUB_WORKSPACE. */
Expand Down Expand Up @@ -65,7 +65,6 @@ const MANIFEST_NAMES = ['package.json', 'package.yaml'] as const
function resolveProjectPaths(): {
workingDirectory: string
packageJsonFile: string
cacheDependencyPath: string
} {
const workingDirectoryInput = getInput('working-directory').trim()
const packageJsonFileInput = getInput('package-json-file').trim()
Expand All @@ -81,30 +80,16 @@ function resolveProjectPaths(): {
if (packageJsonFileInput) {
const packageJsonFile = expandTilde(packageJsonFileInput)
const workingDirectory = path.dirname(packageJsonFile)
return { workingDirectory, packageJsonFile, cacheDependencyPath: resolveCacheDependencyPath(workingDirectory) }
return { workingDirectory, packageJsonFile }
}

const workingDirectory = expandTilde(workingDirectoryInput || '.')
return {
workingDirectory,
packageJsonFile: findManifest(workingDirectory),
cacheDependencyPath: resolveCacheDependencyPath(workingDirectory),
}
}

/**
* `cache-dependency-path` stays relative to the repository root, the way it
* has always been documented — rewriting a value the workflow set would turn
* an existing `web/pnpm-lock.yaml` into `web/web/pnpm-lock.yaml`. Only the
* default follows the project, so a subdirectory finds its own lockfile
* without the workflow having to name it twice.
*/
function resolveCacheDependencyPath(workingDirectory: string): string {
const configured = getInput('cache-dependency-path').trim()
if (configured) return expandTilde(configured)
return path.join(workingDirectory, 'pnpm-lock.yaml')
}

/**
* pnpm reads `package.yaml` as well as `package.json`, and without an input
* naming the file the action has to look. Falls back to `package.json` so the
Expand All @@ -125,17 +110,22 @@ function isSupportedRuntime(name: string): name is RuntimeName {
return (SUPPORTED_RUNTIMES as readonly string[]).includes(name)
}

export const getInputs = (): Inputs => ({
version: getInput('version'),
dest: path.resolve(expandTilde(getInput('dest', options))),
cache: getBooleanInput('cache'),
...resolveProjectPaths(),
runtime: parseRuntime(),
nodeVersionFile: parseNodeVersionFileInput(),
install: getBooleanInput('install'),
requireLockfile: getBooleanInput('require-lockfile'),
token: getInput('token') || undefined,
})
export const getInputs = (): Inputs => {
const cache = getBooleanInput('cache')
const cacheDependencyPath = getInput('cache-dependency-path').trim()
return {
version: getInput('version'),
dest: path.resolve(expandTilde(getInput('dest', options))),
cache,
...resolveProjectPaths(),
cacheDependencyPath: cacheDependencyPath ? expandTilde(cacheDependencyPath) : undefined,
runtime: parseRuntime(),
nodeVersionFile: parseNodeVersionFileInput(),
install: getBooleanInput('install'),
requireLockfile: getBooleanInput('require-lockfile'),
token: getInput('token') || undefined,
}
}

function parseNodeVersionFileInput(): string | false | undefined {
const value = getInput('node-version-file').trim()
Expand Down
67 changes: 67 additions & 0 deletions src/install-pnpm/locked-version.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, test } from 'node:test'
import { readLockedPnpmVersion } from './locked-version.ts'

const directories = []
afterEach(() => {
for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true })
})

function fixture(specifier = '>=12.0.0 <13.0.0', version = '12.8.1') {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-locked-pnpm-'))
directories.push(directory)
fs.writeFileSync(path.join(directory, 'pnpm-lock.yaml'), `---
lockfileVersion: '9.0'
importers:
.:
packageManagerDependencies:
pnpm:
specifier: '${specifier}'
version: ${version}
---
lockfileVersion: '9.0'
importers:
.: {}
`)
return directory
}

test('uses the locked version for a matching range, without resolving latest', () => {
assert.equal(readLockedPnpmVersion(fixture(), '>=12.0.0 <13.0.0'), '12.8.1')
})

test('reuses a satisfying locked version after the manifest range changes', () => {
assert.equal(readLockedPnpmVersion(fixture('12'), '>=12.0.0 <13.0.0'), '12.8.1')
assert.equal(readLockedPnpmVersion(fixture('12.8.1'), '^12.0.0'), '12.8.1')
})

test('reuses a locked prerelease with pnpm package-manager range semantics', () => {
assert.equal(readLockedPnpmVersion(fixture('12', '12.0.0-alpha.18'), '12'), '12.0.0-alpha.18')
assert.equal(readLockedPnpmVersion(fixture('12', '12.0.0-alpha.18'), '>=12.0.0'), undefined)
assert.equal(readLockedPnpmVersion(fixture('13', '13.0.0-alpha.1'), '12'), undefined)
})

test('does not reuse a version outside the requested range', () => {
assert.equal(readLockedPnpmVersion(fixture('>=12.0.0 <13.0.0', '11.28.1'), '>=12.0.0 <13.0.0'), undefined)
})

test('does not interpret a dist-tag as a semver range', () => {
assert.equal(readLockedPnpmVersion(fixture('latest'), 'latest'), undefined)
})

test('falls back when no lockfile or package-manager document exists', () => {
const directory = fixture()
fs.unlinkSync(path.join(directory, 'pnpm-lock.yaml'))
assert.equal(readLockedPnpmVersion(directory, '12'), undefined)
fs.writeFileSync(path.join(directory, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\nimporters:\n .: {}\n")
assert.equal(readLockedPnpmVersion(directory, '12'), undefined)
})

test('rejects a malformed lockfile rather than silently selecting latest', () => {
const directory = fixture()
fs.writeFileSync(path.join(directory, 'pnpm-lock.yaml'), 'importers: [')
assert.throws(() => readLockedPnpmVersion(directory, '12'))
})
34 changes: 34 additions & 0 deletions src/install-pnpm/locked-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { readFileSync } from 'fs'
import path from 'path'
import semver from 'semver'
import { parseAllDocuments } from 'yaml'

/**
* Reuse a compatible package-manager pin from the lockfile's environment
* document. Exact versions and registry tags bypass lockfile selection.
*/
export function readLockedPnpmVersion(directory: string, spec: string): string | undefined {
// Exact pins already identify the binary; tags need registry resolution.
if (semver.valid(spec) || !semver.validRange(spec)) return undefined

let content: string
try {
content = readFileSync(path.join(directory, 'pnpm-lock.yaml'), 'utf8')
Comment thread
MindTooth marked this conversation as resolved.
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
}

// pnpm 11+ stores package-manager dependencies in the first YAML document.
const document = parseAllDocuments(content)[0]
if (!document) return undefined
if (document.errors.length) throw document.errors[0]
const locked = document.toJSON()?.importers?.['.']?.packageManagerDependencies?.pnpm
// Match pnpm's version switching: keep a satisfying lock even after the
// specifier changes, and include prereleases when testing the range.
if (typeof locked?.version === 'string' && semver.valid(locked.version) &&
semver.satisfies(locked.version, spec, { includePrerelease: true })) {
return locked.version
}
return undefined
}
Loading