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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Vendored
+147
-147
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user