Skip to content
Merged
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
97 changes: 97 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -773,3 +773,100 @@ jobs:
exit 1
fi
shell: bash

working-directory:
# A project whose root is not the repository root. Running the install at
# the repository root would exit 0 having installed nothing, so assert the
# dependency actually landed. `cache: true` is part of the test: without
# `cache-dependency-path` rebasing onto the working directory it matches no
# lockfile and the restore throws outright.
name: 'Project in a subdirectory'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Move this project into docs/
run: |
set -e
mkdir -p docs
mv package.json pnpm-lock.yaml pnpm-workspace.yaml docs/
node -e "
const fs = require('fs')
const manifest = JSON.parse(fs.readFileSync('docs/package.json', 'utf8'))
manifest.packageManager = 'pnpm@12.0.0'
fs.writeFileSync('docs/package.json', JSON.stringify(manifest, null, 2))
"
shell: bash

- id: pnpm
uses: ./
with:
working-directory: docs
runtime: node@22
cache: true

- name: 'Test: the subdirectory project was installed'
run: |
set -e
if [ ! -d docs/node_modules/@actions/cache ]; then
echo "Expected pnpm install to populate docs/node_modules"
ls -la docs || true
exit 1
fi
if [ -d node_modules ]; then
echo "Did not expect node_modules at the repository root"
exit 1
fi
# `packageManager` was read from docs/package.json, not the root.
pnpm_version="$(pnpm --version)"
if [ "${pnpm_version}" != "12.0.0" ]; then
echo "Expected pnpm 12.0.0 from docs/package.json, got ${pnpm_version}"
exit 1
fi
shell: bash

working-directory-deprecated-input:
# `package-json-file` is deprecated but must keep working exactly as it
# did: the directory holding the file becomes the working directory, and
# an explicit `cache-dependency-path` stays relative to the repository
# root. Rebasing it onto the working directory would look for
# `web/web/pnpm-lock.yaml` and fail the restore.
name: 'Deprecated package-json-file still works'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Move this project into web/
run: |
set -e
mkdir -p web
mv package.json pnpm-lock.yaml pnpm-workspace.yaml web/
node -e "
const fs = require('fs')
const manifest = JSON.parse(fs.readFileSync('web/package.json', 'utf8'))
manifest.packageManager = 'pnpm@12.0.0'
fs.writeFileSync('web/package.json', JSON.stringify(manifest, null, 2))
"
shell: bash

- uses: ./
with:
package-json-file: web/package.json
cache: true
cache-dependency-path: web/pnpm-lock.yaml

- name: 'Test: the install ran in web/'
run: |
set -e
if [ ! -d web/node_modules/@actions/cache ]; then
echo "Expected pnpm install to populate web/node_modules"
ls -la web || true
exit 1
fi
pnpm_version="$(pnpm --version)"
if [ "${pnpm_version}" != "12.0.0" ]; then
echo "Expected pnpm 12.0.0 from web/package.json, got ${pnpm_version}"
exit 1
fi
shell: bash

30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ Only one version of each runtime can be installed globally. If a runtime name is
| `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`. |
| `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. Default: `pnpm-lock.yaml`. |
| `package-json-file` | Path to `package.json` (relative to `GITHUB_WORKSPACE`). Default: `package.json`. |
| `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: `.`. |
| `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). |
| `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. |

Expand Down Expand Up @@ -93,6 +94,31 @@ jobs:
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:

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

