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
89 changes: 74 additions & 15 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ jobs:
matrix:
include:
- os: ubuntu-latest
version: '12.0.0-beta.4'
version: '12.0.0'
- os: ubuntu-24.04-arm
version: '12.0.0-beta.4'
version: '12.0.0'
- os: macos-latest
version: '12.0.0-beta.4'
version: '12.0.0'
- os: windows-latest
version: '12.0.0-beta.4'
version: '12.0.0'

steps:
- uses: actions/checkout@v7
Expand Down Expand Up @@ -100,6 +100,11 @@ jobs:
# pnpm can report a Windows store path with the extended-length `\\?\`
# prefix. Cache paths must use the regular drive-path form because the
# cache toolkit otherwise interprets `?` as a glob in the root segment.
#
# The pnpm version below is load-bearing, not stale: 12.0.0 reports a
# plain `D:\.pnpm-store\v11` for this store-dir, so only an older
# release still reproduces the prefix this job exists to cover. Do not
# bump it without checking that `pnpm store path` still starts `\\?\`.
name: 'Cache Windows extended-length store path'
runs-on: windows-latest
steps:
Expand Down Expand Up @@ -306,7 +311,7 @@ jobs:
- id: pnpm
uses: ./
with:
version: '12.0.0-beta.4'
version: '12.0.0'
runtime: node@${{ matrix.major }}

