mirror of
https://github.com/pnpm/action-setup.git
synced 2026-08-28 21:23:45 +08:00
Compare commits
6 Commits
c0a6b0ff36
...
cache-lock
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
987541b4df | ||
|
|
34f0a19e27 | ||
|
|
e6cb65ab2f | ||
|
|
b543421fa5 | ||
|
|
544072d0b9 | ||
|
|
f141ddd75f |
16
.github/workflows/test.yaml
vendored
16
.github/workflows/test.yaml
vendored
@@ -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]
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
263
dist/index.js
vendored
File diff suppressed because one or more lines are too long
@@ -4,10 +4,10 @@ import { Inputs } from '../inputs'
|
||||
import { runRestoreCache } from './run'
|
||||
|
||||
export async function restoreCache(inputs: Inputs) {
|
||||
if (!inputs.cache) return
|
||||
|
||||
if (!isFeatureAvailable()) {
|
||||
warning('Cache is not available, skipping cache restoration')
|
||||
if (inputs.cache) {
|
||||
warning('Cache is not available, skipping cache restoration')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
throw new Error('Some specified paths were not resolved, unable to cache dependencies.')
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user