Compare commits

..
Author SHA1 Message Date
Zoltan Kochan 987541b4df feat: check the verification log before caching it
Moving the upload to just after the install left one window open: pnpm runs a
package's lifecycle scripts during the install, so an allow-listed dependency
can still append a record claiming some other lockfile passed verification, and
the upload would publish it. Writing pnpm's own record after those scripts
would not help — the log is appended to, so the forged record survives whatever
pnpm writes next to it.

What does distinguish the two is shape: an install appends its own verdict and
leaves earlier records untouched. So the log is uploaded only when every record
that predated the install is still there, and no more records were added than
there were installs. Both failure modes cost a re-verification in the next job
and nothing else, which is also the price of pnpm compacting the log past a
thousand records — rare enough in CI, where a job restores at most one record.
2026-08-13 17:13:55 +02:00
Zoltan Kochan 34f0a19e27 docs: put lifecycle scripts on the right side of the upload
The previous commit listed a dependency's own scripts among the things that
run after the install, which is where they do not run: pnpm executes them
during the install, ahead of the upload, so they stay inside the window rather
than being closed out of it. What keeps that narrow is that pnpm refuses to
run them at all — `ERR_PNPM_IGNORED_BUILDS` — unless the repository
allow-lists the package, and such a package can already run code in the job.
2026-08-13 17:09:12 +02:00
Zoltan Kochan e6cb65ab2f fix: upload the verification log right after the install writes it
Saving in the post step left the whole job between the install and the upload.
Anything running in that window — the job's tests, its build, a dependency's
own install scripts — can rewrite the log on disk, and the job's own cache
write would then publish a record claiming some other lockfile passed
verification, for every later job to restore and trust. No cache credentials
needed: the attacker rides the write the job performs anyway.

The log is complete the moment the install finishes, so it is uploaded there.
The post step still covers a job that installs in a step of its own, where
that is the first point the log is known to be final; the save is idempotent
across the two, and the process-local flags exist because main and post do not
share state within a run.
2026-08-13 17:03:37 +02:00
Zoltan Kochan b543421fa5 feat: cache the lockfile verification log regardless of cache
The log is under a kilobyte and pnpm writes it on every install, not only
where supply-chain policies are configured: the integrity and tarball-URL
checks are unconditional. A job that starts without it re-checks every
lockfile entry against the registry — on a ~2000-entry lockfile with a warm
store, 13.5s vs 1.5s with `minimumReleaseAge` and `trustPolicy` configured,
and still 6.7s vs 1.6s with no policies at all.

Tying that to the `cache` input made the common case slow for no saving worth
counting, so the log is now restored and saved on its own key whether or not
the store is cached. `cache` goes back to meaning what its name says.
2026-08-13 16:56:49 +02:00
Zoltan Kochan 544072d0b9 docs: tighten the verification cache comments
The module header explained the whole feature where naming the file's purpose
is enough, and the ordering comment described `pnpm store prune` deleting the
log without saying which versions do — pnpm/pnpm#13893 stops deleting it.
2026-08-13 16:47:45 +02:00
Zoltan Kochan f141ddd75f fix: normalize Windows extended-length store paths
On a Windows runner pnpm 12 reports a store path like
`\\?\D:\.pnpm-store\v11`, and the post step then fails with
"Invalid pattern. Root segment must not contain globs" — the cache toolkit
reads the `?` in that prefix as a glob in the root segment. Cache APIs do not
need the extended-length form, so the path is converted back to a regular
drive or UNC path, the same way pnpm/setup handles it.

