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
130 changes: 126 additions & 4 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,24 @@ permissions:

jobs:
pnpm-commands:
name: 'pnpm command execution (${{ matrix.os }})'
name: 'pnpm command execution (${{ matrix.os }} / pnpm ${{ matrix.version }})'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
version: ['11.22.0', '12.5.1']
steps:
- uses: actions/checkout@v7
- uses: ./
- id: pnpm
uses: ./
with:
version: '12.0.0'
version: ${{ matrix.version }}
runtime: node@24
- name: Test command execution and reject deprecation warnings
run: node --throw-deprecation --test src/pnpm-commands.test.mjs
env:
PNPM_TEST_BIN: ${{ steps.pnpm.outputs.bin-dest }}/pnpm${{ runner.os == 'Windows' && '.exe' || '' }}
run: node --throw-deprecation --test src/pnpm-commands.test.mjs src/pnpm-install/lockfile.test.mjs

smoke:
# Direct binary download + pnpm on PATH across OSes and architectures.
Expand Down Expand Up @@ -1067,3 +1071,121 @@ jobs:
echo "Expected a plain install to write a lockfile"; exit 1
fi
shell: bash


require-lockfile-standalone-project:
# A nested project pnpm installs on its own: `working-directory` has a
# manifest of its own and no workspace above it, so the lockfile at the
# checkout root is not the one pnpm installs from. `require-lockfile` has
# to say that before the install rather than let pnpm's own
# ERR_PNPM_NO_LOCKFILE report it afterwards.
name: 'require-lockfile (standalone project / ${{ matrix.os }})'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v7

- name: Set up a checkout-root lockfile and a nested project without one
# This repository's own pnpm-workspace.yaml has to go: with it in
# place the nested directory is a workspace member on the pnpm pinned
# below, which is a different case from the one under test.
run: |
set -e
rm -f pnpm-lock.yaml pnpm-workspace.yaml
cat > package.json <<'JSON'
{
"packageManager": "pnpm@12.0.0",
"dependencies": {
"is-odd": "3.0.1"
}
}
JSON
mkdir -p nested
cat > nested/package.json <<'JSON'
{
"dependencies": {
"is-even": "1.0.0"
}
}
JSON
shell: bash

- name: Put pnpm on PATH without installing
uses: ./
with:
version: '12.0.0'
install: false

- name: Write the lockfile at the checkout root only
run: pnpm install --lockfile-only
shell: bash

- id: standalone
continue-on-error: true
uses: ./
with:
version: '12.0.0'
working-directory: nested
require-lockfile: true

- name: 'Test: the lockfile above the project did not satisfy the check'
env:
OUTCOME: ${{ steps.standalone.outcome }}
run: |
set -e
echo "outcome: ${OUTCOME}"
if [ "${OUTCOME}" != "failure" ]; then
echo "Expected require-lockfile to fail for a project whose own lockfile is missing"; exit 1
fi
if [ -d nested/node_modules ]; then
echo "Expected the step to fail before pnpm installed anything"; exit 1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi
shell: bash

- name: 'Test: a lockfile of its own satisfies it'
run: pnpm --dir nested install --lockfile-only
shell: bash

- uses: ./
with:
version: '12.0.0'
working-directory: nested
require-lockfile: true

- name: 'Test: the nested project installed from its own lockfile'
run: |
set -e
if [ ! -d nested/node_modules/is-even ]; then
echo "Expected the nested project to install is-even"; exit 1
fi
shell: bash

- name: Make the nested project a member of a workspace instead
# The other branch: the lockfile a member installs from is the
# workspace root's, so its own is neither needed nor consulted.
run: |
set -e
rm -rf nested/node_modules nested/pnpm-lock.yaml
printf "packages:\n - 'nested'\n" > pnpm-workspace.yaml
pnpm install --lockfile-only
if [ -f nested/pnpm-lock.yaml ]; then
echo "Expected the workspace to keep one lockfile, at its root"; exit 1
fi
shell: bash

- uses: ./
with:
version: '12.0.0'
working-directory: nested
require-lockfile: true

- name: 'Test: the member installed from the workspace root lockfile'
run: |
set -e
if [ ! -d nested/node_modules/is-even ]; then
echo "Expected the member to install is-even"; exit 1
fi
shell: bash
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,16 @@ known to be complete. The record count cannot be bounded there, so only the
```

Fails unless `pnpm-lock.yaml` already describes the install, and runs
`pnpm install --frozen-lockfile` when it does. If no lockfile is found — in
`working-directory` or above it, the way pnpm searches — the action fails
before running pnpm, saying so directly rather than through an install that
was never going to succeed.
`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
Expand Down
198 changes: 99 additions & 99 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-commands.test.mjs"
"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"
},
"dependencies": {
"@actions/cache": "^6.2.0",
Expand Down
42 changes: 42 additions & 0 deletions src/pnpm-commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ const recorder = `
}, 25)
`
writeFileSync(path.join(project, 'install'), recorder)
writeFileSync(path.join(project, 'root'), `
if (process.env.PNPM_TEST_WORKSPACE_ROOT) {
console.log(require('node:path').join(process.env.PNPM_TEST_WORKSPACE_ROOT, 'node_modules'))
} else {
process.exitCode = 1
}
`)
writeFileSync(path.join(project, 'config'), `console.log(process.env.PNPM_TEST_SHARED_LOCKFILE || 'true')`)
const shimScript = path.join(dest, 'record.cjs')
writeFileSync(shimScript, recorder)
const quoteShell = value => `'${value.replaceAll("'", "'\\''")}'`
Expand Down Expand Up @@ -80,11 +88,15 @@ beforeEach(t => {
INPUT_RUNTIME: '',
PNPM_TEST_RECORD: record,
PNPM_TEST_EXIT_CODE: '0',
PNPM_TEST_WORKSPACE_ROOT: '',
PNPM_TEST_SHARED_LOCKFILE: 'true',
})
// Simulate the PATH prepared by setup, with self-update's shim first.
const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH'
process.env[pathKey] = [path.join(dest, 'bin'), dest, process.env[pathKey]].join(path.delimiter)
rmSync(record, { force: true })
rmSync(path.join(root, 'pnpm-lock.yaml'), { force: true })
writeFileSync(path.join(project, 'pnpm-lock.yaml'), '')
})

