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
+5
View File
@@ -1,5 +1,10 @@
# @actions/cache Releases
### 5.2.0
- Handle cache read error due to read-only token: detect the `cache read denied:` prefix on cache download failures (both the v2 twirp path and the v1 `_apis/artifactcache` path) and surface it as a `core.warning` (without failing the run).
- Honor the `ACTIONS_CACHE_MODE` environment variable: 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. When `ACTIONS_CACHE_MODE` is unset or unrecognized, behavior is unchanged.
### 5.1.0
- Handle cache write error due to read-only token: detect the `cache write denied:` prefix on cache reservation failures and surface it as a `core.warning` (without failing the run).
+34 -1
View File
@@ -1,8 +1,10 @@
import {downloadCache} from '../src/internal/cacheHttpClient'
import {downloadCache, getCacheEntry} from '../src/internal/cacheHttpClient'
import {getCacheVersion} from '../src/internal/cacheUtils'
import {CompressionMethod} from '../src/internal/constants'
import * as downloadUtils from '../src/internal/downloadUtils'
import * as requestUtils from '../src/internal/requestUtils'
import {DownloadOptions, getDownloadOptions} from '../src/options'
import {HttpClientError} from '@actions/http-client'
jest.mock('../src/internal/downloadUtils')
@@ -57,6 +59,37 @@ test('getCacheVersion with enableCrossOsArchive as false returns version on wind
}
})
test('getCacheEntry throws a generic status-code error for non-read-denied failures', async () => {
// Regression: a non read-denied failure must NOT leak the server's body
// message; it should surface the generic status-code error.
jest.spyOn(requestUtils, 'retryTypedResponse').mockResolvedValue({
statusCode: 403,
result: null,
headers: {},
error: new HttpClientError('some other server detail', 403)
})
await expect(getCacheEntry(['key'], ['node_modules'])).rejects.toThrow(
'Cache service responded with 403'
)
})
test('getCacheEntry surfaces the body message for a cache read denial', async () => {
jest.spyOn(requestUtils, 'retryTypedResponse').mockResolvedValue({
statusCode: 403,
result: null,
headers: {},
error: new HttpClientError(
'cache read denied: token has no readable scopes',
403
)
})
await expect(getCacheEntry(['key'], ['node_modules'])).rejects.toThrow(
'cache read denied: token has no readable scopes'
)
})
test('downloadCache uses http-client for non-Azure URLs', async () => {
const downloadCacheHttpClientMock = jest.spyOn(
downloadUtils,
+34
View File
@@ -23,3 +23,37 @@ test('isGhes returns false for ghe.localhost', () => {
process.env.GITHUB_SERVER_URL = 'https://my.domain.ghe.localhost'
expect(config.isGhes()).toBe(false)
})
describe('cache-mode helpers', () => {
const original = process.env.ACTIONS_CACHE_MODE
afterEach(() => {
if (original === undefined) {
delete process.env.ACTIONS_CACHE_MODE
} else {
process.env.ACTIONS_CACHE_MODE = original
}
})
test('getCacheMode normalizes whitespace and case', () => {
process.env.ACTIONS_CACHE_MODE = ' Write-Only '
expect(config.getCacheMode()).toBe('write-only')
})
test('getCacheMode returns empty string when unset', () => {
delete process.env.ACTIONS_CACHE_MODE
expect(config.getCacheMode()).toBe('')
})
test.each([
['', true, true],
['read', true, false],
['write', true, true],
['write-only', false, true],
['none', false, false],
['garbage', true, true]
])("mode '%s' -> readable=%s writable=%s", (mode, readable, writable) => {
expect(config.isCacheReadable(mode)).toBe(readable)
expect(config.isCacheWritable(mode)).toBe(writable)
})
})
+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'
+40
View File
@@ -33,6 +33,19 @@ beforeAll(() => {
// Ensure that we're using v2 for these tests
jest.spyOn(config, 'getCacheServiceVersion').mockReturnValue('v2')
// config is auto-mocked; use the real cache-mode helpers so gating reflects
// ACTIONS_CACHE_MODE and unset stays permissive.
const actualConfig = jest.requireActual('../src/internal/config')
jest
.spyOn(config, 'getCacheMode')
.mockImplementation(actualConfig.getCacheMode)
jest
.spyOn(config, 'isCacheReadable')
.mockImplementation(actualConfig.isCacheReadable)
jest
.spyOn(config, 'isCacheWritable')
.mockImplementation(actualConfig.isCacheWritable)
logDebugMock = jest.spyOn(core, 'debug')
logInfoMock = jest.spyOn(core, 'info')
})
@@ -112,6 +125,33 @@ test('restore with server error should fail', async () => {
)
})
test('restore denied by read-only token logs warning and reports cache miss', async () => {
// The receiver returns twirp PermissionDenied (403) when the run's token has
// no readable cache scopes; the client wraps it so the `cache read denied:`
// prefix arrives embedded. Expect a single warning (not error) and a miss.
const paths = ['node_modules']
const key = 'node-test'
const logErrorMock = jest.spyOn(core, 'error')
const logWarningMock = jest.spyOn(core, 'warning')
const wrappedDeniedMessage =
'Failed to GetCacheEntryDownloadURL: Received non-retryable error: ' +
'Failed request: (403) Forbidden: cache read denied: token has no readable scopes'
jest
.spyOn(CacheServiceClientJSON.prototype, 'GetCacheEntryDownloadURL')
.mockImplementation(() => {
throw new Error(wrappedDeniedMessage)
})
const cacheKey = await restoreCache(paths, key)
expect(cacheKey).toBe(undefined)
expect(logErrorMock).not.toHaveBeenCalled()
expect(logWarningMock).toHaveBeenCalledWith(
`Failed to restore: ${wrappedDeniedMessage}`
)
expect(logWarningMock).toHaveBeenCalledTimes(1)
})
test('restore with restore keys and no cache found', async () => {
const paths = ['node_modules']
const key = 'node-test'
+82
View File
@@ -35,6 +35,18 @@ beforeAll(() => {
jest.spyOn(cacheUtils, 'createTempDirectory').mockImplementation(async () => {
return Promise.resolve('/foo/bar')
})
// config is auto-mocked; use the real cache-mode helpers so gating reflects
// ACTIONS_CACHE_MODE and unset stays permissive.
const actualConfig = jest.requireActual('../src/internal/config')
jest
.spyOn(config, 'getCacheMode')
.mockImplementation(actualConfig.getCacheMode)
jest
.spyOn(config, 'isCacheReadable')
.mockImplementation(actualConfig.isCacheReadable)
jest
.spyOn(config, 'isCacheWritable')
.mockImplementation(actualConfig.isCacheWritable)
})
test('save with missing input should fail', async () => {
@@ -45,6 +57,75 @@ test('save with missing input should fail', async () => {
)
})
describe('save 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([
['read', undefined],
['read', 'true'],
['none', undefined],
['none', 'true']
])(
"mode '%s' skips save 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 resolvePathsMock = jest.spyOn(cacheUtils, 'resolvePaths')
const cacheId = await saveCache(['node_modules'], 'node-test')
expect(cacheId).toBe(-1)
expect(resolvePathsMock).not.toHaveBeenCalled()
expect(logInfoMock).toHaveBeenCalledTimes(1)
expect(logInfoMock).toHaveBeenCalledWith(
`Cache save skipped: the effective cache-mode '${mode}' does not permit writes.`
)
}
)
test.each(['write', 'write-only', '', 'garbage'])(
"mode '%s' does not skip save",
async mode => {
if (mode === '') {
delete process.env.ACTIONS_CACHE_MODE
} else {
process.env.ACTIONS_CACHE_MODE = mode
}
const logInfoMock = jest.spyOn(core, 'info')
const resolvePathsMock = jest.spyOn(cacheUtils, 'resolvePaths')
try {
await saveCache(['node_modules'], 'node-test')
} catch {
// Downstream client is not fully mocked here; we only assert the guard
// let execution proceed past it.
}
expect(resolvePathsMock).toHaveBeenCalled()
expect(logInfoMock).not.toHaveBeenCalledWith(
expect.stringContaining('Cache save skipped')
)
}
)
})
test('save with large cache outputs should fail', async () => {
const filePath = 'node_modules'
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
@@ -265,6 +346,7 @@ test('save with reserve cache denied by read-only token logs warning (not info)'
`Failed to save: Unable to reserve cache with key ${primaryKey}. More details: ${deniedMessage}`
)
expect(logWarningMock).toHaveBeenCalledTimes(1)
expect(reserveCacheMock).toHaveBeenCalledTimes(1)
expect(createTarMock).toHaveBeenCalledTimes(1)
expect(saveCacheMock).toHaveBeenCalledTimes(0)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@actions/cache",
"version": "5.1.0",
"version": "5.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@actions/cache",
"version": "5.1.0",
"version": "5.2.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^2.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/cache",
"version": "5.1.0",
"version": "5.2.0",
"preview": true,
"description": "Actions cache lib",
"keywords": [
+84 -8
View File
@@ -3,7 +3,13 @@ import * as path from 'path'
import * as utils from './internal/cacheUtils'
import * as cacheHttpClient from './internal/cacheHttpClient'
import * as cacheTwirpClient from './internal/shared/cacheTwirpClient'
import {getCacheServiceVersion, isGhes} from './internal/config'
import {
getCacheServiceVersion,
isGhes,
getCacheMode,
isCacheReadable,
isCacheWritable
} from './internal/config'
import {DownloadOptions, UploadOptions} from './options'
import {createTar, extractTar, listTar} from './internal/tar'
import {
@@ -13,6 +19,7 @@ import {
GetCacheEntryDownloadURLRequest
} from './generated/results/api/v1/cache'
import {HttpClientError} from '@actions/http-client'
import {CacheReadDeniedMessagePrefix} from './internal/constants'
export class ValidationError extends Error {
constructor(message: string) {
super(message)
@@ -50,6 +57,21 @@ export class CacheWriteDeniedError extends ReserveCacheError {
}
}
// Re-exported from constants so consumers keep referencing it here; the shared
// value also drives detection in cacheHttpClient without duplicating the string.
export const CACHE_READ_DENIED_PREFIX = CacheReadDeniedMessagePrefix
// Raised when the cache backend denies a download URL because the run's token
// has no readable cache scopes. Caching is best-effort, so restoreCache logs a
// warning and reports a cache miss rather than rethrowing this.
export class CacheReadDeniedError extends Error {
constructor(message: string) {
super(message)
this.name = 'CacheReadDeniedError'
Object.setPrototypeOf(this, CacheReadDeniedError.prototype)
}
}
export class FinalizeCacheError extends Error {
constructor(message: string) {
super(message)
@@ -122,6 +144,17 @@ export async function restoreCache(
checkPaths(paths)
const cacheMode = getCacheMode()
if (!isCacheReadable(cacheMode)) {
core.info(
`Cache restore skipped: the effective cache-mode '${cacheMode}' does not permit reads.`
)
core.debug(
`Skipped restore for paths [${paths.join(', ')}] with primary key '${primaryKey}'.`
)
return undefined
}
switch (cacheServiceVersion) {
case 'v2':
return await restoreCacheV2(
@@ -179,10 +212,25 @@ async function restoreCacheV1(
let archivePath = ''
try {
// path are needed to compute version
const cacheEntry = await cacheHttpClient.getCacheEntry(keys, paths, {
compressionMethod,
enableCrossOsArchive
})
let cacheEntry
try {
cacheEntry = await cacheHttpClient.getCacheEntry(keys, paths, {
compressionMethod,
enableCrossOsArchive
})
} catch (error) {
// The v1 artifact cache service returns HTTP 403 with a
// `cache read denied:` body when the run's token has no readable cache
// scopes. getCacheEntry lives in a dependency-free internal module and
// cannot import CacheReadDeniedError without a circular dependency, so it
// only surfaces the raw denial message; we classify it into the typed
// error here so the outer catch and consumers can dispatch on it.
const errorMessage = (error as Error)?.message ?? ''
if (errorMessage.includes(CACHE_READ_DENIED_PREFIX)) {
throw new CacheReadDeniedError(errorMessage)
}
throw error
}
if (!cacheEntry?.archiveLocation) {
// Cache not found
return undefined
@@ -227,7 +275,9 @@ async function restoreCacheV1(
throw error
} else {
// warn on cache restore failure and continue build
// Log server errors (5xx) as errors, all other errors as warnings
// Log server errors (5xx) as errors, all other errors as warnings.
// A read denied by policy (CacheReadDeniedError) is not an HttpClientError
// so it falls here and is warned, treated as a cache miss.
if (
typedError instanceof HttpClientError &&
typeof typedError.statusCode === 'number' &&
@@ -302,7 +352,19 @@ async function restoreCacheV2(
)
}
const response = await twirpClient.GetCacheEntryDownloadURL(request)
let response
try {
response = await twirpClient.GetCacheEntryDownloadURL(request)
} catch (error) {
// The receiver returns twirp PermissionDenied (403) when the run's token
// has no readable cache scopes. The client wraps that 403, so the stable
// prefix is embedded in the message rather than leading it.
const errorMessage = (error as Error)?.message ?? ''
if (errorMessage.includes(CACHE_READ_DENIED_PREFIX)) {
throw new CacheReadDeniedError(errorMessage)
}
throw error
}
if (!response.ok) {
core.debug(
@@ -359,7 +421,9 @@ async function restoreCacheV2(
throw error
} else {
// Supress all non-validation cache related errors because caching should be optional
// Log server errors (5xx) as errors, all other errors as warnings
// Log server errors (5xx) as errors, all other errors as warnings.
// A read denied by policy (CacheReadDeniedError) is not an HttpClientError
// so it falls here and is warned, treated as a cache miss.
if (
typedError instanceof HttpClientError &&
typeof typedError.statusCode === 'number' &&
@@ -402,6 +466,18 @@ export async function saveCache(
core.debug(`Cache service version: ${cacheServiceVersion}`)
checkPaths(paths)
checkKey(key)
const cacheMode = getCacheMode()
if (!isCacheWritable(cacheMode)) {
core.info(
`Cache save skipped: the effective cache-mode '${cacheMode}' does not permit writes.`
)
core.debug(
`Skipped save for paths [${paths.join(', ')}] with key '${key}'.`
)
return -1
}
switch (cacheServiceVersion) {
case 'v2':
return await saveCacheV2(paths, key, options, enableCrossOsArchive)
+7
View File
@@ -35,6 +35,7 @@ import {
retryTypedResponse
} from './requestUtils'
import {getCacheServiceURL} from './config'
import {CacheReadDeniedMessagePrefix} from './constants'
import {getUserAgentString} from './shared/user-agent'
function getCacheApiUrl(resource: string): string {
@@ -101,6 +102,12 @@ export async function getCacheEntry(
return null
}
if (!isSuccessStatusCode(response.statusCode)) {
// Only surface the receiver's body for a `cache read denied:` policy denial
// so callers can dispatch on it; keep the generic message otherwise.
const errorMessage = response.error?.message
if (errorMessage?.includes(CacheReadDeniedMessagePrefix)) {
throw new Error(errorMessage)
}
throw new Error(`Cache service responded with ${response.statusCode}`)
}
+20
View File
@@ -19,6 +19,26 @@ export function getCacheServiceVersion(): string {
return process.env['ACTIONS_CACHE_SERVICE_V2'] ? 'v2' : 'v1'
}
// The cache-mode lattice: readable = {read, write}, writable = {write,
// write-only}, none = neither.
const KNOWN_CACHE_MODES = ['none', 'read', 'write', 'write-only']
// The effective cache-mode exported by the runner, or '' when not set.
export function getCacheMode(): string {
return (process.env['ACTIONS_CACHE_MODE'] || '').trim().toLowerCase()
}
// Unset or unrecognized modes are permissive so behavior matches today.
export function isCacheReadable(mode: string): boolean {
if (!KNOWN_CACHE_MODES.includes(mode)) return true
return mode === 'read' || mode === 'write'
}
export function isCacheWritable(mode: string): boolean {
if (!KNOWN_CACHE_MODES.includes(mode)) return true
return mode === 'write' || mode === 'write-only'
}
export function getCacheServiceURL(): string {
const version = getCacheServiceVersion()
+5
View File
@@ -38,3 +38,8 @@ export const TarFilename = 'cache.tar'
export const ManifestFilename = 'manifest.txt'
export const CacheFileSizeLimit = 10 * Math.pow(1024, 3) // 10GiB per repository
// Prefix the cache backend embeds in a read-denial message (v2 twirp
// GetCacheEntryDownloadURL error or the GHES v1 `_apis/artifactcache` 403 body).
// Shared so cache.ts and cacheHttpClient.ts match the same contract value.
export const CacheReadDeniedMessagePrefix = 'cache read denied:'