mirror of
https://github.com/actions/toolkit.git
synced 2026-08-15 00:00:25 +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
+40
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user