mirror of
https://github.com/pnpm/action-setup.git
synced 2026-08-14 14:02:10 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
987541b4df | ||
|
|
34f0a19e27 | ||
|
|
e6cb65ab2f | ||
|
|
b543421fa5 | ||
|
|
544072d0b9 | ||
|
|
f141ddd75f | ||
|
|
c0a6b0ff36 |
@@ -329,3 +329,61 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
**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,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.
|
||||
|
||||
### 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
|
||||
|
||||
```yaml
|
||||
|
||||
+4
-1
@@ -16,7 +16,10 @@ inputs:
|
||||
required: false
|
||||
default: 'null'
|
||||
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
|
||||
default: 'false'
|
||||
cache_dependency_path:
|
||||
|
||||
Vendored
+148
-147
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
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,35 @@ import { getExecOutput } from '@actions/exec'
|
||||
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 cachePath = await getCacheDirectory()
|
||||
saveState('cache_path', cachePath)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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}`
|
||||
debug(`Primary key is ${primaryKey}`)
|
||||
saveState('cache_primary_key', primaryKey)
|
||||
@@ -42,7 +61,7 @@ export async function runRestoreCache(inputs: Inputs) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import restoreCache from './cache-restore'
|
||||
import saveCache from './cache-save'
|
||||
import getInputs, { Inputs } from './inputs'
|
||||
import installPnpm from './install-pnpm'
|
||||
import { saveVerificationCache } from './lockfile-verification-cache'
|
||||
import setOutputs from './outputs'
|
||||
import pnpmInstall from './pnpm-install'
|
||||
import pruneStore from './pnpm-store-prune'
|
||||
@@ -28,10 +29,15 @@ async function runMain() {
|
||||
await restoreCache(inputs)
|
||||
|
||||
pnpmInstall(inputs)
|
||||
await saveVerificationCache(inputs.runInstall.length)
|
||||
}
|
||||
|
||||
async function runPost() {
|
||||
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)
|
||||
await saveCache(inputs)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user