feat(cache): add cache-mode client behavior (read-denied warning + ACTIONS_CACHE_MODE skip) (#2447)

* feat(cache): surface cache read-denied as a distinct restore warning

Mirror the existing cache write-denied handling on the restore path. When
the receiver refuses a download URL because the run's token has no readable
cache scopes, it returns a twirp PermissionDenied (HTTP 403). The twirp
client wraps that 403 in a generic Error, so the stable 'cache read denied:'
prefix is embedded in the message rather than at the start.

- Add CACHE_READ_DENIED_PREFIX and CacheReadDeniedError
- Dispatch on the prefix in the restoreCacheV2 catch block (V2 only), log a
  policy-specific warning, and report a cache miss so the run continues
- Add a test mirroring the write-denied coverage

* chore(cache): trim comments, bump to 6.2.0, add RELEASES entry

* refactor(cache): dispatch read-denied by error name to mirror write path

Re-throw CacheReadDeniedError from an inner try/catch around
GetCacheEntryDownloadURL and dispatch on typedError.name in the outer catch,
matching how saveCacheV2 handles CacheWriteDeniedError.

* feat(cache): handle read-denied on the v1 restore path

Extend the read-denied handling to Cache Service v1 so GHES (which forces v1
via _apis/artifactcache) is covered when read-scope enforcement ships there.

- Surface the receiver's error body message from getCacheEntry instead of a
  generic status-code error, so the cache read denied: prefix reaches callers
- Re-throw CacheReadDeniedError from restoreCacheV1 and dispatch on it in the
  outer catch, mirroring restoreCacheV2 and the write-denied v1 handling
- Add a v1 read-denied test

* refactor(cache): only surface receiver body for read-denied on v1

* test(cache): assert getCacheEntry only surfaces body for read-denied

* test(cache): cover non-read-denied getCacheEntry passthrough on v1

* refactor(cache): share read-denied prefix via constants to avoid drift

* feat(cache): skip restore/save per ACTIONS_CACHE_MODE

* test(cache): expand ACTIONS_CACHE_MODE skip coverage across v1/v2 and unknown modes

* fix copilot pr feedback

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* docs(cache): remove internal reference from cache-mode comment

* test(cache): merge redundant cache-mode skip tests and simplify read-denied handling

Address PR review feedback:
- Merge the duplicate restore/save skip test.each blocks into single blocks parametrized over ACTIONS_CACHE_SERVICE_V2.
- Drop the redundant CacheReadDeniedError catch arms; the typed error is not an HttpClientError so it already falls through to a non-fatal warning.
- Clarify why read-denied classification happens both in getCacheEntry and cache.ts (dependency-free internal module cannot import the typed error).

* refactor(cache): drop redundant CacheWriteDeniedError catch arms

Mirror the read-denied simplification on the save path. CacheWriteDeniedError
is not an HttpClientError and its name does not match the ReserveCacheError
arm, so it falls through to the same non-fatal warning. Logging behavior is
unchanged (warns, never fails the run) and the exported type is still thrown
internally for consumers and tests. Also refresh stale doc wording.

* test(cache): collapse redundant restore getCacheEntry-failure tests

The two restoreCache tests exercised the identical warning + cache-miss path
now that read-denied is no longer reclassified in the catch, so merge them into
one. The read-denied prefix detection that actually branches on the message is
covered by getCacheEntry tests in cacheHttpClient.test.ts.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
This commit is contained in:
Philip Gai
2026-07-13 10:03:16 -05:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 0786132e6a
commit ffdc20ef92
12 changed files with 416 additions and 31 deletions
+100 -27
View File
@@ -3,7 +3,13 @@ import * as path from 'path'
import * as utils from './internal/cacheUtils.js'
import * as cacheHttpClient from './internal/cacheHttpClient.js'
import * as cacheTwirpClient from './internal/shared/cacheTwirpClient.js'
import {getCacheServiceVersion, isGhes} from './internal/config.js'
import {
getCacheServiceVersion,
isGhes,
getCacheMode,
isCacheReadable,
isCacheWritable
} from './internal/config.js'
import {DownloadOptions, UploadOptions} from './options.js'
import {createTar, extractTar, listTar} from './internal/tar.js'
import {
@@ -13,6 +19,7 @@ import {
GetCacheEntryDownloadURLRequest
} from './generated/results/api/v1/cache.js'
import {HttpClientError} from '@actions/http-client'
import {CacheReadDeniedMessagePrefix} from './internal/constants.js'
export type {DownloadOptions, UploadOptions}
export class ValidationError extends Error {
@@ -32,12 +39,13 @@ export class ReserveCacheError extends Error {
}
/**
* Stable prefix the receiver writes into the cache reservation response when
* the issuer downgraded the cache token to read-only (for example, because
* Stable prefix the cache service writes into the cache reservation response
* when the issuer downgraded the cache token to read-only (for example, because
* the run was triggered by an untrusted event). saveCacheV1 / saveCacheV2
* dispatch on this prefix to re-classify the failure as a
* CacheWriteDeniedError so consumers (and the outer catch arm) can
* distinguish a policy denial from other reservation failures.
* dispatch on this prefix to re-classify the failure as a CacheWriteDeniedError
* so consumers and tests can distinguish a policy denial from other reservation
* failures. Internally it is logged as a non-fatal warning like other
* best-effort save failures.
*/
export const CACHE_WRITE_DENIED_PREFIX = 'cache write denied:'
@@ -45,7 +53,7 @@ export const CACHE_WRITE_DENIED_PREFIX = 'cache write denied:'
* Raised when the cache backend refuses to reserve a writable cache entry
* because the JWT issued for this run was scoped read-only (for example, the
* run was triggered by an event the repository administrator classified as
* untrusted). The receiver-supplied detail message always begins with
* untrusted). The service-supplied detail message always begins with
* `cache write denied:` (the full error message includes additional context
* like the cache key).
*
@@ -62,6 +70,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)
@@ -134,6 +157,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(
@@ -191,10 +225,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
@@ -239,7 +288,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' &&
@@ -314,7 +365,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(
@@ -370,8 +433,10 @@ async function restoreCacheV2(
if (typedError.name === ValidationError.name) {
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
// Suppress all non-validation cache related errors because caching should be optional
// 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' &&
@@ -414,6 +479,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)
@@ -521,15 +598,13 @@ async function saveCacheV1(
const typedError = error as Error
if (typedError.name === ValidationError.name) {
throw error
} else if (typedError.name === CacheWriteDeniedError.name) {
// Cache write was denied by policy (read-only token). Surface to the
// customer at warning level so it is visible in the workflow log
// without failing the run.
core.warning(`Failed to save: ${typedError.message}`)
} else if (typedError.name === ReserveCacheError.name) {
core.info(`Failed to save: ${typedError.message}`)
} else {
// Log server errors (5xx) as errors, all other errors as warnings
// Log server errors (5xx) as errors, all other errors as warnings.
// A write denied by policy (CacheWriteDeniedError) is not an
// HttpClientError and its name does not match the ReserveCacheError arm,
// so it falls here and is warned without failing the run.
if (
typedError instanceof HttpClientError &&
typeof typedError.statusCode === 'number' &&
@@ -683,17 +758,15 @@ async function saveCacheV2(
const typedError = error as Error
if (typedError.name === ValidationError.name) {
throw error
} else if (typedError.name === CacheWriteDeniedError.name) {
// Cache write was denied by policy (read-only token). Surface to the
// customer at warning level so it is visible in the workflow log
// without failing the run.
core.warning(`Failed to save: ${typedError.message}`)
} else if (typedError.name === ReserveCacheError.name) {
core.info(`Failed to save: ${typedError.message}`)
} else if (typedError.name === FinalizeCacheError.name) {
core.warning(typedError.message)
} else {
// Log server errors (5xx) as errors, all other errors as warnings
// Log server errors (5xx) as errors, all other errors as warnings.
// A write denied by policy (CacheWriteDeniedError) is not an
// HttpClientError and its name does not match the ReserveCacheError arm,
// so it falls here and is warned without failing the run.
if (
typedError instanceof HttpClientError &&
typeof typedError.statusCode === 'number' &&
+7
View File
@@ -35,6 +35,7 @@ import {
retryTypedResponse
} from './requestUtils.js'
import {getCacheServiceURL} from './config.js'
import {CacheReadDeniedMessagePrefix} from './constants.js'
import {getUserAgentString} from './shared/user-agent.js'
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:'