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

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.
This commit is contained in:
Zoltan Kochan
2026-08-13 17:13:55 +02:00
parent 34f0a19e27
commit 987541b4df
4 changed files with 184 additions and 134 deletions
+3 -1
View File
@@ -223,7 +223,9 @@ Reusing a verdict is not a weaker check: pnpm re-verifies whenever the lockfile
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.
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.
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
+130 -129
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -29,7 +29,7 @@ async function runMain() {
await restoreCache(inputs)
pnpmInstall(inputs)
await saveVerificationCache()
await saveVerificationCache(inputs.runInstall.length)
}
async function runPost() {
+50 -3
View File
@@ -1,7 +1,7 @@
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'
@@ -28,6 +28,9 @@ 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
@@ -43,6 +46,7 @@ export async function restoreVerificationCache(lockfileHash: string): Promise<vo
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
@@ -64,17 +68,20 @@ export async function restoreVerificationCache(lockfileHash: string): Promise<vo
* 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.
* 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(): Promise<void> {
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
@@ -86,6 +93,46 @@ export async function saveVerificationCache(): Promise<void> {
}
}
/**
* 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,