From a5631aba37057ab577a3d95e02dba846229de9fe Mon Sep 17 00:00:00 2001
From: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
Date: Thu, 22 Dec 2022 13:10:47 +0100
Subject: [PATCH] Add fail-on-cache-miss option

---
 __tests__/restore.test.ts  | 69 ++++++++++++++++++++++++++++++++++++++
 action.yml                 |  4 +++
 dist/restore-only/index.js | 10 +++++-
 dist/restore/index.js      | 10 +++++-
 dist/save-only/index.js    |  3 +-
 dist/save/index.js         |  3 +-
 restore/README.md          |  8 ++---
 restore/action.yml         |  6 +++-
 src/constants.ts           |  3 +-
 src/restoreImpl.ts         | 14 ++++++++
 src/utils/testUtils.ts     |  5 +++
 11 files changed, 124 insertions(+), 11 deletions(-)

diff --git a/__tests__/restore.test.ts b/__tests__/restore.test.ts
index ab768ba..b185322 100644
--- a/__tests__/restore.test.ts
+++ b/__tests__/restore.test.ts
@@ -205,3 +205,72 @@ test("restore with cache found for restore key", async () => {
     );
     expect(failedMock).toHaveBeenCalledTimes(0);
 });
+
+test("Fail restore when fail on cache miss is enabled and primary key not found", async () => {
+    const path = "node_modules";
+    const key = "node-test";
+    const restoreKey = "node-";
+    testUtils.setInputs({
+        path: path,
+        key,
+        restoreKeys: [restoreKey],
+        failOnCacheMiss: true
+    });
+
+    const failedMock = jest.spyOn(core, "setFailed");
+    const stateMock = jest.spyOn(core, "saveState");
+    const setCacheHitOutputMock = jest.spyOn(core, "setOutput");
+    const restoreCacheMock = jest
+        .spyOn(cache, "restoreCache")
+        .mockImplementationOnce(() => {
+            return Promise.resolve(undefined);
+        });
+
+    await run();
+
+    expect(restoreCacheMock).toHaveBeenCalledTimes(1);
+    expect(restoreCacheMock).toHaveBeenCalledWith([path], key, [restoreKey]);
+
+    expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
+    expect(setCacheHitOutputMock).toHaveBeenCalledTimes(0);
+
+    expect(failedMock).toHaveBeenCalledWith(
+        `Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${key}`
+    );
+    expect(failedMock).toHaveBeenCalledTimes(1);
+});
+
+test("Fail restore when fail on cache miss is enabled and primary key doesn't match restored key", async () => {
+    const path = "node_modules";
+    const key = "node-test";
+    const restoreKey = "node-";
+    testUtils.setInputs({
+        path: path,
+        key,
+        restoreKeys: [restoreKey],
+        failOnCacheMiss: true
+    });
+
+    const failedMock = jest.spyOn(core, "setFailed");
+    const stateMock = jest.spyOn(core, "saveState");
+    const setCacheHitOutputMock = jest.spyOn(core, "setOutput");
+    const restoreCacheMock = jest
+        .spyOn(cache, "restoreCache")
+        .mockImplementationOnce(() => {
+            return Promise.resolve(restoreKey);
+        });
+
+    await run();
+
+    expect(restoreCacheMock).toHaveBeenCalledTimes(1);
+    expect(restoreCacheMock).toHaveBeenCalledWith([path], key, [restoreKey]);
+
+    expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
+    expect(setCacheHitOutputMock).toHaveBeenCalledTimes(1);
+    expect(setCacheHitOutputMock).toHaveBeenCalledWith("cache-hit", "false");
+
+    expect(failedMock).toHaveBeenCalledWith(
+        `Restored cache key doesn't match the given input key. Exiting as fail-on-cache-miss is set. Input key: ${key}`
+    );
+    expect(failedMock).toHaveBeenCalledTimes(1);
+});
diff --git a/action.yml b/action.yml
index 424e191..9eb3857 100644
--- a/action.yml
+++ b/action.yml
@@ -11,6 +11,10 @@ inputs:
   restore-keys:
     description: 'An ordered list of keys to use for restoring stale cache if no cache hit occurred for key. Note `cache-hit` returns false in this case.'
     required: false
