Skip to content

Commit 03433f5

Browse files
authored
Hard fail when action steps do not save/restore caches (#3)
And fix overwriting branch cache bundle in GHA caches
1 parent 15dec85 commit 03433f5

8 files changed

Lines changed: 177 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,24 @@ jobs:
1515
- name: Activate Hermit
1616
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
1717

18+
- uses: actions/cache@v4
19+
with:
20+
path: |
21+
~/go/pkg/mod
22+
~/.cache/go-build
23+
key: go-${{ hashFiles('go.sum') }}
24+
restore-keys: go-
25+
1826
- uses: actions/setup-java@v4
1927
with:
2028
distribution: temurin
2129
java-version: 21
2230

23-
- name: Test
24-
run: go test -run ^Test -timeout 300s ./...
31+
- name: Unit tests
32+
run: go test -v -short -timeout 300s ./...
2533

26-
- name: Build
27-
run: go build ./...
34+
- name: Integration tests
35+
run: go test -v -run ^TestIntegration -timeout 600s ./cmd/gradle-cache/
2836

2937
lint:
3038
runs-on: ubuntu-latest
@@ -34,6 +42,14 @@ jobs:
3442
- name: Activate Hermit
3543
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
3644

45+
- uses: actions/cache@v4
46+
with:
47+
path: |
48+
~/go/pkg/mod
49+
~/.cache/go-build
50+
key: go-${{ hashFiles('go.sum') }}
51+
restore-keys: go-
52+
3753
- name: Lint
3854
run: golangci-lint run
3955

@@ -50,6 +66,14 @@ jobs:
5066
- name: Activate Hermit
5167
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
5268

69+
- uses: actions/cache@v4
70+
with:
71+
path: |
72+
~/go/pkg/mod
73+
~/.cache/go-build
74+
key: go-${{ hashFiles('go.sum') }}
75+
restore-keys: go-
76+
5377
- uses: actions/setup-java@v4
5478
with:
5579
distribution: temurin
@@ -87,6 +111,8 @@ jobs:
87111
runs-on: ubuntu-latest
88112
permissions:
89113
actions: write
114+
env:
115+
GITHUB_TOKEN: ${{ github.token }}
90116
steps:
91117
- uses: actions/checkout@v4
92118
with:
@@ -95,6 +121,14 @@ jobs:
95121
- name: Activate Hermit
96122
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
97123

124+
- uses: actions/cache@v4
125+
with:
126+
path: |
127+
~/go/pkg/mod
128+
~/.cache/go-build
129+
key: go-${{ hashFiles('go.sum') }}
130+
restore-keys: go-
131+
98132
- uses: actions/setup-java@v4
99133
with:
100134
distribution: temurin
@@ -150,6 +184,14 @@ jobs:
150184
- name: Activate Hermit
151185
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
152186

187+
- uses: actions/cache@v4
188+
with:
189+
path: |
190+
~/go/pkg/mod
191+
~/.cache/go-build
192+
key: go-${{ hashFiles('go.sum') }}
193+
restore-keys: go-
194+
153195
- uses: actions/setup-java@v4
154196
with:
155197
distribution: temurin
@@ -196,6 +238,14 @@ jobs:
196238
- name: Activate Hermit
197239
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
198240

241+
- uses: actions/cache@v4
242+
with:
243+
path: |
244+
~/go/pkg/mod
245+
~/.cache/go-build
246+
key: go-${{ hashFiles('go.sum') }}
247+
restore-keys: go-
248+
199249
- name: Release
200250
run: goreleaser release --clean
201251
env:

action.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ inputs:
6464
description: "Log level: debug, info, warn, or error."
6565
required: false
6666
default: info
67+
github-token:
68+
description: >
69+
GitHub token used for cache management (e.g. deleting stale delta
70+
entries). Defaults to the automatic GITHUB_TOKEN.
71+
required: false
72+
default: ${{ github.token }}
6773

6874
runs:
6975
using: node24

action/src/helpers.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,12 @@ function gitDirArgs() {
110110
*/
111111
function execOptions(extra) {
112112
const projectDir = core.getInput("project-dir") || ".";
113-
return { cwd: path.resolve(projectDir), ...extra };
113+
const ghToken = core.getInput("github-token") || process.env.GITHUB_TOKEN;
114+
const env = { ...process.env };
115+
if (ghToken) {
116+
env.GITHUB_TOKEN = ghToken;
117+
}
118+
return { cwd: path.resolve(projectDir), env, ...extra };
114119
}
115120

116121
/**

action/src/post.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const fs = require("fs");
2+
const path = require("path");
13
const core = require("@actions/core");
24
const exec = require("@actions/exec");
35
const {
@@ -20,13 +22,20 @@ async function run() {
2022
const branch = resolveBranch();
2123

2224
if (branch) {
23-
// save-delta will fall back to full save if no restore marker exists
25+
// On cold-start (no base cache found), there's no restore marker so
26+
// save-delta would fail. Detect this and skip gracefully.
27+
const gradleHome = core.getInput("gradle-user-home") || "~/.gradle";
28+
const marker = path.resolve(gradleHome, ".cache-restore-marker");
29+
if (!fs.existsSync(marker)) {
30+
core.info("No restore marker found (cold-start) — skipping delta save");
31+
return;
32+
}
33+
2434
const args = [
2535
"save-delta",
2636
...commonArgs(),
2737
...backendArgs(),
2838
...gradleHomeArgs(),
29-
...gitDirArgs(),
3039
"--branch",
3140
branch,
3241
];
@@ -43,7 +52,7 @@ async function run() {
4352
await exec.exec("gradle-cache", args, execOptions());
4453
}
4554
} catch (error) {
46-
core.warning(`Cache save failed: ${error.message}`);
55+
core.setFailed(`Cache save failed: ${error.message}`);
4756
}
4857
}
4958

dist/main/index.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33634,7 +33634,12 @@ function gitDirArgs() {
3363433634
*/
3363533635
function execOptions(extra) {
3363633636
const projectDir = core.getInput("project-dir") || ".";
33637-
return { cwd: path.resolve(projectDir), ...extra };
33637+
const ghToken = core.getInput("github-token") || process.env.GITHUB_TOKEN;
33638+
const env = { ...process.env };
33639+
if (ghToken) {
33640+
env.GITHUB_TOKEN = ghToken;
33641+
}
33642+
return { cwd: path.resolve(projectDir), env, ...extra };
3363833643
}
3363933644

3364033645
/**

dist/post/index.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33634,7 +33634,12 @@ function gitDirArgs() {
3363433634
*/
3363533635
function execOptions(extra) {
3363633636
const projectDir = core.getInput("project-dir") || ".";
33637-
return { cwd: path.resolve(projectDir), ...extra };
33637+
const ghToken = core.getInput("github-token") || process.env.GITHUB_TOKEN;
33638+
const env = { ...process.env };
33639+
if (ghToken) {
33640+
env.GITHUB_TOKEN = ghToken;
33641+
}
33642+
return { cwd: path.resolve(projectDir), env, ...extra };
3363833643
}
3363933644

3364033645
/**
@@ -33990,6 +33995,8 @@ module.exports = require("util");
3399033995
/******/
3399133996
/************************************************************************/
3399233997
var __webpack_exports__ = {};
33998+
const fs = __nccwpck_require__(9896);
33999+
const path = __nccwpck_require__(6928);
3399334000
const core = __nccwpck_require__(7484);
3399434001
const exec = __nccwpck_require__(5236);
3399534002
const {
@@ -34012,13 +34019,20 @@ async function run() {
3401234019
const branch = resolveBranch();
3401334020

3401434021
if (branch) {
34015-
// save-delta will fall back to full save if no restore marker exists
34022+
// On cold-start (no base cache found), there's no restore marker so
34023+
// save-delta would fail. Detect this and skip gracefully.
34024+
const gradleHome = core.getInput("gradle-user-home") || "~/.gradle";
34025+
const marker = path.resolve(gradleHome, ".cache-restore-marker");
34026+
if (!fs.existsSync(marker)) {
34027+
core.info("No restore marker found (cold-start) — skipping delta save");
34028+
return;
34029+
}
34030+
3401634031
const args = [
3401734032
"save-delta",
3401834033
...commonArgs(),
3401934034
...backendArgs(),
3402034035
...gradleHomeArgs(),
34021-
...gitDirArgs(),
3402234036
"--branch",
3402334037
branch,
3402434038
];
@@ -34035,7 +34049,7 @@ async function run() {
3403534049
await exec.exec("gradle-cache", args, execOptions());
3403634050
}
3403734051
} catch (error) {
34038-
core.warning(`Cache save failed: ${error.message}`);
34052+
core.setFailed(`Cache save failed: ${error.message}`);
3403934053
}
3404034054
}
3404134055

dist/pre/index.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33634,7 +33634,12 @@ function gitDirArgs() {
3363433634
*/
3363533635
function execOptions(extra) {
3363633636
const projectDir = core.getInput("project-dir") || ".";
33637-
return { cwd: path.resolve(projectDir), ...extra };
33637+
const ghToken = core.getInput("github-token") || process.env.GITHUB_TOKEN;
33638+
const env = { ...process.env };
33639+
if (ghToken) {
33640+
env.GITHUB_TOKEN = ghToken;
33641+
}
33642+
return { cwd: path.resolve(projectDir), env, ...extra };
3363833643
}
3363933644

3364033645
/**

gradlecache/ghacache.go

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
4043
const (
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

124169
type 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

Comments
 (0)