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:
@@ -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
|
||||
|
||||
|
||||
Vendored
+130
-129
File diff suppressed because one or more lines are too long
+1
-1
@@ -29,7 +29,7 @@ async function runMain() {
|
||||
await restoreCache(inputs)
|
||||
|
||||
pnpmInstall(inputs)
|
||||
await saveVerificationCache()
|
||||
await saveVerificationCache(inputs.runInstall.length)
|
||||
}
|
||||
|
||||
async function runPost() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user