2
mirror of https://github.com/pnpm/action-setup.git synced 2026-08-13 16:01:31 +00:00

perf: cache pnpm's lockfile verification results

pnpm v11 and newer verify every lockfile entry against the configured
supply-chain policies (`minimumReleaseAge`, `trustPolicy`, ...) and memoize
the verdict in `<cacheDir>/lockfile-verified.jsonl`. The action cached only
the store, so every job started with that verdict missing and re-checked the
whole lockfile against the registry — on typescript-eslint's repository,
16.6s of a 17.6s install on Linux and 40.1s of 42.4s on Windows.

The verdict depends on the lockfile content and the policies, never on the
runner, so it is cached under its own key alongside the store cache and
restored without prefix fallback: an entry recorded for a different lockfile
could never be reused. Saving happens before `pnpm store prune`, which drops
the log along with the store's other derived state.

Anything that goes wrong here only costs the next job the re-verification, so
failures are reported as warnings instead of failing the build. Older pnpm
versions never write the log, and the post step then finds nothing to save.
This commit is contained in:
Zoltan Kochan
2026-08-13 13:59:37 +02:00
parent 0977fd9972
commit c0a6b0ff36
8 changed files with 329 additions and 152 deletions
+48
View File
@@ -329,3 +329,51 @@ jobs:
exit 1
fi
shell: bash
cache_lockfile_verification:
# The action caches pnpm's lockfile verification log, which lives in
# `cacheDir` — a directory pnpm resolves per platform and does not print.
# Guard the action's copy of that default against pnpm's own.
name: 'Lockfile verification cache (${{ matrix.os }})'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up a project with a supply-chain policy
# A one-minute floor activates the verification without holding back
# any version the install resolves.
run: |
echo '{"dependencies":{"is-odd":"3.0.1"}}' > package.json
printf 'packages:\n - .\nminimumReleaseAge: 1\n' > pnpm-workspace.yaml
shell: bash
- uses: ./
with:
version: '12.0.0-rc.4'
cache: true
run_install: |
- args: [--no-frozen-lockfile]
- 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
+3 -1
View File
@@ -94,7 +94,7 @@ If `run_install` is a YAML string representation of either an object or an array
### `cache`
**Optional** (_type:_ `boolean`, _default:_ `false`) Whether to cache the pnpm store directory.
**Optional** (_type:_ `boolean`, _default:_ `false`) Whether to cache the pnpm store directory and, on pnpm v11 and newer, the results of pnpm's lockfile verification against the configured supply-chain policies. Both are keyed on the lockfile's content hash.
### `cache_dependency_path`
@@ -208,6 +208,8 @@ jobs:
**Note:** You don't need to run `pnpm store prune` at the end; post-action has already taken care of that.
Besides the store, this also caches pnpm's lockfile verification results (pnpm v11 and newer). Repositories that configure supply-chain policies such as `minimumReleaseAge` or `trustPolicy` make pnpm check every lockfile entry against the registry on each install; that check depends only on the lockfile and the policies, so its result is cached and reused until the lockfile changes.
### Cache dependencies from multiple lockfiles
```yaml
+4 -1
View File
@@ -16,7 +16,10 @@ inputs:
required: false
default: 'null'
cache:
description: Whether to cache the pnpm store directory
description: |
Whether to cache the pnpm store directory and, on pnpm v11 and newer,
the results of pnpm's lockfile verification against the configured
supply-chain policies. Both are keyed on the lockfile's content hash.
required: false
default: 'false'
cache_dependency_path:
+147 -147
View File
File diff suppressed because one or more lines are too long
+9 -3
View File
@@ -4,16 +4,22 @@ import { getExecOutput } from '@actions/exec'
import { hashFiles } from '@actions/glob'
import os from 'os'
import { Inputs } from '../inputs'
import { restoreVerificationCache } from '../lockfile-verification-cache'
export async function runRestoreCache(inputs: Inputs) {
const cachePath = await getCacheDirectory()
saveState('cache_path', cachePath)
const fileHash = await hashFiles(inputs.cacheDependencyPath)
if (!fileHash) {
throw new Error('Some specified paths were not resolved, unable to cache dependencies.')
}
await runRestoreStoreCache(fileHash)
await restoreVerificationCache(fileHash)
}
async function runRestoreStoreCache(fileHash: string) {
const cachePath = await getCacheDirectory()
saveState('cache_path', cachePath)
const primaryKey = `pnpm-cache-${process.env.RUNNER_OS}-${os.arch()}-${fileHash}`
debug(`Primary key is ${primaryKey}`)
saveState('cache_primary_key', primaryKey)
+4
View File
@@ -3,6 +3,7 @@ import restoreCache from './cache-restore'
import saveCache from './cache-save'
import getInputs, { Inputs } from './inputs'
import installPnpm from './install-pnpm'
import { saveVerificationCache } from './lockfile-verification-cache'
import setOutputs from './outputs'
import pnpmInstall from './pnpm-install'
import pruneStore from './pnpm-store-prune'
@@ -32,6 +33,9 @@ async function runMain() {
async function runPost() {
const inputs = JSON.parse(getState('inputs')) as Inputs
// Saved ahead of the prune because `pnpm store prune` drops the
// verification log along with the rest of the store's derived state.
await saveVerificationCache()
pruneStore(inputs)
await saveCache(inputs)
}
+95
View File
@@ -0,0 +1,95 @@
import { restoreCache, saveCache } from '@actions/cache'
import { debug, getState, info, saveState, warning } from '@actions/core'
import { getExecOutput } from '@actions/exec'
import { existsSync } from 'fs'
import os from 'os'
import path from 'path'
import { removeWindowsExtendedPathPrefix } from '../windows-path'
/**
* pnpm v11+ verifies every lockfile entry against the configured
* supply-chain policies (`minimumReleaseAge`, `trustPolicy`, …) and memoizes
* the verdict in this file, so the next install with the same lockfile and
* the same policies skips the registry round-trips entirely. Without it a CI
* job re-verifies the whole lockfile on every run, which on a large
* repository costs more than the install itself.
*/
const VERIFICATION_CACHE_FILE = 'lockfile-verified.jsonl'
const PATH_STATE = 'lockfile_verification_cache_path'
const KEY_STATE = 'lockfile_verification_cache_key'
const RESTORED_STATE = 'lockfile_verification_cache_restored'
/**
* The verdict is only valid for the exact lockfile content it was recorded
* for, so this cache is keyed on the same lockfile hash as the store cache
* but restored without prefix fallback: an older entry could never be used.
*/
export async function restoreVerificationCache(lockfileHash: string): Promise<void> {
try {
const cacheFilePath = path.join(await getPnpmCacheDirectory(), VERIFICATION_CACHE_FILE)
const key = `pnpm-lockfile-verified-${process.env.RUNNER_OS}-${os.arch()}-${lockfileHash}`
saveState(PATH_STATE, cacheFilePath)
saveState(KEY_STATE, key)
debug(`Lockfile verification cache path is ${cacheFilePath}, key is ${key}`)
const restoredKey = await restoreCache([cacheFilePath], key)
if (!restoredKey) {
info('Lockfile verification cache is not found')
return
}
saveState(RESTORED_STATE, 'true')
info(`Lockfile verification cache restored from key: ${restoredKey}`)
} catch (error) {
// The gate only costs time, never correctness — a job that cannot reuse
// a past verdict re-verifies and moves on.
warning(`Failed to restore the lockfile verification cache: ${(error as Error).message}`)
}
}
export async function saveVerificationCache(): Promise<void> {
if (getState(RESTORED_STATE) === 'true') return
const cacheFilePath = getState(PATH_STATE)
const key = getState(KEY_STATE)
if (!cacheFilePath || !key || !existsSync(cacheFilePath)) return
try {
const cacheId = await saveCache([cacheFilePath], key)
if (cacheId === -1) return
info(`Lockfile verification cache saved with the key: ${key}`)
} catch (error) {
warning(`Failed to save the lockfile verification cache: ${(error as Error).message}`)
}
}
async function getPnpmCacheDirectory(): Promise<string> {
const { stdout } = await getExecOutput('pnpm config get cacheDir', undefined, {
silent: true,
ignoreReturnCode: true,
})
const configured = stdout.trim()
// `pnpm config get` reports settings, not defaults: an unset `cacheDir`
// prints `undefined` and the default has to be derived here.
if (configured && configured !== 'undefined') {
return removeWindowsExtendedPathPrefix(configured)
}
return defaultPnpmCacheDirectory()
}
/** Mirrors pnpm's own `cacheDir` default. */
function defaultPnpmCacheDirectory(): string {
const { XDG_CACHE_HOME, LOCALAPPDATA } = process.env
if (XDG_CACHE_HOME) return path.join(XDG_CACHE_HOME, 'pnpm')
const homeDir = os.homedir()
switch (process.platform) {
case 'darwin':
return path.join(homeDir, 'Library', 'Caches', 'pnpm')
case 'win32':
return LOCALAPPDATA ? path.join(LOCALAPPDATA, 'pnpm-cache') : path.join(homeDir, '.pnpm-cache')
default:
return path.join(homeDir, '.cache', 'pnpm')
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* pnpm may report 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
}
export default removeWindowsExtendedPathPrefix