Reported in pnpm/action-setup#286 and reproduced by the Windows leg of the
lockfile verification cache job.
2026-08-13 14:05:26 +02:00
Zoltan Kochan c0a6b0ff36 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.
2026-08-13 13:59:37 +02:00
9 changed files with 446 additions and 157 deletions
+58
View File
@@ -329,3 +329,61 @@ jobs:
exit 1 exit 1
fi fi
shell: bash 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 }}, 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@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: ${{ matrix.cache }}
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
+20 -1
View File
@@ -94,7 +94,7 @@ If `run_install` is a YAML string representation of either an object or an array
### `cache` ### `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, keyed on the lockfile's content hash. On pnpm v11 and newer, the results of pnpm's lockfile verification are cached regardless of this input — see [Lockfile verification cache](#lockfile-verification-cache).
### `cache_dependency_path` ### `cache_dependency_path`
@@ -208,6 +208,25 @@ jobs:
**Note:** You don't need to run `pnpm store prune` at the end; post-action has already taken care of that. **Note:** You don't need to run `pnpm store prune` at the end; post-action has already taken care of that.
### 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.
### Cache dependencies from multiple lockfiles ### Cache dependencies from multiple lockfiles
```yaml ```yaml
+4 -1
View File
@@ -16,7 +16,10 @@ inputs:
required: false required: false
default: 'null' default: 'null'
cache: cache:
description: Whether to cache the pnpm store directory description: |
Whether to cache the pnpm store directory, keyed on the lockfile's
content hash. On pnpm v11 and newer, the results of pnpm's lockfile
verification are cached either way — see the README.
required: false required: false
default: 'false' default: 'false'
cache_dependency_path: cache_dependency_path:
+148 -147
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,10 +4,10 @@ import { Inputs } from '../inputs'
import { runRestoreCache } from './run' import { runRestoreCache } from './run'
export async function restoreCache(inputs: Inputs) { export async function restoreCache(inputs: Inputs) {
if (!inputs.cache) return
if (!isFeatureAvailable()) { if (!isFeatureAvailable()) {
if (inputs.cache) {
warning('Cache is not available, skipping cache restoration') warning('Cache is not available, skipping cache restoration')
}
return return
} }
+23 -4
View File
@@ -4,15 +4,34 @@ import { getExecOutput } from '@actions/exec'
import { hashFiles } from '@actions/glob' import { hashFiles } from '@actions/glob'
import os from 'os' import os from 'os'
import { Inputs } from '../inputs' import { Inputs } from '../inputs'
import { restoreVerificationCache } from '../lockfile-verification-cache'
import { removeWindowsExtendedPathPrefix } from '../windows-path'
export async function runRestoreCache(inputs: Inputs) { export async function runRestoreCache(inputs: Inputs) {
const cachePath = await getCacheDirectory()
saveState('cache_path', cachePath)
const fileHash = await hashFiles(inputs.cacheDependencyPath) const fileHash = await hashFiles(inputs.cacheDependencyPath)
if (!fileHash) { if (!fileHash) {
// 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.') 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) {
await runRestoreStoreCache(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}` const primaryKey = `pnpm-cache-${process.env.RUNNER_OS}-${os.arch()}-${fileHash}`
debug(`Primary key is ${primaryKey}`) debug(`Primary key is ${primaryKey}`)
@@ -42,7 +61,7 @@ export async function runRestoreCache(inputs: Inputs) {
async function getCacheDirectory() { async function getCacheDirectory() {
const { stdout } = await getExecOutput('pnpm store path --silent') const { stdout } = await getExecOutput('pnpm store path --silent')
const cacheFolderPath = stdout.trim() const cacheFolderPath = removeWindowsExtendedPathPrefix(stdout.trim())
debug(`Cache folder is set to "${cacheFolderPath}"`) debug(`Cache folder is set to "${cacheFolderPath}"`)
return cacheFolderPath return cacheFolderPath
} }
+6
View File
@@ -3,6 +3,7 @@ import restoreCache from './cache-restore'
import saveCache from './cache-save' import saveCache from './cache-save'
import getInputs, { Inputs } from './inputs' import getInputs, { Inputs } from './inputs'
import installPnpm from './install-pnpm' import installPnpm from './install-pnpm'
import { saveVerificationCache } from './lockfile-verification-cache'
import setOutputs from './outputs' import setOutputs from './outputs'
import pnpmInstall from './pnpm-install' import pnpmInstall from './pnpm-install'
import pruneStore from './pnpm-store-prune' import pruneStore from './pnpm-store-prune'
@@ -28,10 +29,15 @@ async function runMain() {
await restoreCache(inputs) await restoreCache(inputs)
pnpmInstall(inputs) pnpmInstall(inputs)
await saveVerificationCache(inputs.runInstall.length)
} }
async function runPost() { async function runPost() {
const inputs = JSON.parse(getState('inputs')) as Inputs 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) pruneStore(inputs)
await saveCache(inputs) await saveCache(inputs)
} }
+164
View File
@@ -0,0 +1,164 @@
import { restoreCache, saveCache } from '@actions/cache'
import { debug, getState, info, saveState, warning } from '@actions/core'
import { getExecOutput } from '@actions/exec'
import { existsSync, readFileSync } from 'fs'
import os from 'os'
import path from 'path'
import { removeWindowsExtendedPathPrefix } from '../windows-path'
/**
* Where pnpm v11+ memoizes which lockfile passed which supply-chain policies.
* A job without it re-checks every lockfile entry against the registry, which
* on a large repository costs more than the install.
*/
const VERIFICATION_CACHE_FILE = 'lockfile-verified.jsonl'
const PATH_STATE = 'lockfile_verification_cache_path'
const KEY_STATE = 'lockfile_verification_cache_key'
const STORED_STATE = 'lockfile_verification_cache_stored'
/**
* Where the log lives and under which key it belongs in the cache. Held in
* memory as well as in the action's state because the main and post steps run
* as separate processes, and state written by one is only readable by the
* other.
*/
let target: { cacheFilePath: string, key: string } | undefined
/** Whether this process already restored or saved the log. */
let stored = false
/** The log's records as they stood before the install ran. */
let recordsBeforeInstall: string[] | undefined
/**
* 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}`
target = { cacheFilePath, key }
saveState(PATH_STATE, cacheFilePath)
saveState(KEY_STATE, key)
debug(`Lockfile verification cache path is ${cacheFilePath}, key is ${key}`)
const restoredKey = await restoreCache([cacheFilePath], key)
recordsBeforeInstall = readRecords(cacheFilePath)
if (!restoredKey) {
info('Lockfile verification cache is not found')
return
}
stored = true
saveState(STORED_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}`)
}
}
/**
* Uploaded as soon as the install that produced the log finishes, rather than
* at the end of the job: whatever a job runs after installing can rewrite the
* log on disk, and the job's own cache write would then publish that for later
* jobs to trust. Lifecycle scripts of the installed packages stay inside the
* window — they run during the install — but pnpm only runs those the
* repository has allow-listed, and `expectedNewRecords` catches what they
* append.
*
* Safe to call more than once; the second call is a no-op.
*/
export async function saveVerificationCache(expectedNewRecords = Infinity): Promise<void> {
if (stored || getState(STORED_STATE) === 'true') return
const cacheFilePath = target?.cacheFilePath ?? getState(PATH_STATE)
const key = target?.key ?? getState(KEY_STATE)
if (!cacheFilePath || !key || !existsSync(cacheFilePath)) return
if (!onlyGrewAsExpected(cacheFilePath, expectedNewRecords)) return
try {
const cacheId = await saveCache([cacheFilePath], key)
if (cacheId === -1) return
stored = true
saveState(STORED_STATE, 'true')
info(`Lockfile verification cache saved with the key: ${key}`)
} catch (error) {
warning(`Failed to save the lockfile verification cache: ${(error as Error).message}`)
}
}
/**
* An install appends its own verdict and leaves every earlier record in place.
* Anything else — a record the install did not write, or an earlier one gone —
* means something other than pnpm's verification wrote to the log, and
* uploading it would hand that to every later job. pnpm compacting the log
* (past a thousand records) lands here too, at the cost of one re-verification.
*/
function onlyGrewAsExpected(cacheFilePath: string, expectedNewRecords: number): boolean {
const before = recordsBeforeInstall
if (before === undefined) return true
const after = readRecords(cacheFilePath)
if (after === undefined) return false
if (!before.every((record, index) => after[index] === record)) {
warning(
'Records that predate the install are missing from the lockfile verification log; not caching it.'
)
return false
}
const added = after.length - before.length
if (added > expectedNewRecords) {
warning(
`The lockfile verification log gained ${added} records during the install, expected at most ${expectedNewRecords}; not caching it.`
)
return false
}
return true
}
function readRecords(cacheFilePath: string): string[] | undefined {
try {
return readFileSync(cacheFilePath, 'utf8').split('\n').filter(Boolean)
} catch {
return undefined
}
}
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