feat: backport cache read-denied + ACTIONS_CACHE_MODE handling (5.2.0)

Backport of the read-denied and ACTIONS_CACHE_MODE cache-mode gating from
the ESM v6.2.0 line (#2447) to the CommonJS v5 line, released as 5.2.0.
Mirrors the earlier write-denied backport (#2435, 5.1.0).

- Detect the `cache read denied:` prefix on download failures (v2 twirp
  path and v1 `_apis/artifactcache` path) and surface it as a core.warning
  without failing the run.
- Honor ACTIONS_CACHE_MODE: skip restore when the effective cache-mode does
  not permit reads (none, write-only) and skip save when it does not permit
  writes (none, read), logging a single non-fatal core.info line. Unset or
  unrecognized modes are unchanged.
- Add read-denied and cache-mode tests; bump to 5.2.0 with RELEASES entry.

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: e96deec1-716e-4e14-acdf-a230139420a2
This commit is contained in:
Philip Gai
2026-07-13 15:19:29 -05:00
co-authored by Copilot App
parent c6ca5e729f
commit 3b3db3879e
12 changed files with 401 additions and 12 deletions
+87
View File
@@ -99,6 +99,93 @@ test('restore with server error should fail', async () => {
delete process.env['ACTIONS_RESULTS_URL']
})
test('restore surfaces a getCacheEntry failure as a warning and reports a cache miss', async () => {
// restoreCache treats any getCacheEntry failure (read-denied or otherwise)
// as a non-fatal warning and a cache miss so the workflow continues. The
// read-denied prefix detection itself is covered in cacheHttpClient.test.ts.
const paths = ['node_modules']
const key = 'node-test'
const logErrorMock = jest.spyOn(core, 'error')
const logWarningMock = jest.spyOn(core, 'warning')
const message = 'cache read denied: token has no readable scopes'
jest.spyOn(cacheHttpClient, 'getCacheEntry').mockImplementation(async () => {
throw new Error(message)
})
const cacheKey = await restoreCache(paths, key)
expect(cacheKey).toBe(undefined)
expect(logErrorMock).not.toHaveBeenCalled()
expect(logWarningMock).toHaveBeenCalledWith(`Failed to restore: ${message}`)
expect(logWarningMock).toHaveBeenCalledTimes(1)
})
describe('restore cache-mode gating', () => {
const originalMode = process.env.ACTIONS_CACHE_MODE
const originalV2 = process.env.ACTIONS_CACHE_SERVICE_V2
const restoreEnv = (key: string, value: string | undefined): void => {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
afterEach(() => {
restoreEnv('ACTIONS_CACHE_MODE', originalMode)
restoreEnv('ACTIONS_CACHE_SERVICE_V2', originalV2)
})
// The skip short-circuits before v1/v2 dispatch, so it applies regardless of
// the ACTIONS_CACHE_SERVICE_V2 feature flag.
test.each([
['none', undefined],
['none', 'true'],
['write-only', undefined],
['write-only', 'true']
])(
"mode '%s' skips restore with ACTIONS_CACHE_SERVICE_V2=%s",
async (mode, v2) => {
process.env.ACTIONS_CACHE_MODE = mode
restoreEnv('ACTIONS_CACHE_SERVICE_V2', v2)
const logInfoMock = jest.spyOn(core, 'info')
const getCacheEntryMock = jest.spyOn(cacheHttpClient, 'getCacheEntry')
const cacheKey = await restoreCache(['node_modules'], 'node-test')
expect(cacheKey).toBe(undefined)
expect(getCacheEntryMock).not.toHaveBeenCalled()
expect(logInfoMock).toHaveBeenCalledTimes(1)
expect(logInfoMock).toHaveBeenCalledWith(
`Cache restore skipped: the effective cache-mode '${mode}' does not permit reads.`
)
}
)
test.each(['read', 'write', '', 'garbage'])(
"mode '%s' does not skip restore",
async mode => {
if (mode === '') {
delete process.env.ACTIONS_CACHE_MODE
} else {
process.env.ACTIONS_CACHE_MODE = mode
}
const logInfoMock = jest.spyOn(core, 'info')
const getCacheEntryMock = jest
.spyOn(cacheHttpClient, 'getCacheEntry')
.mockResolvedValue(null as never)
await restoreCache(['node_modules'], 'node-test')
expect(getCacheEntryMock).toHaveBeenCalledTimes(1)
expect(logInfoMock).not.toHaveBeenCalledWith(
expect.stringContaining('Cache restore skipped')
)
}
)
})
test('restore with restore keys and no cache found', async () => {
const paths = ['node_modules']
const key = 'node-test'