1
0
mirror of https://github.com/pnpm/action-setup.git synced 2026-08-28 21:23:45 +08:00

Compare commits

...

6 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
8 changed files with 272 additions and 160 deletions

View File

@@ -334,14 +334,24 @@ jobs:
# 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 }})'
name: 'Lockfile verification cache (${{ matrix.os }}, cache=${{ matrix.cache }})'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
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
@@ -357,7 +367,7 @@ jobs:
- uses: ./
with:
version: '12.0.0-rc.4'
cache: true
cache: ${{ matrix.cache }}
run_install: |
- args: [--no-frozen-lockfile]

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 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.
**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`
@@ -208,7 +208,24 @@ 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.
### 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

View File

@@ -17,9 +17,9 @@ inputs:
default: 'null'
cache:
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.
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
default: 'false'
cache_dependency_path:

263
dist/index.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -4,10 +4,10 @@ import { Inputs } from '../inputs'
import { runRestoreCache } from './run'
export async function restoreCache(inputs: Inputs) {
if (!inputs.cache) return
if (!isFeatureAvailable()) {
if (inputs.cache) {
warning('Cache is not available, skipping cache restoration')
}
return
}

View File

@@ -5,15 +5,28 @@ import { hashFiles } from '@actions/glob'
import os from 'os'
import { Inputs } from '../inputs'
import { restoreVerificationCache } from '../lockfile-verification-cache'
import { removeWindowsExtendedPathPrefix } from '../windows-path'
export async function runRestoreCache(inputs: Inputs) {
const fileHash = await hashFiles(inputs.cacheDependencyPath)
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.')
}
return
}
await runRestoreStoreCache(fileHash)
// 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) {
@@ -48,7 +61,7 @@ async function runRestoreStoreCache(fileHash: string) {
async function getCacheDirectory() {
const { stdout } = await getExecOutput('pnpm store path --silent')
const cacheFolderPath = stdout.trim()
const cacheFolderPath = removeWindowsExtendedPathPrefix(stdout.trim())
debug(`Cache folder is set to "${cacheFolderPath}"`)
return cacheFolderPath
}

View File

@@ -29,12 +29,14 @@ async function runMain() {
await restoreCache(inputs)
pnpmInstall(inputs)
await saveVerificationCache(inputs.runInstall.length)
}
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.
// 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)

View File

@@ -1,24 +1,35 @@
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 { existsSync, readFileSync } 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.
* 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 RESTORED_STATE = 'lockfile_verification_cache_restored'
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
@@ -29,17 +40,20 @@ export async function restoreVerificationCache(lockfileHash: string): Promise<vo
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
}
saveState(RESTORED_STATE, 'true')
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
@@ -48,22 +62,77 @@ export async function restoreVerificationCache(lockfileHash: string): Promise<vo
}
}
export async function saveVerificationCache(): Promise<void> {
if (getState(RESTORED_STATE) === 'true') return
/**
* 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 = getState(PATH_STATE)
const key = getState(KEY_STATE)
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,