diff --git a/packages/cache/RELEASES.md b/packages/cache/RELEASES.md index cc9e33a202..fb668e6974 100644 --- a/packages/cache/RELEASES.md +++ b/packages/cache/RELEASES.md @@ -1,5 +1,10 @@ # @actions/cache Releases +## 6.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. + ## 6.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). diff --git a/packages/cache/__tests__/cacheHttpClient.test.ts b/packages/cache/__tests__/cacheHttpClient.test.ts index e2201cd1cb..397461f33d 100644 --- a/packages/cache/__tests__/cacheHttpClient.test.ts +++ b/packages/cache/__tests__/cacheHttpClient.test.ts @@ -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, diff --git a/packages/cache/__tests__/config.test.ts b/packages/cache/__tests__/config.test.ts index 52d86d3620..7582bb3251 100644 --- a/packages/cache/__tests__/config.test.ts +++ b/packages/cache/__tests__/config.test.ts @@ -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) + }) +}) diff --git a/packages/cache/__tests__/restoreCache.test.ts b/packages/cache/__tests__/restoreCache.test.ts index 4b5c6f865f..cfff4738f6 100644 --- a/packages/cache/__tests__/restoreCache.test.ts +++ b/packages/cache/__tests__/restoreCache.test.ts @@ -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' diff --git a/packages/cache/__tests__/restoreCacheV2.test.ts b/packages/cache/__tests__/restoreCacheV2.test.ts index 421069db9d..0b62baa463 100644 --- a/packages/cache/__tests__/restoreCacheV2.test.ts +++ b/packages/cache/__tests__/restoreCacheV2.test.ts @@ -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' diff --git a/packages/cache/__tests__/saveCache.test.ts b/packages/cache/__tests__/saveCache.test.ts index 917846425b..89eaf085f2 100644 --- a/packages/cache/__tests__/saveCache.test.ts +++ b/packages/cache/__tests__/saveCache.test.ts @@ -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' diff --git a/packages/cache/package-lock.json b/packages/cache/package-lock.json index 92530faf35..1226ca4e6b 100644 --- a/packages/cache/package-lock.json +++ b/packages/cache/package-lock.json @@ -1,12 +1,12 @@ { "name": "@actions/cache", - "version": "6.1.0", + "version": "6.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@actions/cache", - "version": "6.1.0", + "version": "6.2.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.1", diff --git a/packages/cache/package.json b/packages/cache/package.json index 209212de77..bc7115f50c 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -1,6 +1,6 @@ { "name": "@actions/cache", - "version": "6.1.0", + "version": "6.2.0", "description": "Actions cache lib", "keywords": [ "github", diff --git a/packages/cache/src/cache.ts b/packages/cache/src/cache.ts index c53404049b..af8eabb6b5 100644 --- a/packages/cache/src/cache.ts +++ b/packages/cache/src/cache.ts @@ -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' && diff --git a/packages/cache/src/internal/cacheHttpClient.ts b/packages/cache/src/internal/cacheHttpClient.ts index 879d73f079..3b364814d5 100644 --- a/packages/cache/src/internal/cacheHttpClient.ts +++ b/packages/cache/src/internal/cacheHttpClient.ts @@ -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}`) } diff --git a/packages/cache/src/internal/config.ts b/packages/cache/src/internal/config.ts index 24b9fa1af8..70526475a4 100644 --- a/packages/cache/src/internal/config.ts +++ b/packages/cache/src/internal/config.ts @@ -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() diff --git a/packages/cache/src/internal/constants.ts b/packages/cache/src/internal/constants.ts index 8c5d1ee440..cec23808dc 100644 --- a/packages/cache/src/internal/constants.ts +++ b/packages/cache/src/internal/constants.ts @@ -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:'