@@ -11,6 +11,7 @@ import (
1111 "io"
1212 "log/slog"
1313 "net/http"
14+ "net/url"
1415 "os"
1516 "sort"
1617 "strings"
@@ -37,6 +38,8 @@ type ghaCacheStore struct {
3738 http * http.Client
3839}
3940
41+ var errCacheAlreadyExists = errors .New ("cache entry already exists" )
42+
4043const (
4144 // ghaBlockSize is the size of each Azure Block Blob block.
4245 // 32 MiB × 50 000 blocks = 1.5 TiB max, well above any cache bundle.
@@ -113,12 +116,54 @@ func (g *ghaCacheStore) twirpCall(ctx context.Context, method string, reqBody, r
113116
114117 if resp .StatusCode != http .StatusOK {
115118 msg , _ := io .ReadAll (io .LimitReader (resp .Body , 4096 ))
119+ if resp .StatusCode == http .StatusConflict {
120+ return errors .Wrap (errCacheAlreadyExists , string (msg ))
121+ }
116122 return errors .Errorf ("%s: status %d: %s" , method , resp .StatusCode , msg )
117123 }
118124
119125 return json .NewDecoder (resp .Body ).Decode (respBody )
120126}
121127
128+ // deleteByKey deletes a cache entry via the GitHub Actions REST API.
129+ // This is needed because the Twirp v2 API doesn't expose a delete RPC, but
130+ // the REST API at /repos/{owner}/{repo}/actions/caches?key=... does.
131+ func (g * ghaCacheStore ) deleteByKey (ctx context.Context , key string ) error {
132+ repo := os .Getenv ("GITHUB_REPOSITORY" )
133+ apiURL := os .Getenv ("GITHUB_API_URL" )
134+ if apiURL == "" {
135+ apiURL = "https://api.github.com"
136+ }
137+
138+ u := fmt .Sprintf ("%s/repos/%s/actions/caches?key=%s" , apiURL , repo , url .QueryEscape (key ))
139+ req , err := http .NewRequestWithContext (ctx , http .MethodDelete , u , nil )
140+ if err != nil {
141+ return errors .Wrap (err , "build delete request" )
142+ }
143+ // The REST API requires GITHUB_TOKEN, not the ACTIONS_RUNTIME_TOKEN used
144+ // by the Twirp cache API.
145+ ghToken := os .Getenv ("GITHUB_TOKEN" )
146+ if ghToken == "" {
147+ return errors .New ("GITHUB_TOKEN is required to delete cache entries" )
148+ }
149+ req .Header .Set ("Authorization" , "Bearer " + ghToken )
150+
151+ resp , err := g .http .Do (req )
152+ if err != nil {
153+ return errors .Wrap (err , "delete cache entry" )
154+ }
155+ defer func () {
156+ io .Copy (io .Discard , resp .Body ) //nolint:errcheck,gosec
157+ resp .Body .Close () //nolint:errcheck,gosec
158+ }()
159+
160+ if resp .StatusCode != http .StatusOK {
161+ body , _ := io .ReadAll (io .LimitReader (resp .Body , 4096 ))
162+ return errors .Errorf ("delete cache entry: status %d: %s" , resp .StatusCode , body )
163+ }
164+ return nil
165+ }
166+
122167// ─── Twirp request/response types ───────────────────────────────────────────
123168
124169type ghaCacheMetadata struct {
@@ -235,13 +280,23 @@ func (g *ghaCacheStore) createAndFinalize(ctx context.Context, commit, cacheKey
235280 key := ghaCacheKey (commit , cacheKey )
236281 version := ghaCacheVersion (cacheKey )
237282
238- // 1. Create cache entry → get signed upload URL
283+ // 1. Create cache entry → get signed upload URL.
284+ // If the entry already exists (409), delete it and retry once.
239285 var createResp ghaCreateEntryResp
240- if err := g . twirpCall ( ctx , "CreateCacheEntry" , ghaCreateEntryReq {
286+ createReq := ghaCreateEntryReq {
241287 Metadata : g .metadata (),
242288 Key : key ,
243289 Version : version ,
244- }, & createResp ); err != nil {
290+ }
291+ if err := g .twirpCall (ctx , "CreateCacheEntry" , createReq , & createResp ); errors .Is (err , errCacheAlreadyExists ) {
292+ slog .Info ("cache entry already exists, deleting and retrying" , "key" , key )
293+ if delErr := g .deleteByKey (ctx , key ); delErr != nil {
294+ return errors .Wrap (delErr , "delete existing cache entry" )
295+ }
296+ if err := g .twirpCall (ctx , "CreateCacheEntry" , createReq , & createResp ); err != nil {
297+ return errors .Wrap (err , "gha cache create (after delete)" )
298+ }
299+ } else if err != nil {
245300 return errors .Wrap (err , "gha cache create" )
246301 }
247302 if ! createResp .OK || createResp .SignedUploadURL == "" {
@@ -293,11 +348,20 @@ func (g *ghaCacheStore) putStream(ctx context.Context, commit, cacheKey string,
293348 version := ghaCacheVersion (cacheKey )
294349
295350 var createResp ghaCreateEntryResp
296- if err := g . twirpCall ( ctx , "CreateCacheEntry" , ghaCreateEntryReq {
351+ createReq := ghaCreateEntryReq {
297352 Metadata : g .metadata (),
298353 Key : key ,
299354 Version : version ,
300- }, & createResp ); err != nil {
355+ }
356+ if err := g .twirpCall (ctx , "CreateCacheEntry" , createReq , & createResp ); errors .Is (err , errCacheAlreadyExists ) {
357+ slog .Info ("cache entry already exists, deleting and retrying" , "key" , key )
358+ if delErr := g .deleteByKey (ctx , key ); delErr != nil {
359+ return 0 , errors .Wrap (delErr , "delete existing cache entry" )
360+ }
361+ if err := g .twirpCall (ctx , "CreateCacheEntry" , createReq , & createResp ); err != nil {
362+ return 0 , errors .Wrap (err , "gha cache create (after delete)" )
363+ }
364+ } else if err != nil {
301365 return 0 , errors .Wrap (err , "gha cache create" )
302366 }
303367 if ! createResp .OK || createResp .SignedUploadURL == "" {
0 commit comments