diff --git a/internal/system/cache_github_actions.go b/internal/system/cache_github_actions.go index 7aeb90c5..420bcc6d 100644 --- a/internal/system/cache_github_actions.go +++ b/internal/system/cache_github_actions.go @@ -6,6 +6,7 @@ import ( "compress/gzip" "context" "crypto/sha256" + "errors" "fmt" "io" "os" @@ -16,9 +17,17 @@ import ( actionscache "github.com/tonistiigi/go-actions-cache" ) +// actionsCacheClient is the subset of the GitHub Actions cache client used by +// GitHubActionsCache. It exists so the save/load behaviour can be tested without +// talking to the real API. +type actionsCacheClient interface { + Load(ctx context.Context, keys ...string) (*actionscache.Entry, error) + Save(ctx context.Context, key string, b actionscache.Blob) error +} + // GitHubActionsCache implements Cache interface using GitHub Actions cache type GitHubActionsCache struct { - client *actionscache.Cache + client actionsCacheClient prefix string tempFiles []string mu sync.Mutex @@ -78,6 +87,11 @@ func (c *GitHubActionsCache) Set(ctx context.Context, key string, data io.Reader err = c.client.Save(ctx, cacheKey, blob) if err != nil { + // Another job stored the same key first, the content is identical so this is not an error + if errors.Is(err, os.ErrExist) { + return nil + } + return fmt.Errorf("failed to save to GitHub Actions cache: %w", err) } @@ -199,6 +213,11 @@ func (c *GitHubActionsCache) StoreFolderCache(ctx context.Context, key string, f // Store the archive in cache blob := actionscache.NewBlob(buf.Bytes()) if err := c.client.Save(ctx, cacheKey, blob); err != nil { + // Another job stored the same key first, the content is identical so this is not an error + if errors.Is(err, os.ErrExist) { + return nil + } + return fmt.Errorf("failed to save folder to GitHub Actions cache: %w", err) } diff --git a/internal/system/cache_github_actions_test.go b/internal/system/cache_github_actions_test.go new file mode 100644 index 00000000..ed2a27e3 --- /dev/null +++ b/internal/system/cache_github_actions_test.go @@ -0,0 +1,95 @@ +package system + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + actionscache "github.com/tonistiigi/go-actions-cache" +) + +// fakeActionsCacheClient records Save calls and returns a canned error. +type fakeActionsCacheClient struct { + saveErr error + savedKeys []string +} + +// Load is only present to satisfy actionsCacheClient; these tests exercise the save paths. +func (f *fakeActionsCacheClient) Load(ctx context.Context, keys ...string) (*actionscache.Entry, error) { + return nil, ErrCacheNotFound +} + +func (f *fakeActionsCacheClient) Save(ctx context.Context, key string, b actionscache.Blob) error { + f.savedKeys = append(f.savedKeys, key) + return f.saveErr +} + +// alreadyExistsError mimics what the GitHub Actions cache API returns when an +// entry for the key was already uploaded, wrapped the same way the library does. +func alreadyExistsError() error { + return actionscache.HTTPError{ + StatusCode: 409, + Err: actionscache.GithubAPIError{ + Message: "409 Conflict", + TypeKey: "ArtifactCacheItemAlreadyExistsException", + }, + } +} + +func TestGitHubActionsCacheSetIgnoresAlreadyExists(t *testing.T) { + t.Parallel() + + client := &fakeActionsCacheClient{saveErr: alreadyExistsError()} + cache := &GitHubActionsCache{client: client, prefix: "sw-cli"} + + err := cache.Set(t.Context(), "some-key", strings.NewReader("content")) + + require.NoError(t, err, "already existing cache entry must not fail the build") + assert.Len(t, client.savedKeys, 1) +} + +func TestGitHubActionsCacheSetReturnsOtherErrors(t *testing.T) { + t.Parallel() + + client := &fakeActionsCacheClient{saveErr: assert.AnError} + cache := &GitHubActionsCache{client: client, prefix: "sw-cli"} + + err := cache.Set(t.Context(), "some-key", strings.NewReader("content")) + + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) +} + +func TestGitHubActionsCacheStoreFolderCacheIgnoresAlreadyExists(t *testing.T) { + t.Parallel() + + folder := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(folder, "app.js"), []byte("console.log(1)"), 0o644)) + + client := &fakeActionsCacheClient{saveErr: alreadyExistsError()} + cache := &GitHubActionsCache{client: client, prefix: "sw-cli"} + + err := cache.StoreFolderCache(t.Context(), "sw-cli-6.7.0.0-abc", folder) + + require.NoError(t, err, "already existing folder cache entry must not fail the build") + assert.Len(t, client.savedKeys, 1) +} + +func TestGitHubActionsCacheStoreFolderCacheReturnsOtherErrors(t *testing.T) { + t.Parallel() + + folder := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(folder, "app.js"), []byte("console.log(1)"), 0o644)) + + client := &fakeActionsCacheClient{saveErr: assert.AnError} + cache := &GitHubActionsCache{client: client, prefix: "sw-cli"} + + err := cache.StoreFolderCache(t.Context(), "sw-cli-6.7.0.0-abc", folder) + + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) +}