Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Only one version of each runtime can be installed globally. If a runtime name is
| `runtime-name` | Name of the first installed runtime, or empty string if none was installed. |
| `runtime-version` | Resolved version of the first installed runtime, or empty string if none was installed. |
| `runtimes` | JSON array of every installed runtime in declaration order, as `{ "name": string, "version": string }` objects. Returns `[]` when none were installed. |
| `cache-hit` | Whether the pnpm store cache matched the exact primary key. |
| `cache-hit` | Whether the restored cache matched the current lockfile exactly, rather than falling back to a store cached for a different lockfile. |

## Usage

Expand Down Expand Up @@ -197,6 +197,17 @@ the registry, writes one and exits `0`. Set `require-lockfile` when a missing
lockfile should fail the job instead of silently installing unpinned
dependencies.

Every action invocation that saves a cache uses its own unique key, including
matrix jobs, repeated steps, and workflow re-runs. Restoration looks for the
most recent entry for the current lockfile. This means a job that gets
cancelled or fails mid-install can never pin a partial
store under a key later runs are stuck matching — the next successful run
simply publishes a fresher entry.

Each save creates a new cache entry, even when the lockfile is unchanged.
Large matrix workflows therefore use more cache storage and can evict older
entries sooner.

### Skip `pnpm install`

For jobs that only need pnpm itself — e.g. `pnpm audit`, lockfile-only regeneration — set `install: false`:
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ outputs:
runtimes:
description: JSON array of installed runtimes, each containing `name` and `version`
cache-hit:
description: Whether the pnpm store cache matched the exact primary key
description: Whether the restored cache matched the current lockfile exactly, rather than falling back to a store cached for a different lockfile
runs:
using: node24
main: dist/index.js
Expand Down
236 changes: 118 additions & 118 deletions dist/index.js

Large diffs are not rendered by default.

96 changes: 71 additions & 25 deletions src/cache-restore/keys.test.mjs
Original file line number Diff line number Diff line change
@@ -1,31 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { getCacheKeyPrefix, getPrimaryCacheKey } from './keys.ts'
import { getCacheKeyPrefix, getRestoreKeys, getSaveCacheKey, isLockfileExactHit } from './keys.ts'

