Skip to content

Conversation

@timbretimber
Copy link

Make use of goreleaser binary in cache if present, would also close #476. Useful for self-hosted runners.

Copy link
Member

@crazy-max crazy-max left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could do smth similar to https://github.com/magefile/mage-action/blob/6f50bbb8ea47d56e62dee92392788acbc8192d0b/src/installer.ts#L36 to use both tool-cache and GitHub Actions cache?

required: false
cache-binary:
description: 'Cache binary to GitHub Actions cache backend'
default: 'false'
Copy link
Author

@timbretimber timbretimber Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mage defaults to true here but for runners hosted on github i don't think there's much if anything to gain from caching the binary as they are both on the same infrastructure.

@coderabbitai
Copy link

coderabbitai bot commented Nov 8, 2025

Walkthrough

This PR adds caching functionality to the GoReleaser GitHub Action. It introduces a new optional cache-binary input flag (default false) that enables storing and retrieving the GoReleaser binary from the GitHub Actions cache. When enabled, the action extracts the semver from the release tag and uses it as a cache key to check for a previously cached binary before downloading. If a valid cached binary exists, it reuses that instead of downloading again. The binary is stored in and retrieved from a ~/.goreleaser directory, with cache operations integrated using the @actions/cache library.

Sequence Diagram

sequenceDiagram
    participant main as main.ts
    participant goreleaser as goreleaser.ts
    participant cache as `@actions/cache`

    main->>goreleaser: install(distribution, version, cacheBinary=true)
    
    alt cacheBinary enabled
        goreleaser->>goreleaser: Extract semver from version tag
        goreleaser->>goreleaser: Create ~/.goreleaser directory
        
        rect rgb(200, 220, 255)
        Note over goreleaser: Cache lookup phase
        goreleaser->>goreleaser: Check local cache for binary<br/>(validate executable)
        alt Binary found in local cache
            goreleaser-->>main: Return cached path
        end
        end
        
        rect rgb(220, 200, 255)
        Note over goreleaser,cache: GitHub Actions cache phase
        goreleaser->>cache: restoreCache(key=semver)
        cache-->>goreleaser: Restored path (if exists)
        alt Cache restored successfully
            goreleaser-->>main: Return cached binary path
        end
        end
        
        rect rgb(255, 220, 200)
        Note over goreleaser: Download and cache phase
        goreleaser->>goreleaser: Download & extract binary<br/>to ~/.goreleaser
        goreleaser->>cache: saveCache(key=semver, path)
        cache-->>goreleaser: Cache saved
        goreleaser-->>main: Return extracted binary path
        end
    else cacheBinary disabled
        rect rgb(240, 240, 240)
        Note over goreleaser: Standard download
        goreleaser->>goreleaser: Download & extract binary<br/>(skip cache operations)
        goreleaser-->>main: Return extracted path
        end
    end
Loading

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: implementing caching functionality to reuse goreleaser binaries.
Description check ✅ Passed The description clearly relates to the changeset and references issue #476, explaining the purpose of caching binaries.
Linked Issues check ✅ Passed The PR implementation satisfies #476 by adding cache lookup logic and storing binaries in cache for reuse across runs.
Out of Scope Changes check ✅ Passed All changes directly support the caching objective: documentation, configuration, dependencies, and implementation align with requirements.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9cf3611 and 04e0e23.

