mirror of
https://github.com/actions/toolkit.git
synced 2026-08-07 00:00:18 +02:00
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:
co-authored by
Copilot Autofix powered by AI
parent
0786132e6a
commit
ffdc20ef92
Vendored
+5
@@ -1,5 +1,10 @@
|
|||||||
# @actions/cache Releases
|
# @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
|
## 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).
|
- 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
@@ -1,8 +1,10 @@
|
|||||||
import {downloadCache} from '../src/internal/cacheHttpClient'
|
import {downloadCache, getCacheEntry} from '../src/internal/cacheHttpClient'
|
||||||
import {getCacheVersion} from '../src/internal/cacheUtils'
|
import {getCacheVersion} from '../src/internal/cacheUtils'
|
||||||
import {CompressionMethod} from '../src/internal/constants'
|
import {CompressionMethod} from '../src/internal/constants'
|
||||||
import * as downloadUtils from '../src/internal/downloadUtils'
|
import * as downloadUtils from '../src/internal/downloadUtils'
|
||||||
|
import * as requestUtils from '../src/internal/requestUtils'
|
||||||
import {DownloadOptions, getDownloadOptions} from '../src/options'
|
import {DownloadOptions, getDownloadOptions} from '../src/options'
|
||||||
|
import {HttpClientError} from '@actions/http-client'
|
||||||
|
|
||||||
jest.mock('../src/internal/downloadUtils')
|
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 () => {
|
test('downloadCache uses http-client for non-Azure URLs', async () => {
|
||||||
const downloadCacheHttpClientMock = jest.spyOn(
|
const downloadCacheHttpClientMock = jest.spyOn(
|
||||||
downloadUtils,
|
downloadUtils,
|
||||||
|
|||||||
+34
@@ -23,3 +23,37 @@ test('isGhes returns false for ghe.localhost', () => {
|
|||||||
process.env.GITHUB_SERVER_URL = 'https://my.domain.ghe.localhost'
|
process.env.GITHUB_SERVER_URL = 'https://my.domain.ghe.localhost'
|
||||||
expect(config.isGhes()).toBe(false)
|
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
@@ -99,6 +99,93 @@ test('restore with server error should fail', async () => {
|
|||||||
delete process.env['ACTIONS_RESULTS_URL']
|
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 () => {
|
test('restore with restore keys and no cache found', async () => {
|
||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'node-test'
|
const key = 'node-test'
|
||||||
|
|||||||
+40
@@ -33,6 +33,19 @@ beforeAll(() => {
|
|||||||
// Ensure that we're using v2 for these tests
|
// Ensure that we're using v2 for these tests
|
||||||
jest.spyOn(config, 'getCacheServiceVersion').mockReturnValue('v2')
|
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')
|
logDebugMock = jest.spyOn(core, 'debug')
|
||||||
logInfoMock = jest.spyOn(core, 'info')
|
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 () => {
|
test('restore with restore keys and no cache found', async () => {
|
||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'node-test'
|
const key = 'node-test'
|
||||||
|
|||||||
+81
@@ -35,6 +35,18 @@ beforeAll(() => {
|
|||||||
jest.spyOn(cacheUtils, 'createTempDirectory').mockImplementation(async () => {
|
jest.spyOn(cacheUtils, 'createTempDirectory').mockImplementation(async () => {
|
||||||
return Promise.resolve('/foo/bar')
|
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 () => {
|
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 () => {
|
test('save with large cache outputs should fail', async () => {
|
||||||
const filePath = 'node_modules'
|
const filePath = 'node_modules'
|
||||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/cache",
|
"name": "@actions/cache",
|
||||||
"version": "6.1.0",
|
"version": "6.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@actions/cache",
|
"name": "@actions/cache",
|
||||||
"version": "6.1.0",
|
"version": "6.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^3.0.1",
|
"@actions/core": "^3.0.1",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/cache",
|
"name": "@actions/cache",
|
||||||
"version": "6.1.0",
|
"version": "6.2.0",
|
||||||
"description": "Actions cache lib",
|
"description": "Actions cache lib",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"github",
|
"github",
|
||||||
|
|||||||
Vendored
+100
-27
@@ -3,7 +3,13 @@ import * as path from 'path'
|
|||||||
import * as utils from './internal/cacheUtils.js'
|
import * as utils from './internal/cacheUtils.js'
|
||||||
import * as cacheHttpClient from './internal/cacheHttpClient.js'
|
import * as cacheHttpClient from './internal/cacheHttpClient.js'
|
||||||
import * as cacheTwirpClient from './internal/shared/cacheTwirpClient.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 {DownloadOptions, UploadOptions} from './options.js'
|
||||||
import {createTar, extractTar, listTar} from './internal/tar.js'
|
import {createTar, extractTar, listTar} from './internal/tar.js'
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +19,7 @@ import {
|
|||||||
GetCacheEntryDownloadURLRequest
|
GetCacheEntryDownloadURLRequest
|
||||||
} from './generated/results/api/v1/cache.js'
|
} from './generated/results/api/v1/cache.js'
|
||||||
import {HttpClientError} from '@actions/http-client'
|
import {HttpClientError} from '@actions/http-client'
|
||||||
|
import {CacheReadDeniedMessagePrefix} from './internal/constants.js'
|
||||||
|
|
||||||
export type {DownloadOptions, UploadOptions}
|
export type {DownloadOptions, UploadOptions}
|
||||||
export class ValidationError extends Error {
|
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
|
* Stable prefix the cache service writes into the cache reservation response
|
||||||
* the issuer downgraded the cache token to read-only (for example, because
|
* when the issuer downgraded the cache token to read-only (for example, because
|
||||||
* the run was triggered by an untrusted event). saveCacheV1 / saveCacheV2
|
* the run was triggered by an untrusted event). saveCacheV1 / saveCacheV2
|
||||||
* dispatch on this prefix to re-classify the failure as a
|
* dispatch on this prefix to re-classify the failure as a CacheWriteDeniedError
|
||||||
* CacheWriteDeniedError so consumers (and the outer catch arm) can
|
* so consumers and tests can distinguish a policy denial from other reservation
|
||||||
* distinguish a policy denial from other reservation failures.
|
* 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:'
|
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
|
* 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
|
* 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
|
* 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
|
* `cache write denied:` (the full error message includes additional context
|
||||||
* like the cache key).
|
* 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 {
|
export class FinalizeCacheError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message)
|
super(message)
|
||||||
@@ -134,6 +157,17 @@ export async function restoreCache(
|
|||||||
|
|
||||||
checkPaths(paths)
|
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) {
|
switch (cacheServiceVersion) {
|
||||||
case 'v2':
|
case 'v2':
|
||||||
return await restoreCacheV2(
|
return await restoreCacheV2(
|
||||||
@@ -191,10 +225,25 @@ async function restoreCacheV1(
|
|||||||
let archivePath = ''
|
let archivePath = ''
|
||||||
try {
|
try {
|
||||||
// path are needed to compute version
|
// path are needed to compute version
|
||||||
const cacheEntry = await cacheHttpClient.getCacheEntry(keys, paths, {
|
let cacheEntry
|
||||||
compressionMethod,
|
try {
|
||||||
enableCrossOsArchive
|
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) {
|
if (!cacheEntry?.archiveLocation) {
|
||||||
// Cache not found
|
// Cache not found
|
||||||
return undefined
|
return undefined
|
||||||
@@ -239,7 +288,9 @@ async function restoreCacheV1(
|
|||||||
throw error
|
throw error
|
||||||
} else {
|
} else {
|
||||||
// warn on cache restore failure and continue build
|
// 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 (
|
if (
|
||||||
typedError instanceof HttpClientError &&
|
typedError instanceof HttpClientError &&
|
||||||
typeof typedError.statusCode === 'number' &&
|
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) {
|
if (!response.ok) {
|
||||||
core.debug(
|
core.debug(
|
||||||
@@ -370,8 +433,10 @@ async function restoreCacheV2(
|
|||||||
if (typedError.name === ValidationError.name) {
|
if (typedError.name === ValidationError.name) {
|
||||||
throw error
|
throw error
|
||||||
} else {
|
} else {
|
||||||
// Supress all non-validation cache related errors because caching should be optional
|
// Suppress 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 (
|
if (
|
||||||
typedError instanceof HttpClientError &&
|
typedError instanceof HttpClientError &&
|
||||||
typeof typedError.statusCode === 'number' &&
|
typeof typedError.statusCode === 'number' &&
|
||||||
@@ -414,6 +479,18 @@ export async function saveCache(
|
|||||||
core.debug(`Cache service version: ${cacheServiceVersion}`)
|
core.debug(`Cache service version: ${cacheServiceVersion}`)
|
||||||
checkPaths(paths)
|
checkPaths(paths)
|
||||||
checkKey(key)
|
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) {
|
switch (cacheServiceVersion) {
|
||||||
case 'v2':
|
case 'v2':
|
||||||
return await saveCacheV2(paths, key, options, enableCrossOsArchive)
|
return await saveCacheV2(paths, key, options, enableCrossOsArchive)
|
||||||
@@ -521,15 +598,13 @@ async function saveCacheV1(
|
|||||||
const typedError = error as Error
|
const typedError = error as Error
|
||||||
if (typedError.name === ValidationError.name) {
|
if (typedError.name === ValidationError.name) {
|
||||||
throw error
|
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) {
|
} else if (typedError.name === ReserveCacheError.name) {
|
||||||
core.info(`Failed to save: ${typedError.message}`)
|
core.info(`Failed to save: ${typedError.message}`)
|
||||||
} else {
|
} 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 (
|
if (
|
||||||
typedError instanceof HttpClientError &&
|
typedError instanceof HttpClientError &&
|
||||||
typeof typedError.statusCode === 'number' &&
|
typeof typedError.statusCode === 'number' &&
|
||||||
@@ -683,17 +758,15 @@ async function saveCacheV2(
|
|||||||
const typedError = error as Error
|
const typedError = error as Error
|
||||||
if (typedError.name === ValidationError.name) {
|
if (typedError.name === ValidationError.name) {
|
||||||
throw error
|
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) {
|
} else if (typedError.name === ReserveCacheError.name) {
|
||||||
core.info(`Failed to save: ${typedError.message}`)
|
core.info(`Failed to save: ${typedError.message}`)
|
||||||
} else if (typedError.name === FinalizeCacheError.name) {
|
} else if (typedError.name === FinalizeCacheError.name) {
|
||||||
core.warning(typedError.message)
|
core.warning(typedError.message)
|
||||||
} else {
|
} 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 (
|
if (
|
||||||
typedError instanceof HttpClientError &&
|
typedError instanceof HttpClientError &&
|
||||||
typeof typedError.statusCode === 'number' &&
|
typeof typedError.statusCode === 'number' &&
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
retryTypedResponse
|
retryTypedResponse
|
||||||
} from './requestUtils.js'
|
} from './requestUtils.js'
|
||||||
import {getCacheServiceURL} from './config.js'
|
import {getCacheServiceURL} from './config.js'
|
||||||
|
import {CacheReadDeniedMessagePrefix} from './constants.js'
|
||||||
import {getUserAgentString} from './shared/user-agent.js'
|
import {getUserAgentString} from './shared/user-agent.js'
|
||||||
|
|
||||||
function getCacheApiUrl(resource: string): string {
|
function getCacheApiUrl(resource: string): string {
|
||||||
@@ -101,6 +102,12 @@ export async function getCacheEntry(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (!isSuccessStatusCode(response.statusCode)) {
|
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}`)
|
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'
|
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 {
|
export function getCacheServiceURL(): string {
|
||||||
const version = getCacheServiceVersion()
|
const version = getCacheServiceVersion()
|
||||||
|
|
||||||
|
|||||||
+5
@@ -38,3 +38,8 @@ export const TarFilename = 'cache.tar'
|
|||||||
export const ManifestFilename = 'manifest.txt'
|
export const ManifestFilename = 'manifest.txt'
|
||||||
|
|
||||||
export const CacheFileSizeLimit = 10 * Math.pow(1024, 3) // 10GiB per repository
|
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