mirror of
https://github.com/actions/toolkit.git
synced 2026-08-12 00:00:17 +02:00
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:
co-authored by
Copilot App
parent
c6ca5e729f
commit
a107f24fb0
Vendored
+84
-8
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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:'
|
||||
|
||||
Reference in New Issue
Block a user