Repository: mheap/pin-github-action
Pull request #210 (2025-06-18 21:15:22)

perf: add caching to avoid duplicate GitHub API calls
Thanks for writing `pin-github-actions`!

I found that for some of my workflows (which are a [bit bonkers](https://github.com/photostructure/node-sqlite/actions/runs/15741574117), but that's native modules for you), this tool took upwards of 10 seconds to run on just one workflow.

I added caching in this PR: with this new code, the same workflow was done in under 2 seconds.


Patch:
diff --git a/findRefOnGithub.js b/findRefOnGithub.js
index 2808371..aa3ccbf 100644
--- a/findRefOnGithub.js
+++ b/findRefOnGithub.js
@@ -11,6 +11,14 @@ if (process.env.GITHUB_TOKEN) {
   auth = process.env.GITHUB_TOKEN;
 }
 
+// In-memory cache for resolved SHA commits
+const shaCache = new Map();
+
+// Export function to clear cache (for testing)
+export function clearCache() {
+  shaCache.clear();
+}
+
 let debug = () => {};
 export default function (action, log) {
   debug = log.extend("find-ref-on-github");
@@ -28,6 +36,13 @@ export default function (action, log) {
     const repo = action.repo;
     const pinned = action.pinnedVersion;
     const name = `${owner}/${repo}`;
+    const cacheKey = `${name}@${pinned}`;
+
+    // Check cache first
+    if (shaCache.has(cacheKey)) {
+      debug(`[${name}] Using cached SHA for ${pinned}`);
+      return resolve(shaCache.get(cacheKey));
+    }
 
     let error;
 
@@ -54,12 +69,15 @@ export default function (action, log) {
             tag_sha: object.sha,
           });
           debug(`[${name}] Fetched commit. Found SHA.`);
-          return resolve(tag.data.object.sha);
+          const sha = tag.data.object.sha;
+          shaCache.set(cacheKey, sha);
+          return resolve(sha);
         }
 
         // If it's already a commit, return that
         if (object.type === "commit") {
           debug(`[${name}] Ref is a commit. Found SHA.`);
+          shaCache.set(cacheKey, object.sha);
           return resolve(object.sha);
         }
       } catch (e) {
@@ -79,7 +97,9 @@ export default function (action, log) {
         repo,
         ref: pinned,
       });
-      return resolve(commit.data.sha);
+      const sha = commit.data.sha;
+      shaCache.set(cacheKey, sha);
+      return resolve(sha);
     } catch (e) {
       // If it's not a commit, it doesn't matter
       debug(`[${name}] Error fetching commit: ${e.message}`);


Test patch:
diff --git a/findRefOnGithub.test.js b/findRefOnGithub.test.js
index fa35a02..57c6e0e 100644
--- a/findRefOnGithub.test.js
+++ b/findRefOnGithub.test.js
@@ -3,7 +3,7 @@ nock.disableNetConnect();
 
 import debugLib from "debug";
 const debug = debugLib("pin-github-action-test");
-import run from "./findRefOnGithub";
+import run, { clearCache } from "./findRefOnGithub";
 const findRef = (action) => {
   return run.apply(null, [action, debug]);
 };
@@ -25,6 +25,7 @@ afterEach(() => {
     );
   }
   nock.cleanAll();
+  clearCache();
 });
 
 test("looks up a specific hash", async () => {
@@ -94,6 +95,53 @@ test("fails to find ref (rate limiting)", () => {
   );
 });
 
+test("uses cache for duplicate requests", async () => {
+  const testAction = {
+    ...action,
+    pinnedVersion: "v1.0.0",
+  };
+
+  // Mock only one set of API calls for the first request
+  // This validates caching because the second call would fail if it tried to make API requests
+  // (nock would throw "Nock: No match for request" since no additional mocks are set up)
+  mockTagRefLookupSuccess(testAction, "tags/v1.0.0", "tag-sha-123");
+  mockTagLookupSuccess(testAction, "tag-sha-123", "commit-sha-456");
+
+  // First call should make API requests
+  const result1 = await findRef(testAction);
+  expect(result1).toEqual("commit-sha-456");
+
+  // Second call must use cache (would fail if it tried to make API requests)
+  const result2 = await findRef(testAction);
+  expect(result2).toEqual("commit-sha-456");
+
+  // Both results should be identical
+  expect(result1).toEqual(result2);
+});
+
+test("duplicate requests without cache would fail", async () => {
+  const testAction = {
+    ...action,
+    pinnedVersion: "v2.0.0",
+  };
+
+  // Mock only one set of API calls
+  mockTagRefLookupSuccess(testAction, "tags/v2.0.0", "tag-sha-789");
+  mockTagLookupSuccess(testAction, "tag-sha-789", "commit-sha-abc");
+
+  // First call should succeed
+  const result1 = await findRef(testAction);
+  expect(result1).toEqual("commit-sha-abc");
+
+  // Clear cache to simulate what would happen without caching
+  clearCache();
+
+  // Second call should fail because no mocks are available
+  await expect(findRef(testAction)).rejects.toEqual(
+    `Unable to find SHA for nexmo/github-actions@v2.0.0\n`,
+  );
+});
+
 function mockRefLookupFailure(action, path) {
   path = path.replace("/", "%2F");
   nock("https://api.github.com")