```yaml
Expand Down
27 changes: 23 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,32 @@ inputs:
required: false
default: 'false'
cache-dependency-path:
description: File path to the pnpm lockfile, which contents hash will be used as a cache key
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`.
required: false
default: 'pnpm-lock.yaml'
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 `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
whose root is not the repository root.
required: false
default: '.'
package-json-file:
description: File path to the package.json to read `packageManager` and `devEngines.runtime` configuration. This path must be relative to the repository root (GITHUB_WORKSPACE).
description: |
Deprecated. Use `working-directory` instead: the manifest is read
from that directory, which is also where `pnpm install` runs. Still
honoured when set on its own — the directory containing the file
becomes the working directory.
required: false
default: 'package.json'
deprecationMessage: 'The package-json-file input is deprecated; use working-directory instead.'
install:
description: |
Whether to run `pnpm install` after pnpm and the runtime are set up.
Expand Down
346 changes: 173 additions & 173 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/keys.test.mjs"
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs"
},
"dependencies": {
"@actions/cache": "^4.1.0",
Expand Down
76 changes: 74 additions & 2 deletions src/inputs/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { getBooleanInput, getInput, InputOptions } from '@actions/core'
import expandTilde from 'expand-tilde'
import { existsSync } from 'fs'
import path from 'path'

export type RuntimeName = 'node' | 'bun' | 'deno'

Expand All @@ -15,6 +17,9 @@ export interface Inputs {
readonly dest: string
readonly cache: boolean
readonly cacheDependencyPath: string
/** Where the project lives, relative to GITHUB_WORKSPACE. */
readonly workingDirectory: string
/** The manifest to read config from, relative to GITHUB_WORKSPACE. */
readonly packageJsonFile: string
readonly runtime?: RuntimeInput
readonly install: boolean
Expand Down Expand Up @@ -47,6 +52,74 @@ function parseRuntime(): RuntimeInput | undefined {
return { name, version }
}

const MANIFEST_NAMES = ['package.json', 'package.yaml'] as const

/**
* `working-directory` says where the project is; `package-json-file` said
* which file to read. The second is deprecated onto the first, because the
* directory holding the manifest is also the only sensible place to run
* `pnpm install` — running it at the repository root installs nothing at all
* when the project lives one level down.
*/
function resolveProjectPaths(): {
workingDirectory: string
packageJsonFile: string
cacheDependencyPath: string
} {
const workingDirectoryInput = getInput('working-directory').trim()
const packageJsonFileInput = getInput('package-json-file').trim()

if (workingDirectoryInput && workingDirectoryInput !== '.' && packageJsonFileInput) {
throw new Error(
'Both `working-directory` and the deprecated `package-json-file` are set. ' +
'Remove `package-json-file`: `working-directory` already covers where the manifest ' +
'is read from and where `pnpm install` runs.',
)
}

if (packageJsonFileInput) {
const packageJsonFile = expandTilde(packageJsonFileInput)
const workingDirectory = path.dirname(packageJsonFile)
return { workingDirectory, packageJsonFile, cacheDependencyPath: resolveCacheDependencyPath(workingDirectory) }
}

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
* "no manifest" path still reports the name a user expects.
*/
function findManifest(workingDirectory: string): string {
const { GITHUB_WORKSPACE } = process.env
if (GITHUB_WORKSPACE) {
for (const name of MANIFEST_NAMES) {
const candidate = path.join(workingDirectory, name)
if (existsSync(path.resolve(GITHUB_WORKSPACE, candidate))) return candidate
}
}
return path.join(workingDirectory, MANIFEST_NAMES[0])
}

function isSupportedRuntime(name: string): name is RuntimeName {
return (SUPPORTED_RUNTIMES as readonly string[]).includes(name)
}
Expand All @@ -55,8 +128,7 @@ export const getInputs = (): Inputs => ({
version: getInput('version'),
dest: parseInputPath('dest'),
cache: getBooleanInput('cache'),
cacheDependencyPath: parseInputPath('cache-dependency-path'),
packageJsonFile: parseInputPath('package-json-file'),
...resolveProjectPaths(),
runtime: parseRuntime(),
install: getBooleanInput('install'),
token: getInput('token') || undefined,
Expand Down
2 changes: 1 addition & 1 deletion src/install-pnpm/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function readTargetVersion(opts: {

if (GITHUB_WORKSPACE) {
try {
const content = readFileSync(path.join(GITHUB_WORKSPACE, packageJsonFile), 'utf8');
const content = readFileSync(path.resolve(GITHUB_WORKSPACE, packageJsonFile), 'utf8');
const manifest = packageJsonFile.endsWith('.yaml')
? parseYaml(content, { merge: true })
: JSON.parse(content)
Expand Down
2 changes: 1 addition & 1 deletion src/install-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function readManifest(inputs: Inputs): Record<string, unknown> | undefined {
const { GITHUB_WORKSPACE } = process.env
if (!GITHUB_WORKSPACE) return undefined
try {
const content = readFileSync(path.join(GITHUB_WORKSPACE, inputs.packageJsonFile), 'utf8')
const content = readFileSync(path.resolve(GITHUB_WORKSPACE, inputs.packageJsonFile), 'utf8')
return inputs.packageJsonFile.endsWith('.yaml')
? parseYaml(content, { merge: true })
: JSON.parse(content)
Expand Down
4 changes: 2 additions & 2 deletions src/pnpm-install/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs
info('GITHUB_WORKSPACE is not set; skipping `pnpm install`.')
return
}
const manifestPath = path.join(GITHUB_WORKSPACE, inputs.packageJsonFile)
const manifestPath = path.resolve(GITHUB_WORKSPACE, inputs.packageJsonFile)
if (!existsSync(manifestPath)) {
info(`No ${inputs.packageJsonFile} found in workspace; skipping \`pnpm install\`.`)
return
Expand All @@ -32,7 +32,7 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs
startGroup(`Running pnpm ${args.join(' ')}...`)
const { error, status } = spawnSync('pnpm', args, {
stdio: 'inherit',
cwd: GITHUB_WORKSPACE,
cwd: path.resolve(GITHUB_WORKSPACE, inputs.workingDirectory),
shell: true,
})
endGroup()
Expand Down
Loading