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

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.


Copilot-Session: e96deec1-716e-4e14-acdf-a230139420a2

Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
Philip Gai
2026-07-15 13:13:58 -05:00
committed by GitHub
co-authored by Copilot App
parent c6ca5e729f
commit a107f24fb0
12 changed files with 401 additions and 12 deletions
+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:'