+  fail-on-cache-miss:
+    description: 'Fail the workflow if the cache is not found for the primary key'
+    required: false
+    default: "false"
   upload-chunk-size:
     description: 'The chunk size used to split up large files during upload, in bytes'
     required: false
diff --git a/dist/restore-only/index.js b/dist/restore-only/index.js
index f676abb..34c4dd6 100644
--- a/dist/restore-only/index.js
+++ b/dist/restore-only/index.js
@@ -4978,7 +4978,8 @@ var Inputs;
     Inputs["Path"] = "path";
     Inputs["RestoreKeys"] = "restore-keys";
     Inputs["UploadChunkSize"] = "upload-chunk-size";
-    Inputs["EnableCrossOsArchive"] = "enableCrossOsArchive"; // Input for cache, restore, save action
+    Inputs["EnableCrossOsArchive"] = "enableCrossOsArchive";
+    Inputs["FailOnCacheMiss"] = "fail-on-cache-miss"; // Input for cache, restore action
 })(Inputs = exports.Inputs || (exports.Inputs = {}));
 var Outputs;
 (function (Outputs) {
@@ -50497,6 +50498,9 @@ function restoreImpl(stateProvider) {
             const enableCrossOsArchive = utils.getInputAsBool(constants_1.Inputs.EnableCrossOsArchive);
             const cacheKey = yield cache.restoreCache(cachePaths, primaryKey, restoreKeys, {}, enableCrossOsArchive);
             if (!cacheKey) {
+                if (core.getBooleanInput(constants_1.Inputs.FailOnCacheMiss) == true) {
+                    throw new Error(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${primaryKey}`);
+                }
                 core.info(`Cache not found for input keys: ${[
                     primaryKey,
                     ...restoreKeys
@@ -50507,6 +50511,10 @@ function restoreImpl(stateProvider) {
             stateProvider.setState(constants_1.State.CacheMatchedKey, cacheKey);
             const isExactKeyMatch = utils.isExactKeyMatch(core.getInput(constants_1.Inputs.Key, { required: true }), cacheKey);
             core.setOutput(constants_1.Outputs.CacheHit, isExactKeyMatch.toString());
+            if (!isExactKeyMatch &&
+                core.getBooleanInput(constants_1.Inputs.FailOnCacheMiss) == true) {
+                throw new Error(`Restored cache key doesn't match the given input key. Exiting as fail-on-cache-miss is set. Input key: ${primaryKey}`);
+            }
             core.info(`Cache restored from key: ${cacheKey}`);
             return cacheKey;
         }
diff --git a/dist/restore/index.js b/dist/restore/index.js
index 6415478..df8456b 100644
--- a/dist/restore/index.js
+++ b/dist/restore/index.js
@@ -4978,7 +4978,8 @@ var Inputs;
     Inputs["Path"] = "path";
     Inputs["RestoreKeys"] = "restore-keys";
     Inputs["UploadChunkSize"] = "upload-chunk-size";
-    Inputs["EnableCrossOsArchive"] = "enableCrossOsArchive"; // Input for cache, restore, save action
+    Inputs["EnableCrossOsArchive"] = "enableCrossOsArchive";
+    Inputs["FailOnCacheMiss"] = "fail-on-cache-miss"; // Input for cache, restore action
 })(Inputs = exports.Inputs || (exports.Inputs = {}));
 var Outputs;
 (function (Outputs) {
@@ -50497,6 +50498,9 @@ function restoreImpl(stateProvider) {
             const enableCrossOsArchive = utils.getInputAsBool(constants_1.Inputs.EnableCrossOsArchive);
             const cacheKey = yield cache.restoreCache(cachePaths, primaryKey, restoreKeys, {}, enableCrossOsArchive);
             if (!cacheKey) {
+                if (core.getBooleanInput(constants_1.Inputs.FailOnCacheMiss) == true) {
+                    throw new Error(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${primaryKey}`);
+                }
                 core.info(`Cache not found for input keys: ${[
                     primaryKey,
                     ...restoreKeys
@@ -50507,6 +50511,10 @@ function restoreImpl(stateProvider) {
             stateProvider.setState(constants_1.State.CacheMatchedKey, cacheKey);
             const isExactKeyMatch = utils.isExactKeyMatch(core.getInput(constants_1.Inputs.Key, { required: true }), cacheKey);
             core.setOutput(constants_1.Outputs.CacheHit, isExactKeyMatch.toString());
+            if (!isExactKeyMatch &&
+                core.getBooleanInput(constants_1.Inputs.FailOnCacheMiss) == true) {
+                throw new Error(`Restored cache key doesn't match the given input key. Exiting as fail-on-cache-miss is set. Input key: ${primaryKey}`);
+            }
             core.info(`Cache restored from key: ${cacheKey}`);
             return cacheKey;
         }
diff --git a/dist/save-only/index.js b/dist/save-only/index.js
index 0d3295c..39ba9bc 100644
--- a/dist/save-only/index.js
+++ b/dist/save-only/index.js
@@ -5034,7 +5034,8 @@ var Inputs;
     Inputs["Path"] = "path";
     Inputs["RestoreKeys"] = "restore-keys";
     Inputs["UploadChunkSize"] = "upload-chunk-size";
-    Inputs["EnableCrossOsArchive"] = "enableCrossOsArchive"; // Input for cache, restore, save action
+    Inputs["EnableCrossOsArchive"] = "enableCrossOsArchive";
+    Inputs["FailOnCacheMiss"] = "fail-on-cache-miss"; // Input for cache, restore action
 })(Inputs = exports.Inputs || (exports.Inputs = {}));
 var Outputs;
 (function (Outputs) {
diff --git a/dist/save/index.js b/dist/save/index.js
index 1b0a733..c65c686 100644
--- a/dist/save/index.js
+++ b/dist/save/index.js
@@ -4978,7 +4978,8 @@ var Inputs;
     Inputs["Path"] = "path";
     Inputs["RestoreKeys"] = "restore-keys";
     Inputs["UploadChunkSize"] = "upload-chunk-size";
-    Inputs["EnableCrossOsArchive"] = "enableCrossOsArchive"; // Input for cache, restore, save action
+    Inputs["EnableCrossOsArchive"] = "enableCrossOsArchive";
+    Inputs["FailOnCacheMiss"] = "fail-on-cache-miss"; // Input for cache, restore action
 })(Inputs = exports.Inputs || (exports.Inputs = {}));
 var Outputs;
 (function (Outputs) {
diff --git a/restore/README.md b/restore/README.md
index e6592d6..361a589 100644
--- a/restore/README.md
+++ b/restore/README.md
@@ -7,6 +7,7 @@ The restore action, as the name suggest, restores a cache. It acts similar to th
 * `path` - A list of files, directories, and wildcard patterns to cache and restore. See [`@actions/glob`](https://github.com/actions/toolkit/tree/main/packages/glob) for supported patterns.
 * `key` - String used while saving cache for restoring the cache
 * `restore-keys` - An ordered list of prefix-matched keys to use for restoring stale cache if no cache hit occurred for key.
+* `fail-on-cache-miss` - Fail the workflow if the cache is not found for the primary key
 
 ## Outputs
 
@@ -95,7 +96,7 @@ steps:
 
 ### Exit workflow on cache miss
 
-You can use the output of this action to exit the workflow on cache miss. This way you can restrict your workflow to only initiate the build when `cache-hit` occurs, in other words, cache with exact key is found.
+You can use `fail-on-cache-miss: true` to exit the workflow on a cache miss. This way you can restrict your workflow to only initiate the build when a cache with the exact key is found.
 
 ```yaml
 steps:
@@ -106,10 +107,7 @@ steps:
     with:
       path: path/to/dependencies
       key: ${{ runner.os }}-${{ hashFiles('**/lockfiles') }}
-
-  - name: Check cache hit
-    if: steps.cache.outputs.cache-hit != 'true'
-    run: exit 1
+      fail-on-cache-miss: true
 
   - name: Build
     run: /build.sh
diff --git a/restore/action.yml b/restore/action.yml
index 8989197..1744a7f 100644
--- a/restore/action.yml
+++ b/restore/action.yml
@@ -15,6 +15,10 @@ inputs:
     description: 'An optional boolean when enabled, allows windows runners to restore caches that were saved on other platforms'
     default: 'false'
     required: false
+  fail-on-cache-miss:
+    description: 'Fail the workflow if the cache is not found for the primary key'
+    required: false
+    default: "false"
 outputs:
   cache-hit:
     description: 'A boolean value to indicate an exact match was found for the primary key'
@@ -27,4 +31,4 @@ runs:
   main: '../dist/restore-only/index.js'
 branding:
   icon: 'archive'
-  color: 'gray-dark'
\ No newline at end of file
+  color: 'gray-dark'
diff --git a/src/constants.ts b/src/constants.ts
index 97fa2a0..4de3845 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -3,7 +3,8 @@ export enum Inputs {
     Path = "path", // Input for cache, restore, save action
     RestoreKeys = "restore-keys", // Input for cache, restore action
     UploadChunkSize = "upload-chunk-size", // Input for cache, save action
-    EnableCrossOsArchive = "enableCrossOsArchive" // Input for cache, restore, save action
+    EnableCrossOsArchive = "enableCrossOsArchive", // Input for cache, restore, save action
+    FailOnCacheMiss = "fail-on-cache-miss" // Input for cache, restore action
 }
 
 export enum Outputs {
diff --git a/src/restoreImpl.ts b/src/restoreImpl.ts
index 6214cfd..db27f4f 100644
--- a/src/restoreImpl.ts
+++ b/src/restoreImpl.ts
@@ -44,6 +44,11 @@ async function restoreImpl(
         );
 
         if (!cacheKey) {
+            if (core.getBooleanInput(Inputs.FailOnCacheMiss) == true) {
+                throw new Error(
+                    `Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${primaryKey}`
+                );
+            }
             core.info(
                 `Cache not found for input keys: ${[
                     primaryKey,
@@ -63,6 +68,15 @@ async function restoreImpl(
         );
 
         core.setOutput(Outputs.CacheHit, isExactKeyMatch.toString());
+        if (
+            !isExactKeyMatch &&
+            core.getBooleanInput(Inputs.FailOnCacheMiss) == true
+        ) {
+            throw new Error(
+                `Restored cache key doesn't match the given input key. Exiting as fail-on-cache-miss is set. Input key: ${primaryKey}`
+            );
+        }
+
         core.info(`Cache restored from key: ${cacheKey}`);
 
         return cacheKey;
diff --git a/src/utils/testUtils.ts b/src/utils/testUtils.ts
index c0a3f43..2bc7d16 100644
--- a/src/utils/testUtils.ts
+++ b/src/utils/testUtils.ts
@@ -14,11 +14,13 @@ interface CacheInput {
     key: string;
     restoreKeys?: string[];
     enableCrossOsArchive?: boolean;
+    failOnCacheMiss?: boolean;
 }
 
 export function setInputs(input: CacheInput): void {
     setInput(Inputs.Path, input.path);
     setInput(Inputs.Key, input.key);
+    setInput(Inputs.FailOnCacheMiss, "false");
     input.restoreKeys &&
         setInput(Inputs.RestoreKeys, input.restoreKeys.join("\n"));
     input.enableCrossOsArchive !== undefined &&
@@ -26,12 +28,15 @@ export function setInputs(input: CacheInput): void {
             Inputs.EnableCrossOsArchive,
             input.enableCrossOsArchive.toString()
         );
+    input.failOnCacheMiss &&
+        setInput(Inputs.FailOnCacheMiss, String(input.failOnCacheMiss));
 }
 
 export function clearInputs(): void {
     delete process.env[getInputName(Inputs.Path)];
     delete process.env[getInputName(Inputs.Key)];
     delete process.env[getInputName(Inputs.RestoreKeys)];
+    delete process.env[getInputName(Inputs.FailOnCacheMiss)];
     delete process.env[getInputName(Inputs.UploadChunkSize)];
     delete process.env[getInputName(Inputs.EnableCrossOsArchive)];
 }