- name: 'Test: node binary on PATH'
Expand Down Expand Up @@ -347,7 +352,7 @@ jobs:
- uses: actions/checkout@v7
- uses: ./
with:
version: '12.0.0-beta.4'
version: '12.0.0'
runtime: bun@latest
- name: 'Test: bun on PATH'
run: |
Expand All @@ -363,7 +368,7 @@ jobs:
- uses: actions/checkout@v7
- uses: ./
with:
version: '12.0.0-beta.4'
version: '12.0.0'
runtime: deno@2
- name: 'Test: deno on PATH'
run: |
Expand All @@ -388,7 +393,7 @@ jobs:
rm -f pnpm-lock.yaml
cat > package.json <<'EOF'
{
"packageManager": "pnpm@12.0.0-beta.4",
"packageManager": "pnpm@12.0.0",
"devEngines": {
"runtime": [
{ "name": "bun", "version": "1.3.12", "onFail": "warn" },
Expand Down Expand Up @@ -474,7 +479,7 @@ jobs:
rm -f pnpm-lock.yaml
cat > package.json <<'EOF'
{
"packageManager": "pnpm@12.0.0-beta.4",
"packageManager": "pnpm@12.0.0",
"devEngines": {
"runtime": { "name": "node", "version": "^20.0.0", "onFail": "download" }
},
Expand Down Expand Up @@ -521,7 +526,7 @@ jobs:
rm -f pnpm-lock.yaml
cat > package.json <<'EOF'
{
"packageManager": "pnpm@12.0.0-rc.2",
"packageManager": "pnpm@12.0.0",
"devEngines": {
"runtime": { "name": "node", "version": "20.19.0", "onFail": "download" }
}
Expand Down Expand Up @@ -569,7 +574,7 @@ jobs:
rm -f pnpm-lock.yaml
cat > package.json <<'EOF'
{
"packageManager": "pnpm@12.0.0-rc.2",
"packageManager": "pnpm@12.0.0",
"devEngines": {
"runtime": { "name": "node", "version": "20.19.0", "onFail": "download" }
}
Expand Down Expand Up @@ -616,7 +621,7 @@ jobs:
rm -f pnpm-lock.yaml
cat > package.json <<'EOF'
{
"packageManager": "pnpm@12.0.0-beta.4",
"packageManager": "pnpm@12.0.0",
"devEngines": {
"runtime": { "name": "node", "version": "^20.0.0", "onFail": "download" }
}
Expand Down Expand Up @@ -653,7 +658,7 @@ jobs:
rm -f pnpm-lock.yaml
cat > package.json <<'EOF'
{
"packageManager": "pnpm@12.0.0-beta.4",
"packageManager": "pnpm@12.0.0",
"dependencies": {
"is-odd": "3.0.1"
}
Expand All @@ -663,7 +668,7 @@ jobs:

- uses: ./
with:
version: '12.0.0-beta.4'
version: '12.0.0'
install: false

- name: 'Test: node_modules was not populated'
Expand All @@ -690,7 +695,7 @@ jobs:
- id: pnpm
uses: ./
with:
version: '12.0.0-beta.4'
version: '12.0.0'

- name: 'Test: pnpm works, runtime outputs are empty'
env:
Expand All @@ -714,3 +719,57 @@ jobs:
exit 1
fi
shell: bash

cache-lockfile-verification:
# The action caches pnpm's lockfile verification log, which lives in
# `cacheDir`. Newer pnpm reports that via `pnpm cache path`; older
# releases have no such subcommand and the action derives pnpm's
# per-platform default instead. Guard that derivation against pnpm's own.
name: 'Lockfile verification cache (${{ matrix.os }}, cache=${{ matrix.cache }})'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# The log is cached independently of the store, so the store-less
# configuration has to reach it too.
- os: ubuntu-latest
cache: false
- os: ubuntu-latest
cache: true
- os: macos-latest
cache: true
- os: windows-latest
cache: true
steps:
- uses: actions/checkout@v7

- name: Configure a supply-chain policy so the verification runs
# A one-minute floor activates the check without holding back any
# version this repo already locks.
run: |
printf '\nminimumReleaseAge: 1\n' >> pnpm-workspace.yaml
shell: bash

- uses: ./
with:
version: '12.0.0'
runtime: node@22
cache: ${{ matrix.cache }}

- name: 'Test: pnpm wrote the verification log where the action looks for it'
run: |
set -e
case "$RUNNER_OS" in
Linux) cacheDir="${XDG_CACHE_HOME:-$HOME/.cache}/pnpm" ;;
macOS) cacheDir="$HOME/Library/Caches/pnpm" ;;
Windows) cacheDir="$(cygpath -u "$LOCALAPPDATA")/pnpm-cache" ;;
*) echo "Unexpected RUNNER_OS: $RUNNER_OS"; exit 1 ;;
esac
echo "Expecting the verification log in ${cacheDir}"
if [ ! -f "${cacheDir}/lockfile-verified.jsonl" ]; then
echo "No lockfile-verified.jsonl there; the action would cache nothing"
ls -la "${cacheDir}" || true
exit 1
fi
shell: bash
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Only one version of each runtime can be installed globally. If a runtime name is
```json
// package.json
{
"packageManager": "pnpm@12.0.0-beta.4",
"packageManager": "pnpm@12.0.0",
"devEngines": {
"runtime": { "name": "node", "version": "^22.0.0", "onFail": "download" }
}
Expand Down Expand Up @@ -107,6 +107,46 @@ runtime selectors and the versions actually installed. Reordering
`devEngines.runtime` does not change the key — the same set of runtimes
produces the same store.

### Lockfile verification cache

pnpm v11 and newer check every lockfile entry before installing it — that each
entry pins an integrity hash, that a pinned tarball URL matches the registry's
own metadata, and, where configured, your `minimumReleaseAge` and `trustPolicy`
policies. The verdict is memoized in a sub-kilobyte file, so an unchanged
lockfile is not re-checked against the registry.

The action restores and saves that file on every run, independently of the
`cache` input, because a job that starts without it pays for the check every
time. On a repository with ~2000 lockfile entries and a warm store:

| | without the log | with it |
| --- | --- | --- |
| `minimumReleaseAge` + `trustPolicy` | 13.5s | 1.5s |
| no policies configured | 6.7s | 1.6s |

Reusing a verdict is not a weaker check: pnpm re-verifies whenever the lockfile
content changes, and whenever the recorded policy is looser than the one now
configured.

The log is uploaded as soon as the install that produced it finishes, not at
the end of the job, so nothing the job runs afterwards — its tests, its build,
any later step — can alter what other jobs restore. Dependency lifecycle
scripts are the exception, since they run inside the install itself, ahead of
the upload: pnpm refuses to run them unless the repository allow-lists the
package through `allowBuilds`, and a package on that list can already run code
in the job.

Before uploading, the action checks that the log grew the way an install grows
it: every record that predated the install still there, and no more new records
than installs it ran. A dependency's script that slips an extra record in is
caught by that, and the log is not cached — the next job re-verifies, which
costs seconds and nothing else.

A job that installs in a step of its own rather than through this action is
saved at the end of the job instead, since that is the first moment the log is
known to be complete. The record count cannot be bounded there, so only the
"nothing disappeared" half of the check applies.

### Skip `pnpm install`

For jobs that only need pnpm itself — e.g. `pnpm audit`, lockfile-only regeneration — set `install: false`:
Expand Down
8 changes: 6 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ branding:
inputs:
version:
description: |
Version of pnpm to install: an exact version (`12.0.0-beta.4`), a
Version of pnpm to install: an exact version (`12.0.0`), a
semver range (`^12.0.0`), or an npm dist-tag (`next-12`). Must resolve
to pnpm v11 or newer — the action downloads pnpm's native per-platform
executable directly, without needing Node.js or npm.
Expand Down Expand Up @@ -39,7 +39,11 @@ inputs:
project pins. Set the variable in the workflow to override.
required: false
cache:
description: Whether to cache the pnpm store directory and restore it before installing the runtimes
description: |
Whether to cache the pnpm store directory, keyed on the lockfile's
content hash, and restore it before installing the runtimes. The
results of pnpm's lockfile verification are cached either way — see
the README.
required: false
default: 'false'
cache-dependency-path:
Expand Down
354 changes: 178 additions & 176 deletions dist/index.js

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions src/cache-restore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ export async function restoreCache(
inputs: Inputs,
runtimes: readonly RuntimeRequest[],
): Promise<RestoredCache | undefined> {
if (!inputs.cache) return

if (!isFeatureAvailable()) {
warning('Cache is not available, skipping cache restoration')
// The lockfile verification cache is restored regardless of `cache`, so
// this is not gated on it — but only a workflow that asked for a cache
// by name should hear that it is unavailable.
if (inputs.cache) {
warning('Cache is not available, skipping cache restoration')
}
return
}

Expand Down
50 changes: 27 additions & 23 deletions src/cache-restore/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { hashFiles } from '@actions/glob'
import os from 'os'
import { Inputs } from '../inputs'
import { RuntimeRequest } from '../install-runtime'
import { restoreVerificationCache } from '../lockfile-verification-cache'
import { removeWindowsExtendedPathPrefix } from '../windows-path'
import { getCacheKeyPrefix, getPrimaryCacheKey } from './keys'

export interface RestoredCache {
Expand All @@ -16,15 +18,35 @@ export interface RestoredCache {
export async function runRestoreCache(
inputs: Inputs,
runtimes: readonly RuntimeRequest[],
): Promise<RestoredCache> {
const cachePath = await getCacheDirectory()
saveState('cache_path', cachePath)

): Promise<RestoredCache | undefined> {
const fileHash = await hashFiles(inputs.cacheDependencyPath)
if (!fileHash) {
throw new Error('Some specified paths were not resolved, unable to cache dependencies.')
// Both caches are keyed on the lockfile, so neither can be restored
// without one. Only the store cache was asked for by name.
if (inputs.cache) {
throw new Error('Some specified paths were not resolved, unable to cache dependencies.')
}
return
}

// Restored whether or not the store is cached: the log is a fraction of a
// kilobyte, and without it pnpm re-checks every lockfile entry against the
// registry on each run — seconds even on a repository that configures no
// supply-chain policies.
await restoreVerificationCache(fileHash)

if (!inputs.cache) return

return runRestoreStoreCache(fileHash, runtimes)
}

async function runRestoreStoreCache(
fileHash: string,
runtimes: readonly RuntimeRequest[],
): Promise<RestoredCache> {
const cachePath = await getCacheDirectory()
saveState('cache_path', cachePath)

const keyPrefix = getCacheKeyPrefix(process.env.RUNNER_OS, os.arch(), runtimes)
const provisionalKey = getPrimaryCacheKey(keyPrefix, fileHash)
debug(`Provisional cache key is ${provisionalKey}`)
Expand Down Expand Up @@ -63,21 +85,3 @@ async function getCacheDirectory() {
debug(`Cache folder is set to "${cacheFolderPath}"`)
return cacheFolderPath
}

/**
* `pnpm store path` may return an extended-length path on Windows. The `?` in
* that prefix is interpreted as a wildcard by `@actions/cache`, which rejects
* it as a glob in the root segment. Cache APIs do not need the extended-length
* form, so convert it back to a regular drive or UNC path.
*/
export function removeWindowsExtendedPathPrefix(cachePath: string): string {
const extendedPathPrefix = '\\\\?\\'
if (!cachePath.startsWith(extendedPathPrefix)) return cachePath

const pathWithoutPrefix = cachePath.slice(extendedPathPrefix.length)
const uncPrefix = 'UNC\\'
if (pathWithoutPrefix.toUpperCase().startsWith(uncPrefix)) {
return `\\\\${pathWithoutPrefix.slice(uncPrefix.length)}`
}
return pathWithoutPrefix
}
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
keepInstalledRuntimesAuthoritative,
logSkippedRuntime,
} from './install-runtime'
import { saveVerificationCache, snapshotVerificationLog } from './lockfile-verification-cache'
import setOutputs from './outputs'
import pnpmInstall from './pnpm-install'
import pruneStore from './pnpm-store-prune'
Expand Down Expand Up @@ -68,13 +69,26 @@ async function runMain() {

setOutputs(inputs, result.binDest, installed)

// Taken after the runtime installs, which append a verdict each, so the
// bound below covers only the install this action is about to run.
snapshotVerificationLog()

if (inputs.install) {
pnpmInstall(inputs, runtimes.length > 0)
// Uploaded here rather than in the post step so that whatever the job runs
// next cannot alter what later jobs restore. When `install` is false the
// log is not complete yet — the job installs in a step of its own, and the
// post step is the first moment it is known to be done.
await saveVerificationCache(1)
}
}

async function runPost() {
const inputs = JSON.parse(getState('inputs')) as Inputs
// Covers a job that installs in a later step of its own; when this action
// installed, the log was already saved then. Runs before the prune because
// pnpm versions before pnpm/pnpm#13893 delete the log during one.
await saveVerificationCache()
pruneStore(inputs)
await saveCache(inputs)
}
Expand Down
Loading
Loading