Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -627,3 +627,46 @@ 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 }})'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v7

- name: Configure a supply-chain policy so the verification runs
# A one-minute floor activates the check without holding back any
# version this repo already locks.
run: |
printf '\nminimumReleaseAge: 1\n' >> pnpm-workspace.yaml
shell: bash

- uses: ./
with:
version: '12.0.0-rc.4'
runtime: node@22
cache: true

- 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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ If your `package.json` declares `devEngines.runtime`, the action picks up the ru
| `version` | Version of pnpm to install: an exact version, a semver range (`^12.0.0`), or a dist-tag (`next-12`). Must resolve to v11 or newer. Optional when `packageManager` or `devEngines.packageManager` is set in `package.json`. |
| `dest` | Where to store pnpm files. Defaults to `~/setup-pnpm`. |
| `runtime` | Runtime spec, in `<name>` or `<name>@<version>` form (e.g. `node@22`, `node@lts`, `bun@latest`, `deno@2`). Supported names: `node`, `bun`, `deno`. When the version is omitted, falls back to `devEngines.runtime` in `package.json`, then to `lts` (for `node`) / `latest`. If the input itself is omitted, the action reads `devEngines.runtime` from `package.json`. |
| `cache` | Cache the pnpm store directory. Default: `false`. |
| `cache` | Cache the pnpm store directory and the lockfile verification results. Default: `false`. |
| `cache-dependency-path` | Path(s) to the pnpm lockfile, used to compute the cache key. Default: `pnpm-lock.yaml`. |
| `package-json-file` | Path to `package.json` (relative to `GITHUB_WORKSPACE`). Default: `package.json`. |
| `install` | Run `pnpm install` after setup. Default: `true`. Set to `false` for jobs that only need pnpm itself (e.g. `pnpm audit`, lockfile-only regeneration). |
Expand Down Expand Up @@ -97,6 +97,15 @@ jobs:
cache: true
```

This caches two things, both keyed on the lockfile:

- the **pnpm store**, so packages are not downloaded again;
- the **lockfile verification results**, so pnpm does not re-check every
lockfile entry against your supply-chain policies (`minimumReleaseAge`,
`trustPolicy`, …) on each run. On a large repository that check can take
longer than the install itself, and its result depends only on the lockfile
and the policies — never on the runner.

### Skip `pnpm install`

For jobs that only need pnpm itself — e.g. `pnpm audit`, lockfile-only regeneration — set `install: false`:
Expand Down
5 changes: 4 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ inputs:
project pins. Set the variable in the workflow to override.
required: false
cache:
description: Whether to cache the pnpm store directory
description: |
Whether to cache the pnpm store directory and the results of pnpm's
lockfile verification against the configured supply-chain policies.
Both are keyed on the lockfile's content hash.
required: false
default: 'false'
cache-dependency-path:
Expand Down
350 changes: 175 additions & 175 deletions dist/index.js

Large diffs are not rendered by default.

31 changes: 10 additions & 21 deletions src/cache-restore/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,23 @@ 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.')
}

await runRestoreStoreCache(fileHash)
await restoreVerificationCache(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)
Expand Down Expand Up @@ -43,21 +50,3 @@ async function getCacheDirectory() {
debug(`Cache folder is set to "${cacheFolderPath}"`)
return cacheFolderPath
}

/**
* `pnpm store path` may return 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
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import saveCache from './cache-save'
import getInputs, { Inputs } from './inputs'
import installPnpm from './install-pnpm'
import { resolveRuntimeRequest, installRuntime, InstalledRuntime, logSkippedRuntime } from './install-runtime'
import { saveVerificationCache } from './lockfile-verification-cache'
import setOutputs from './outputs'
import pnpmInstall from './pnpm-install'
import pruneStore from './pnpm-store-prune'
Expand Down Expand Up @@ -44,6 +45,8 @@ async function runMain() {

async function runPost() {
const inputs = JSON.parse(getState('inputs')) as Inputs
// pnpm versions before pnpm/pnpm#13893 delete the log during a store prune.
await saveVerificationCache()
pruneStore(inputs)
await saveCache(inputs)
}
Expand Down
92 changes: 92 additions & 0 deletions src/lockfile-verification-cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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 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 RESTORED_STATE = 'lockfile_verification_cache_restored'

/**
* 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}`
saveState(PATH_STATE, cacheFilePath)
saveState(KEY_STATE, key)
debug(`Lockfile verification cache path is ${cacheFilePath}, key is ${key}`)

const restoredKey = await restoreCache([cacheFilePath], key)
if (!restoredKey) {
info('Lockfile verification cache is not found')
return
}

saveState(RESTORED_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}`)
}
}

export async function saveVerificationCache(): Promise<void> {
if (getState(RESTORED_STATE) === 'true') return

const cacheFilePath = getState(PATH_STATE)
const key = getState(KEY_STATE)
if (!cacheFilePath || !key || !existsSync(cacheFilePath)) return

try {
const cacheId = await saveCache([cacheFilePath], key)
if (cacheId === -1) return
info(`Lockfile verification cache saved with the key: ${key}`)
} catch (error) {
warning(`Failed to save the lockfile verification cache: ${(error as Error).message}`)
}
}

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')
}
}
19 changes: 19 additions & 0 deletions src/windows-path/index.ts
Original file line number Diff line number Diff line change
@@ -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