test('moving runtime selectors use the resolved version in the primary key', () => {
test('moving runtime selectors produce distinct key prefixes', () => {
const previous = getCacheKeyPrefix('Linux', 'x64', [{ name: 'node', version: 'lts' }])
const current = getCacheKeyPrefix('Linux', 'x64', [{ name: 'node', version: 'next' }])

assert.notEqual(previous, current)
})

test('save keys for the same run and lockfile differ by resolved runtime version', () => {
const prefix = getCacheKeyPrefix('Linux', 'x64', [{ name: 'node', version: 'lts' }])
const previousKey = getPrimaryCacheKey(prefix, 'lockfile-hash', [{ name: 'node', version: '22.22.0' }])
const currentKey = getPrimaryCacheKey(prefix, 'lockfile-hash', [{ name: 'node', version: '24.13.0' }])
const lockfileKeyPrefix = `${prefix}lockfile-hash-`

const previousVersionKey = getSaveCacheKey(lockfileKeyPrefix, [{ name: 'node', version: '22.22.0' }], '123')
const currentVersionKey = getSaveCacheKey(lockfileKeyPrefix, [{ name: 'node', version: '24.13.0' }], '123')

assert.notEqual(previousVersionKey, currentVersionKey)
assert.ok(previousVersionKey.startsWith(lockfileKeyPrefix))
assert.ok(currentVersionKey.startsWith(lockfileKeyPrefix))
})

test('save keys differ by invocation identity', () => {
const prefix = getCacheKeyPrefix('Linux', 'x64', [{ name: 'node', version: '24.19.0' }])
const lockfileKeyPrefix = `${prefix}lockfile-hash-`

assert.notEqual(previousKey, currentKey)
assert.ok(previousKey.startsWith(prefix))
assert.ok(currentKey.startsWith(prefix))
const firstRun = getSaveCacheKey(lockfileKeyPrefix, [{ name: 'node', version: '24.19.0' }], '111')
const secondRun = getSaveCacheKey(lockfileKeyPrefix, [{ name: 'node', version: '24.19.0' }], '222')

assert.notEqual(firstRun, secondRun)
})

test('the provisional restore key is never a key a runtime run saves under', () => {
test('save keys for a re-run of the same run id differ by run attempt', () => {
const prefix = getCacheKeyPrefix('Linux', 'x64', [{ name: 'node', version: '24.19.0' }])
const provisional = getPrimaryCacheKey(prefix, 'lockfile-hash')
const final = getPrimaryCacheKey(prefix, 'lockfile-hash', [{ name: 'node', version: '24.19.0' }])
const lockfileKeyPrefix = `${prefix}lockfile-hash-`

const firstAttempt = getSaveCacheKey(lockfileKeyPrefix, [{ name: 'node', version: '24.19.0' }], '555-1')
const secondAttempt = getSaveCacheKey(lockfileKeyPrefix, [{ name: 'node', version: '24.19.0' }], '555-2')

// An exact hit on the provisional key would stop the restore falling back
// to the prefix search that finds the versioned caches.
assert.notEqual(provisional, final)
assert.notEqual(firstAttempt, secondAttempt)
})

test('without a runtime the provisional key is the final key', () => {
test('without a resolved runtime the save key is just the lockfile prefix and invocation identity', () => {
const prefix = getCacheKeyPrefix('Linux', 'x64', [])
const lockfileKeyPrefix = `${prefix}lockfile-hash-`

assert.equal(getPrimaryCacheKey(prefix, 'lockfile-hash'), getPrimaryCacheKey(prefix, 'lockfile-hash', []))
assert.equal(getSaveCacheKey(lockfileKeyPrefix, [], '123'), `${lockfileKeyPrefix}123`)
})

test('every requested runtime contributes to the key prefix', () => {
Expand All @@ -38,7 +58,7 @@ test('every requested runtime contributes to the key prefix', () => {
assert.notEqual(single, both)
})

test('declaration order does not change the key', () => {
test('declaration order does not change the key prefix', () => {
const nodeFirst = getCacheKeyPrefix('Linux', 'x64', [
{ name: 'node', version: '24' },
{ name: 'bun', version: '1.3.13' },
Expand All @@ -52,19 +72,45 @@ test('declaration order does not change the key', () => {
assert.equal(nodeFirst, bunFirst)
})

test('a version change in any runtime changes the primary key', () => {
test('a version change in any runtime changes the save key', () => {
const prefix = getCacheKeyPrefix('Linux', 'x64', [
{ name: 'node', version: 'lts' },
{ name: 'bun', version: 'latest' },
])
const before = getPrimaryCacheKey(prefix, 'lockfile-hash', [
{ name: 'node', version: '24.19.0' },
{ name: 'bun', version: '1.3.13' },
])
const after = getPrimaryCacheKey(prefix, 'lockfile-hash', [
{ name: 'node', version: '24.19.0' },
{ name: 'bun', version: '1.3.14' },
])
const lockfileKeyPrefix = `${prefix}lockfile-hash-`

const before = getSaveCacheKey(
lockfileKeyPrefix,
[
{ name: 'node', version: '24.19.0' },
{ name: 'bun', version: '1.3.13' },
],
'123',
)
const after = getSaveCacheKey(
lockfileKeyPrefix,
[
{ name: 'node', version: '24.19.0' },
{ name: 'bun', version: '1.3.14' },
],
'123',
)

assert.notEqual(before, after)
})

test('restore keys try the exact lockfile match before falling back to any store for the runtime', () => {
const keyPrefix = getCacheKeyPrefix('Linux', 'x64', [{ name: 'node', version: '24' }])
const lockfileKeyPrefix = `${keyPrefix}lockfile-hash-`

assert.deepEqual(getRestoreKeys(lockfileKeyPrefix, keyPrefix), [lockfileKeyPrefix, keyPrefix])
})

test('cache-hit is true only when the restored key matches the current lockfile exactly', () => {
const keyPrefix = getCacheKeyPrefix('Linux', 'x64', [{ name: 'node', version: '24' }])
const lockfileKeyPrefix = `${keyPrefix}lockfile-hash-`

assert.equal(isLockfileExactHit(`${lockfileKeyPrefix}some-run-id`, lockfileKeyPrefix), true)
assert.equal(isLockfileExactHit(keyPrefix, lockfileKeyPrefix), false)
assert.equal(isLockfileExactHit(undefined, lockfileKeyPrefix), false)
})
18 changes: 13 additions & 5 deletions src/cache-restore/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ export function getCacheKeyPrefix(
return `pnpm-cache-${runnerOs}-${architecture}-${runtimeKey}-`
}

export function getPrimaryCacheKey(
keyPrefix: string,
fileHash: string,
resolvedRuntimes: readonly RuntimeRequest[] = [],
export function getSaveCacheKey(
lockfileKeyPrefix: string,
resolvedRuntimes: readonly RuntimeRequest[],
invocationId: string,
): string {
const runtimeVersionKey = resolvedRuntimes.length > 0 ? `${hashRuntimes(resolvedRuntimes)}-` : ''
return `${keyPrefix}${runtimeVersionKey}${fileHash}`
return `${lockfileKeyPrefix}${runtimeVersionKey}${invocationId}`
}

export function getRestoreKeys(lockfileKeyPrefix: string, keyPrefix: string): string[] {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return [lockfileKeyPrefix, keyPrefix]
}

export function isLockfileExactHit(restoredKey: string | undefined, lockfileKeyPrefix: string): boolean {
return restoredKey?.startsWith(lockfileKeyPrefix) ?? false
}

/**
Expand Down
120 changes: 120 additions & 0 deletions src/cache-restore/run.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict'
import os from 'node:os'
import { fileURLToPath } from 'node:url'
import { beforeEach, test } from 'node:test'
import { build } from 'esbuild'
import { getCacheKeyPrefix } from './keys.ts'

const mocks = {
'@actions/cache': `
import { mock } from 'node:test'
export const restoreCache = mock.fn(async () => undefined)
export const saveCache = mock.fn(async () => 1)
`,
'@actions/core': `
export const state = new Map()
export const outputs = new Map()
export const saveState = (key, value) => state.set(key, value)
export const getState = key => state.get(key) ?? ''
export const setOutput = (key, value) => outputs.set(key, value)
export const debug = () => {}
export const info = () => {}
`,
'@actions/exec': String.raw`export const getExecOutput = async () => ({ stdout: '/pnpm-store\n' })`,
'@actions/glob': `export const hashFiles = async () => 'lockfile-hash'`,
'../lockfile-verification-cache': `export const restoreVerificationCache = async () => {}`,
}
const bundle = await build({
stdin: {
contents: `
export { runRestoreCache, finalizeCache } from './run.ts'
export { runSaveCache } from '../cache-save/run.ts'
export * as cache from '@actions/cache'
export * as core from '@actions/core'
`,
resolveDir: fileURLToPath(new URL('.', import.meta.url)),
},
bundle: true,
platform: 'node',
format: 'esm',
write: false,
plugins: [{
name: 'fake-cache-services',
setup(builder) {
builder.onResolve({ filter: /.*/ }, args => {
if (Object.hasOwn(mocks, args.path)) return { path: args.path, namespace: 'mock' }
})
builder.onLoad({ filter: /.*/, namespace: 'mock' }, args => ({ contents: mocks[args.path] }))
},
}],
})
const { runRestoreCache, finalizeCache, runSaveCache, cache, core } = await import(
`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}`
)

const inputs = { cache: true, cacheDependencyPath: 'pnpm-lock.yaml' }
const runtimes = [{ name: 'node', version: '24.19.0' }]
const keyPrefix = getCacheKeyPrefix(process.env.RUNNER_OS, os.arch(), runtimes)
const lockfileKeyPrefix = `${keyPrefix}lockfile-hash-`

beforeEach(() => {
core.state.clear()
core.outputs.clear()
cache.restoreCache.mock.resetCalls()
cache.restoreCache.mock.mockImplementation(async () => undefined)
cache.saveCache.mock.resetCalls()
})

test('restore asks for the current lockfile before the broader runtime fallback', async () => {
await runRestoreCache(inputs, runtimes)
assert.deepEqual(cache.restoreCache.mock.calls[0].arguments, [
['/pnpm-store'], lockfileKeyPrefix, [lockfileKeyPrefix, keyPrefix],
])
})

for (const [label, restoredKey, expectedHit] of [
['same lockfile', `${lockfileKeyPrefix}previous-invocation`, true],
['different lockfile', `${keyPrefix}other-lockfile-previous-invocation`, false],
['cache miss', undefined, false],
]) {
test(`${label}: reports cache-hit and publishes a fresh store`, async () => {
cache.restoreCache.mock.mockImplementation(async () => restoredKey)
const restored = await runRestoreCache(inputs, runtimes)
finalizeCache(restored, runtimes)
assert.equal(core.outputs.get('cache-hit'), expectedHit)

await runSaveCache()
const primaryKey = core.state.get('cache_primary_key')
assert.ok(primaryKey.startsWith(lockfileKeyPrefix))
assert.notEqual(primaryKey, restoredKey)
assert.deepEqual(cache.saveCache.mock.calls[0].arguments, [['/pnpm-store'], primaryKey])
})
}

test('a failure before finalization does not save the restored store', async () => {
await runRestoreCache(inputs, runtimes)
await runSaveCache()
assert.equal(cache.saveCache.mock.callCount(), 0)
})

test('equivalent invocations in one workflow attempt publish distinct save keys', async t => {
const previous = [process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT]
t.after(() => {
for (const [index, name] of ['GITHUB_RUN_ID', 'GITHUB_RUN_ATTEMPT'].entries()) {
if (previous[index] === undefined) delete process.env[name]
else process.env[name] = previous[index]
}
})
process.env.GITHUB_RUN_ID = '555'
process.env.GITHUB_RUN_ATTEMPT = '1'

for (let invocation = 0; invocation < 2; invocation++) {
const restored = await runRestoreCache(inputs, runtimes)
finalizeCache(restored, runtimes)
await runSaveCache()
}
const keys = cache.saveCache.mock.calls.map(call => call.arguments[1])
assert.equal(keys.length, 2)
assert.notEqual(keys[0], keys[1])
for (const key of keys) assert.match(key, /-555-1-[0-9a-f-]{36}$/)
})
34 changes: 14 additions & 20 deletions src/cache-restore/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import { restoreCache } from '@actions/cache'
import { debug, info, saveState, setOutput } from '@actions/core'
import { getExecOutput } from '@actions/exec'
import { hashFiles } from '@actions/glob'
import { randomUUID } from 'crypto'
import os from 'os'
import { Inputs } from '../inputs'
import { RuntimeRequest } from '../install-runtime'
import { restoreVerificationCache } from '../lockfile-verification-cache'
import { removeWindowsExtendedPathPrefix } from '../windows-path'
import { getCacheKeyPrefix, getPrimaryCacheKey } from './keys'
import { getCacheKeyPrefix, getRestoreKeys, getSaveCacheKey, isLockfileExactHit } from './keys'

export interface RestoredCache {
readonly fileHash: string
readonly keyPrefix: string
readonly lockfileKeyPrefix: string
readonly restoredKey: string | undefined
}

Expand Down Expand Up @@ -48,35 +48,29 @@ async function runRestoreStoreCache(
saveState('cache_path', cachePath)

const keyPrefix = getCacheKeyPrefix(process.env.RUNNER_OS, os.arch(), runtimes)
const provisionalKey = getPrimaryCacheKey(keyPrefix, fileHash)
debug(`Provisional cache key is ${provisionalKey}`)
saveState('cache_provisional_key', provisionalKey)
const lockfileKeyPrefix = `${keyPrefix}${fileHash}-`
const restoreKeys = getRestoreKeys(lockfileKeyPrefix, keyPrefix)

// We don't need to download everything again if only one dependency changed
// We can still re-use previous store to cache the rest of the unchanged dependencies
const restoreKeys = [keyPrefix]

const restoredKey = await restoreCache([cachePath], provisionalKey, restoreKeys)
const restoredKey = await restoreCache([cachePath], lockfileKeyPrefix, restoreKeys)

if (!restoredKey) {
info(`Cache is not found`)
return { fileHash, keyPrefix, restoredKey: undefined }
return { lockfileKeyPrefix, restoredKey: undefined }
}

saveState('cache_restored_key', restoredKey)
info(`Cache restored from key: ${restoredKey}`)
return { fileHash, keyPrefix, restoredKey }
return { lockfileKeyPrefix, restoredKey }
}

export function finalizeCache(cache: RestoredCache, resolvedRuntimes: readonly RuntimeRequest[]) {
const primaryKey = getPrimaryCacheKey(
cache.keyPrefix,
cache.fileHash,
resolvedRuntimes,
)
const runId = process.env.GITHUB_RUN_ID ?? ''
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const runAttempt = process.env.GITHUB_RUN_ATTEMPT ?? ''
const invocationId = `${runId}-${runAttempt}-${randomUUID()}`
const primaryKey = getSaveCacheKey(cache.lockfileKeyPrefix, resolvedRuntimes, invocationId)
debug(`Primary key is ${primaryKey}`)
saveState('cache_primary_key', primaryKey)
setOutput('cache-hit', cache.restoredKey === primaryKey)

setOutput('cache-hit', isLockfileExactHit(cache.restoredKey, cache.lockfileKeyPrefix))
}

async function getCacheDirectory() {
Expand Down
Loading
Loading