⛔ Files ignored due to path filters (4)
  • dist/index.js is excluded by !**/dist/**
  • dist/index.js.map is excluded by !**/dist/**, !**/*.map
  • dist/licenses.txt is excluded by !**/dist/**
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (6)
  • README.md (1 hunks)
  • action.yml (1 hunks)
  • package.json (1 hunks)
  • src/context.ts (2 hunks)
  • src/goreleaser.ts (4 hunks)
  • src/main.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/goreleaser.ts (2)
src/github.ts (1)
  • GitHubRelease (29-31)
dist/index.js (2)
  • getExePath (3-3)
  • getCacheKey (3-3)

Comment on lines +35 to +38
const goreleaserHome = path.join(process.env.HOME, '.goreleaser');
if (!fs.existsSync(goreleaserHome)) {
fs.mkdirSync(goreleaserHome, {recursive: true});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix home directory resolution for Windows runners

process.env.HOME is undefined on Windows runners, so path.join throws and the action now fails before any install occurs whenever cache-binary (or even the default flow) hits this block. Use os.homedir() instead so all supported runners keep working.

-import * as path from 'path';
+import * as os from 'os';
+import * as path from 'path';
@@
-  const goreleaserHome = path.join(process.env.HOME, '.goreleaser');
+  const goreleaserHome = path.join(os.homedir(), '.goreleaser');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const goreleaserHome = path.join(process.env.HOME, '.goreleaser');
if (!fs.existsSync(goreleaserHome)) {
fs.mkdirSync(goreleaserHome, {recursive: true});
}
const goreleaserHome = path.join(os.homedir(), '.goreleaser');
if (!fs.existsSync(goreleaserHome)) {
fs.mkdirSync(goreleaserHome, {recursive: true});
}
🤖 Prompt for AI Agents
In src/goreleaser.ts around lines 35 to 38, the code uses process.env.HOME which
is undefined on Windows and causes path.join to throw; replace usage with
os.homedir() (importing/require'ing the Node os module if not already imported)
so the home directory is resolved cross-platform, e.g. build goreleaserHome from
os.homedir() and leave the fs.existsSync/fs.mkdirSync logic unchanged.

Comment on lines +71 to +74
if (cacheBinary && cache.isFeatureAvailable()) {
core.debug(`Caching to GitHub actions cache`);
await cache.saveCache([getExePath(goreleaserHome)], getCacheKey(semver));
}
Copy link

@coderabbitai coderabbitai bot Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Prevent cache.saveCache from failing on duplicate keys

cache.saveCache rejects with “Cache already exists” when another job has populated the same key; without handling, every later run with cache-binary enabled fails even though the install succeeded. Catch that case (while still surfacing real errors) so repeat installs remain healthy.

   if (cacheBinary && cache.isFeatureAvailable()) {
     core.debug(`Caching to GitHub actions cache`);
-    await cache.saveCache([getExePath(goreleaserHome)], getCacheKey(semver));
+    try {
+      await cache.saveCache([getExePath(goreleaserHome)], getCacheKey(semver));
+    } catch (error) {
+      const message = (error as Error).message ?? '';
+      if (message.includes('Cache already exists')) {
+        core.debug(`Skipping cache save: ${message}`);
+      } else {
+        throw error;
+      }
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (cacheBinary && cache.isFeatureAvailable()) {
core.debug(`Caching to GitHub actions cache`);
await cache.saveCache([getExePath(goreleaserHome)], getCacheKey(semver));
}
if (cacheBinary && cache.isFeatureAvailable()) {
core.debug(`Caching to GitHub actions cache`);
try {
await cache.saveCache([getExePath(goreleaserHome)], getCacheKey(semver));
} catch (error) {
const message = (error as Error).message ?? '';
if (message.includes('Cache already exists')) {
core.debug(`Skipping cache save: ${message}`);
} else {
throw error;
}
}
}
🤖 Prompt for AI Agents
In src/goreleaser.ts around lines 71 to 74, the call to cache.saveCache can
reject with "Cache already exists" when another job populated the same key; wrap
the await cache.saveCache(...) call in a try/catch, and in the catch check the
error message (or error.code) for the "Cache already exists" condition — if it
matches, log a debug/info message and continue (silently ignore that specific
error); for any other errors rethrow or surface them so real failures are not
swallowed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see this behavior, and the string Cache already exists doesn't appear in the @actions/cache source code so I don't think this is correct. I did some manual testing by hardcoding the version in

const release: github.GitHubRelease = await github.getRelease(distribution, version);
from v2.12.4 to v2.12.7 and the cache updated as expected, downloaded new version the first run and used it from cache afterwards.

One issue I did notice though is if using toolcache with a semver range like ~> v2 you can't really invalidate the cache when a new version is released without splitting up the save/restore steps and capturing the version in between, making it only really practical to use with specific versions. However @actions/cache works fine with the same statement.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the @actions/cache source code for "Cache already exists" error message
curl -s "https://api.github.com/search/code?q=repo:actions/cache+%22Cache+already+exists%22" | jq -r '.items[] | .path'

Length of output: 58


🏁 Script executed:

#!/bin/bash
# Search for cache save error handling patterns in @actions/cache
curl -s "https://api.github.com/search/code?q=repo:actions/cache+saveCache+error" | jq -r '.items[0:5] | .[] | {path: .path, url: .html_url}'

Length of output: 58


🏁 Script executed:

#!/bin/bash
# Get the main save.ts or saveCache implementation from @actions/cache
curl -s "https://raw.githubusercontent.com/actions/toolkit/main/packages/cache/src/cache.ts" | grep -A 20 "export async function saveCache"

Length of output: 586


🏁 Script executed:

#!/bin/bash
# Look at the actual saveCache implementation details for v1 and v2
curl -s "https://raw.githubusercontent.com/actions/toolkit/main/packages/cache/src/cache.ts" | grep -A 100 "async function saveCacheV1" | head -50

Length of output: 1586


🏁 Script executed:

#!/bin/bash
# Check ReserveCacheError and other cache-related errors
curl -s "https://raw.githubusercontent.com/actions/toolkit/main/packages/cache/src/internal/cacheHttpClient.ts" | grep -B 5 -A 5 "ReserveCacheError\|already exists"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for ReserveCacheError definition
curl -s "https://raw.githubusercontent.com/actions/toolkit/main/packages/cache/src/internal/cacheHttpClient.ts" | grep -B 10 -A 10 "class.*Error\|ReserveCacheError"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the reserveCache function to see how it handles already-cached keys
curl -s "https://raw.githubusercontent.com/actions/toolkit/main/packages/cache/src/internal/cacheHttpClient.ts" | grep -A 50 "export async function reserveCache"

Length of output: 1295


🏁 Script executed:

#!/bin/bash
# Check if saveCache handles the case when reserveCache returns -1 (already exists)
curl -s "https://raw.githubusercontent.com/actions/toolkit/main/packages/cache/src/cache.ts" | grep -A 10 "reserveCacheResponse"

Length of output: 1134


🏁 Script executed:

#!/bin/bash
# Check how ReserveCacheError is handled in the catch block
curl -s "https://raw.githubusercontent.com/actions/toolkit/main/packages/cache/src/cache.ts" | grep -A 20 "typedError.name === ReserveCacheError.name"

Length of output: 1464


🏁 Script executed:

#!/bin/bash
# Look for ReserveCacheError class definition
curl -s "https://raw.githubusercontent.com/actions/toolkit/main/packages/cache/src/internal/cacheHttpClient.ts" -H "Accept: application/vnd.github.v3.raw" | head -100

Length of output: 2711


@timbretimber, you're absolutely right—thank you for testing and catching that! I reviewed the @actions/cache source, and I can confirm:

  1. The string "Cache already exists" doesn't appear anywhere in the codebase
  2. When reserveCache encounters a concurrent cache creation (another job with the same key), it throws a ReserveCacheError
  3. Critically, saveCache already catches ReserveCacheError internally and handles it gracefully by logging core.info() instead of throwing—so no additional error handling is needed

My suggestion was based on incorrect assumptions about the error behavior. Your testing confirmed the actual behavior: the cache library already handles concurrent saves properly.

Regarding your observation about toolcache with semver ranges (~> v2): that's a valid limitation. Since tc.cacheDir uses the resolved semver as the key and tc.find looks up by that exact version, a semver range will always match the first resolved version—you'd need to capture the actual version between operations to invalidate properly. The @actions/cache approach with explicit cache keys sidesteps that issue entirely. Good catch on identifying that distinction!

٩(◕‿◕)۶


✏️ Learnings added
Learnt from: timbretimber
Repo: goreleaser/goreleaser-action PR: 510
File: src/goreleaser.ts:71-74
Timestamp: 2025-11-15T02:14:36.929Z
Learning: In `actions/cache`, the `saveCache` function already handles concurrent cache creation gracefully. When `reserveCache` throws a `ReserveCacheError` (because another job is creating the same cache key), `saveCache` catches it internally and logs it with `core.info()` rather than throwing—no additional error handling is required by callers.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Action isn't using the previously cached download

2 participants