Skip to content

Commit c88dfa2

Browse files
committed
Fix gha save/restore delta
1 parent 15dec85 commit c88dfa2

8 files changed

Lines changed: 179 additions & 18 deletions

File tree

.github/workflows/ci.yml

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ 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
@@ -23,9 +31,6 @@ jobs:
2331
- name: Test
2432
run: go test -run ^Test -timeout 300s ./...
2533

26-
- name: Build
27-
run: go build ./...
28-
2934
lint:
3035
runs-on: ubuntu-latest
3136
steps:
@@ -34,6 +39,14 @@ jobs:
3439
- name: Activate Hermit
3540
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
3641

42+
- uses: actions/cache@v4
43+
with:
44+
path: |
45+
~/go/pkg/mod
46+
~/.cache/go-build
47+
key: go-${{ hashFiles('go.sum') }}
48+
restore-keys: go-
49+
3750
- name: Lint
3851
run: golangci-lint run
3952

@@ -50,6 +63,14 @@ jobs:
5063
- name: Activate Hermit
5164
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
5265

66+
- uses: actions/cache@v4
67+
with:
68+
path: |
69+
~/go/pkg/mod
70+
~/.cache/go-build
71+
key: go-${{ hashFiles('go.sum') }}
72+
restore-keys: go-
73+
5374
- uses: actions/setup-java@v4
5475
with:
5576
distribution: temurin
@@ -87,6 +108,8 @@ jobs:
87108
runs-on: ubuntu-latest
88109
permissions:
89110
actions: write
111+
env:
112+
GITHUB_TOKEN: ${{ github.token }}
90113
steps:
91114
- uses: actions/checkout@v4
92115
with:
@@ -95,6 +118,14 @@ jobs:
95118
- name: Activate Hermit
96119
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
97120

121+
- uses: actions/cache@v4
122+
with:
123+
path: |
124+
~/go/pkg/mod
125+
~/.cache/go-build
126+
key: go-${{ hashFiles('go.sum') }}
127+
restore-keys: go-
128+
98129
- uses: actions/setup-java@v4
99130
with:
100131
distribution: temurin
@@ -150,6 +181,14 @@ jobs:
150181
- name: Activate Hermit
151182
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
152183

184+
- uses: actions/cache@v4
185+
with:
186+
path: |
187+
~/go/pkg/mod
188+
~/.cache/go-build
189+
key: go-${{ hashFiles('go.sum') }}
190+
restore-keys: go-
191+
153192
- uses: actions/setup-java@v4
154193
with:
155194
distribution: temurin
@@ -196,6 +235,14 @@ jobs:
196235
- name: Activate Hermit
197236
run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH"
198237

238+
- uses: actions/cache@v4
239+
with:
240+
path: |
241+
~/go/pkg/mod
242+
~/.cache/go-build
243+
key: go-${{ hashFiles('go.sum') }}
244+
restore-keys: go-
245+
199246
- name: Release
200247
run: goreleaser release --clean
201248
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: 24 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 {
@@ -34011,14 +34018,27 @@ async function run() {
3401134018

3401234019
const branch = resolveBranch();
3401334020

34021+
// Trim stale restored entries before saving. Uses atime to detect files
34022+
// that were restored but never read during the build. No-op if the
34023+
// filesystem doesn't support relatime.
34024+
const trimArgs = ["trim", ...gradleHomeArgs()];
34025+
await exec.exec("gradle-cache", trimArgs, execOptions());
34026+
3401434027
if (branch) {
34015-
// save-delta will fall back to full save if no restore marker exists
34028+
// On cold-start (no base cache found), there's no restore marker so
34029+
// save-delta would fail. Detect this and skip gracefully.
34030+
const gradleHome = core.getInput("gradle-user-home") || "~/.gradle";
34031+
const marker = path.resolve(gradleHome, ".cache-restore-marker");
34032+
if (!fs.existsSync(marker)) {
34033+
core.info("No restore marker found (cold-start) — skipping delta save");
34034+
return;
34035+
}
34036+
3401634037
const args = [
3401734038
"save-delta",
3401834039
...commonArgs(),
3401934040
...backendArgs(),
3402034041
...gradleHomeArgs(),
34021-
...gitDirArgs(),
3402234042
"--branch",
3402334043
branch,
3402434044
];
@@ -34035,7 +34055,7 @@ async function run() {
3403534055
await exec.exec("gradle-cache", args, execOptions());
3403634056
}
3403734057
} catch (error) {
34038-
core.warning(`Cache save failed: ${error.message}`);
34058+
core.setFailed(`Cache save failed: ${error.message}`);
3403934059
}
3404034060
}
3404134061

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)