test('install uses the native binary with a relative dest and a separate project directory', () => {
Expand All @@ -97,6 +109,36 @@ test('install uses the native binary with a relative dest and a separate project
assert.equal(path.dirname(actual.executable), dest)
})

test('a standalone project rejects an unrelated parent lockfile before invoking install', () => {
rmSync(path.join(project, 'pnpm-lock.yaml'))
writeFileSync(path.join(root, 'pnpm-lock.yaml'), '')
runPnpmInstall(getInputs())
assert.equal(process.exitCode, 1)
assert.throws(() => readFileSync(record), { code: 'ENOENT' })
})

test('a shared workspace rejects a stale member lockfile when its root lockfile is missing', () => {
process.env.PNPM_TEST_WORKSPACE_ROOT = root
runPnpmInstall(getInputs())
assert.equal(process.exitCode, 1)
assert.throws(() => readFileSync(record), { code: 'ENOENT' })
})

test('a workspace member installs using only the root lockfile', () => {
process.env.PNPM_TEST_WORKSPACE_ROOT = root
rmSync(path.join(project, 'pnpm-lock.yaml'))
writeFileSync(path.join(root, 'pnpm-lock.yaml'), '')
runPnpmInstall(getInputs())
assert.deepEqual(JSON.parse(readFileSync(record, 'utf8')).args, ['--frozen-lockfile'])
})

test('a workspace with per-project lockfiles installs using the member lockfile', () => {
process.env.PNPM_TEST_WORKSPACE_ROOT = root
process.env.PNPM_TEST_SHARED_LOCKFILE = 'false'
runPnpmInstall(getInputs())
assert.deepEqual(JSON.parse(readFileSync(record, 'utf8')).args, ['--frozen-lockfile'])
})

test('pruning awaits the self-updated shim ahead of the original executable on PATH', async () => {
await pruneStore(getInputs())
assert.deepEqual(JSON.parse(readFileSync(record, 'utf8')).args, ['store', 'prune'])
Expand Down
41 changes: 14 additions & 27 deletions src/pnpm-install/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { spawnSync } from 'child_process'
import { existsSync } from 'fs'
import path from 'path'
import { Inputs } from '../inputs'
import { lockfileDir } from './lockfile'

export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs.runtime)) {
const args = ['install']
Expand Down Expand Up @@ -35,20 +36,25 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs
}

const workingDirectory = path.resolve(GITHUB_WORKSPACE, inputs.workingDirectory)
const pnpmBin = path.join(inputs.dest, process.platform === 'win32' ? 'pnpm.exe' : 'pnpm')

// Answer this before running anything: a missing lockfile is the whole
// reason `require-lockfile` exists, and pnpm's own error for it arrives
// after an install that was never going to succeed.
if (inputs.requireLockfile && !findLockfile(workingDirectory, GITHUB_WORKSPACE)) {
setFailed(
'`require-lockfile` is set but no pnpm-lock.yaml was found in ' +
`${inputs.workingDirectory} or above it. Commit the lockfile, or unset ` +
'`require-lockfile` to let pnpm resolve and write one.',
)
return
if (inputs.requireLockfile) {
const lockfileDirectory = lockfileDir(workingDirectory, pnpmBin)
if (!existsSync(path.join(lockfileDirectory, 'pnpm-lock.yaml'))) {
const searched = path.relative(GITHUB_WORKSPACE, lockfileDirectory) || '.'
setFailed(
'`require-lockfile` is set but no pnpm-lock.yaml was found in ' +
`${searched}, which is where an install in ${inputs.workingDirectory} ` +
'reads one. Commit the lockfile, or unset `require-lockfile` to let ' +
'pnpm resolve and write one.',
)
return
}
}

const pnpmBin = path.join(inputs.dest, process.platform === 'win32' ? 'pnpm.exe' : 'pnpm')
startGroup(`Running ${command}...`)
const { error, status, signal } = spawnSync(pnpmBin, args, {
stdio: 'inherit',
Expand All @@ -71,23 +77,4 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs
}
}

/**
* pnpm keeps the lockfile at the workspace root, which it finds by walking up
* from wherever it runs — so a project that is a workspace member has its
* lockfile above itself. Search the same way rather than only where the
* install runs, or a member directory would be reported as having none.
*/
function findLockfile(from: string, workspaceRoot: string): string | undefined {
let current = from
for (;;) {
const candidate = path.join(current, 'pnpm-lock.yaml')
if (existsSync(candidate)) return candidate
if (current === workspaceRoot) return undefined

const parent = path.dirname(current)
if (parent === current) return undefined
current = parent
}
}

export default runPnpmInstall
Loading
Loading