diff --git a/README.md b/README.md index 6cb71e7..e7fa57e 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ If you are using a `self-hosted` Windows runner, `GNU tar` and `zstd` are requir * `enableCrossOsArchive` - An optional boolean when enabled, allows Windows runners to save or restore caches that can be restored or saved respectively on other platforms. Default: `false` * `fail-on-cache-miss` - Fail the workflow if cache entry is not found. Default: `false` * `lookup-only` - If true, only checks if cache entry exists and skips download. Does not change save cache behavior. Default: `false` +* `fail-on-save-error` - Fail the workflow if the cache save step encounters an error, rather than logging a warning and succeeding. Default: `false` #### Environment Variables diff --git a/__tests__/saveImpl.test.ts b/__tests__/saveImpl.test.ts index ad154f6..ecb3b71 100644 --- a/__tests__/saveImpl.test.ts +++ b/__tests__/saveImpl.test.ts @@ -365,6 +365,84 @@ test("save with server error outputs warning", async () => { expect(failedMock).toHaveBeenCalledTimes(0); }); +test("save with fail-on-error and save error calls setFailed", async () => { + const logWarningMock = jest.spyOn(actionUtils, "logWarning"); + const failedMock = jest.spyOn(core, "setFailed"); + + const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const savedCacheKey = "Linux-node-"; + + jest.spyOn(core, "getState") + .mockImplementationOnce(() => savedCacheKey) + .mockImplementationOnce(() => primaryKey); + + const inputPath = "node_modules"; + testUtils.setInput(Inputs.Path, inputPath); + testUtils.setInput(Inputs.FailOnError, "true"); + + jest.spyOn(cache, "saveCache").mockImplementationOnce(() => { + throw new Error("HTTP Error Occurred"); + }); + + await saveImpl(new StateProvider()); + + expect(failedMock).toHaveBeenCalledWith("HTTP Error Occurred"); + expect(failedMock).toHaveBeenCalledTimes(1); + expect(logWarningMock).toHaveBeenCalledTimes(0); +}); + +test("save with fail-on-save-error (global action input) and save error calls setFailed", async () => { + const logWarningMock = jest.spyOn(actionUtils, "logWarning"); + const failedMock = jest.spyOn(core, "setFailed"); + + const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const savedCacheKey = "Linux-node-"; + + jest.spyOn(core, "getState") + .mockImplementationOnce(() => savedCacheKey) + .mockImplementationOnce(() => primaryKey); + + const inputPath = "node_modules"; + testUtils.setInput(Inputs.Path, inputPath); + testUtils.setInput(Inputs.FailOnSaveError, "true"); + + jest.spyOn(cache, "saveCache").mockImplementationOnce(() => { + throw new Error("HTTP Error Occurred"); + }); + + await saveImpl(new StateProvider()); + + expect(failedMock).toHaveBeenCalledWith("HTTP Error Occurred"); + expect(failedMock).toHaveBeenCalledTimes(1); + expect(logWarningMock).toHaveBeenCalledTimes(0); +}); + +test("save with fail-on-error false and save error logs warning", async () => { + const logWarningMock = jest.spyOn(actionUtils, "logWarning"); + const failedMock = jest.spyOn(core, "setFailed"); + + const primaryKey = "Linux-node-bb828da54c148048dd17899ba9fda624811cfb43"; + const savedCacheKey = "Linux-node-"; + + jest.spyOn(core, "getState") + .mockImplementationOnce(() => savedCacheKey) + .mockImplementationOnce(() => primaryKey); + + const inputPath = "node_modules"; + testUtils.setInput(Inputs.Path, inputPath); + testUtils.setInput(Inputs.FailOnError, "false"); + + jest.spyOn(cache, "saveCache").mockImplementationOnce(() => { + throw new Error("HTTP Error Occurred"); + }); + + await saveImpl(new StateProvider()); + + expect(logWarningMock).toHaveBeenCalledWith("HTTP Error Occurred"); + expect(logWarningMock).toHaveBeenCalledTimes(1); + expect(failedMock).toHaveBeenCalledTimes(0); +}); + test("save with valid inputs uploads a cache", async () => { const failedMock = jest.spyOn(core, "setFailed"); diff --git a/action.yml b/action.yml index 2606455..7ff77c4 100644 --- a/action.yml +++ b/action.yml @@ -26,6 +26,10 @@ inputs: description: 'Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache' default: 'false' required: false + fail-on-save-error: + description: 'Fail the workflow if cache save fails' + default: 'false' + required: false save-always: description: 'Run the post step to save the cache even if another step before fails' default: 'false' diff --git a/save/README.md b/save/README.md index d5352fd..e991bf0 100644 --- a/save/README.md +++ b/save/README.md @@ -9,6 +9,7 @@ The save action saves a cache. It works similarly to the `cache` action except t * `key` - An explicit key for a cache entry. See [creating a cache key](../README.md#creating-a-cache-key). * `path` - A list of files, directories, and wildcard patterns to cache. See [`@actions/glob`](https://github.com/actions/toolkit/tree/main/packages/glob) for supported patterns. * `upload-chunk-size` - The chunk size used to split up large files during upload, in bytes +* `fail-on-error` - Fail the workflow if the cache save encounters an error, rather than logging a warning and succeeding. Default: `false` ### Outputs @@ -68,6 +69,24 @@ with: key: npm-cache-${{hashfiles(package-lock.json)}} ``` +### Fail workflow on save error + +By default, errors during cache save (such as exceeding the cache size limit or a network failure) log a warning and allow the workflow to continue. Use `fail-on-error: true` to treat any save error as a workflow failure instead. + +```yaml +steps: + - uses: actions/checkout@v6 + + - name: Install Dependencies + run: /install.sh + + - uses: actions/cache/save@v5 + with: + path: path/to/dependencies + key: ${{ runner.os }}-${{ hashFiles('**/lockfiles') }} + fail-on-error: true +``` + ### Always save cache There are instances where some flaky test cases would fail the entire workflow and users would get frustrated because the builds would run for hours and the cache couldn't be saved as the workflow failed in between. diff --git a/save/action.yml b/save/action.yml index 1b95184..67a3015 100644 --- a/save/action.yml +++ b/save/action.yml @@ -15,6 +15,10 @@ inputs: description: 'An optional boolean when enabled, allows windows runners to save caches that can be restored on other platforms' default: 'false' required: false + fail-on-error: + description: 'Fail the workflow if cache save fails' + default: 'false' + required: false runs: using: 'node24' main: '../dist/save-only/index.js' diff --git a/src/constants.ts b/src/constants.ts index 0158ae0..014fdbd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,7 +5,9 @@ export enum Inputs { UploadChunkSize = "upload-chunk-size", // Input for cache, save action EnableCrossOsArchive = "enableCrossOsArchive", // Input for cache, restore, save action FailOnCacheMiss = "fail-on-cache-miss", // Input for cache, restore action - LookupOnly = "lookup-only" // Input for cache, restore action + LookupOnly = "lookup-only", // Input for cache, restore action + FailOnError = "fail-on-error", // Input for save action + FailOnSaveError = "fail-on-save-error" // Input for cache action (combined), alias for FailOnError } export enum Outputs { diff --git a/src/saveImpl.ts b/src/saveImpl.ts index 4e5c312..cab4692 100644 --- a/src/saveImpl.ts +++ b/src/saveImpl.ts @@ -18,6 +18,9 @@ export async function saveImpl( stateProvider: IStateProvider ): Promise { let cacheId = -1; + const failOnError = + utils.getInputAsBool(Inputs.FailOnSaveError) || + utils.getInputAsBool(Inputs.FailOnError); try { if (!utils.isCacheFeatureAvailable()) { return; @@ -73,7 +76,11 @@ export async function saveImpl( core.info(`Cache saved with key: ${primaryKey}`); } } catch (error: unknown) { - utils.logWarning((error as Error).message); + if (failOnError) { + core.setFailed((error as Error).message); + } else { + utils.logWarning((error as Error).message); + } } return cacheId; } diff --git a/src/utils/testUtils.ts b/src/utils/testUtils.ts index ba0670b..250e8ad 100644 --- a/src/utils/testUtils.ts +++ b/src/utils/testUtils.ts @@ -42,4 +42,6 @@ export function clearInputs(): void { delete process.env[getInputName(Inputs.EnableCrossOsArchive)]; delete process.env[getInputName(Inputs.FailOnCacheMiss)]; delete process.env[getInputName(Inputs.LookupOnly)]; + delete process.env[getInputName(Inputs.FailOnError)]; + delete process.env[getInputName(Inputs.FailOnSaveError)]; }