diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 362ec02..8bd5a42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,16 +15,24 @@ jobs: - name: Activate Hermit run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH" + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: go-${{ hashFiles('go.sum') }} + restore-keys: go- + - uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - - name: Test - run: go test -run ^Test -timeout 300s ./... + - name: Unit tests + run: go test -v -short -timeout 300s ./... - - name: Build - run: go build ./... + - name: Integration tests + run: go test -v -run ^TestIntegration -timeout 600s ./cmd/gradle-cache/ lint: runs-on: ubuntu-latest @@ -34,6 +42,14 @@ jobs: - name: Activate Hermit run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH" + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: go-${{ hashFiles('go.sum') }} + restore-keys: go- + - name: Lint run: golangci-lint run @@ -50,6 +66,14 @@ jobs: - name: Activate Hermit run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH" + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: go-${{ hashFiles('go.sum') }} + restore-keys: go- + - uses: actions/setup-java@v4 with: distribution: temurin @@ -87,6 +111,8 @@ jobs: runs-on: ubuntu-latest permissions: actions: write + env: + GITHUB_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v4 with: @@ -95,6 +121,14 @@ jobs: - name: Activate Hermit run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH" + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: go-${{ hashFiles('go.sum') }} + restore-keys: go- + - uses: actions/setup-java@v4 with: distribution: temurin @@ -150,6 +184,14 @@ jobs: - name: Activate Hermit run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH" + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: go-${{ hashFiles('go.sum') }} + restore-keys: go- + - uses: actions/setup-java@v4 with: distribution: temurin @@ -196,6 +238,14 @@ jobs: - name: Activate Hermit run: echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH" + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: go-${{ hashFiles('go.sum') }} + restore-keys: go- + - name: Release run: goreleaser release --clean env: diff --git a/action.yml b/action.yml index f3cb672..e594ebd 100644 --- a/action.yml +++ b/action.yml @@ -64,6 +64,12 @@ inputs: description: "Log level: debug, info, warn, or error." required: false default: info + github-token: + description: > + GitHub token used for cache management (e.g. deleting stale delta + entries). Defaults to the automatic GITHUB_TOKEN. + required: false + default: ${{ github.token }} runs: using: node24 diff --git a/action/src/helpers.js b/action/src/helpers.js index 15a3af2..a2bf49c 100644 --- a/action/src/helpers.js +++ b/action/src/helpers.js @@ -110,7 +110,12 @@ function gitDirArgs() { */ function execOptions(extra) { const projectDir = core.getInput("project-dir") || "."; - return { cwd: path.resolve(projectDir), ...extra }; + const ghToken = core.getInput("github-token") || process.env.GITHUB_TOKEN; + const env = { ...process.env }; + if (ghToken) { + env.GITHUB_TOKEN = ghToken; + } + return { cwd: path.resolve(projectDir), env, ...extra }; } /** diff --git a/action/src/post.js b/action/src/post.js index 826ca87..7fe8e48 100644 --- a/action/src/post.js +++ b/action/src/post.js @@ -1,3 +1,5 @@ +const fs = require("fs"); +const path = require("path"); const core = require("@actions/core"); const exec = require("@actions/exec"); const { @@ -20,13 +22,20 @@ async function run() { const branch = resolveBranch(); if (branch) { - // save-delta will fall back to full save if no restore marker exists + // On cold-start (no base cache found), there's no restore marker so + // save-delta would fail. Detect this and skip gracefully. + const gradleHome = core.getInput("gradle-user-home") || "~/.gradle"; + const marker = path.resolve(gradleHome, ".cache-restore-marker"); + if (!fs.existsSync(marker)) { + core.info("No restore marker found (cold-start) — skipping delta save"); + return; + } + const args = [ "save-delta", ...commonArgs(), ...backendArgs(), ...gradleHomeArgs(), - ...gitDirArgs(), "--branch", branch, ]; @@ -43,7 +52,7 @@ async function run() { await exec.exec("gradle-cache", args, execOptions()); } } catch (error) { - core.warning(`Cache save failed: ${error.message}`); + core.setFailed(`Cache save failed: ${error.message}`); } } diff --git a/dist/main/index.js b/dist/main/index.js index efbc069..7d42a8e 100644 --- a/dist/main/index.js +++ b/dist/main/index.js @@ -33634,7 +33634,12 @@ function gitDirArgs() { */ function execOptions(extra) { const projectDir = core.getInput("project-dir") || "."; - return { cwd: path.resolve(projectDir), ...extra }; + const ghToken = core.getInput("github-token") || process.env.GITHUB_TOKEN; + const env = { ...process.env }; + if (ghToken) { + env.GITHUB_TOKEN = ghToken; + } + return { cwd: path.resolve(projectDir), env, ...extra }; } /** diff --git a/dist/post/index.js b/dist/post/index.js index 25e3d51..13139d8 100644 --- a/dist/post/index.js +++ b/dist/post/index.js @@ -33634,7 +33634,12 @@ function gitDirArgs() { */ function execOptions(extra) { const projectDir = core.getInput("project-dir") || "."; - return { cwd: path.resolve(projectDir), ...extra }; + const ghToken = core.getInput("github-token") || process.env.GITHUB_TOKEN; + const env = { ...process.env }; + if (ghToken) { + env.GITHUB_TOKEN = ghToken; + } + return { cwd: path.resolve(projectDir), env, ...extra }; } /** @@ -33990,6 +33995,8 @@ module.exports = require("util"); /******/ /************************************************************************/ var __webpack_exports__ = {}; +const fs = __nccwpck_require__(9896); +const path = __nccwpck_require__(6928); const core = __nccwpck_require__(7484); const exec = __nccwpck_require__(5236); const { @@ -34012,13 +34019,20 @@ async function run() { const branch = resolveBranch(); if (branch) { - // save-delta will fall back to full save if no restore marker exists + // On cold-start (no base cache found), there's no restore marker so + // save-delta would fail. Detect this and skip gracefully. + const gradleHome = core.getInput("gradle-user-home") || "~/.gradle"; + const marker = path.resolve(gradleHome, ".cache-restore-marker"); + if (!fs.existsSync(marker)) { + core.info("No restore marker found (cold-start) — skipping delta save"); + return; + } + const args = [ "save-delta", ...commonArgs(), ...backendArgs(), ...gradleHomeArgs(), - ...gitDirArgs(), "--branch", branch, ]; @@ -34035,7 +34049,7 @@ async function run() { await exec.exec("gradle-cache", args, execOptions()); } } catch (error) { - core.warning(`Cache save failed: ${error.message}`); + core.setFailed(`Cache save failed: ${error.message}`); } } diff --git a/dist/pre/index.js b/dist/pre/index.js index ff0e8b3..94eb80f 100644 --- a/dist/pre/index.js +++ b/dist/pre/index.js @@ -33634,7 +33634,12 @@ function gitDirArgs() { */ function execOptions(extra) { const projectDir = core.getInput("project-dir") || "."; - return { cwd: path.resolve(projectDir), ...extra }; + const ghToken = core.getInput("github-token") || process.env.GITHUB_TOKEN; + const env = { ...process.env }; + if (ghToken) { + env.GITHUB_TOKEN = ghToken; + } + return { cwd: path.resolve(projectDir), env, ...extra }; } /** diff --git a/gradlecache/ghacache.go b/gradlecache/ghacache.go index 0d25e1b..e7f886a 100644 --- a/gradlecache/ghacache.go +++ b/gradlecache/ghacache.go @@ -11,6 +11,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "os" "sort" "strings" @@ -37,6 +38,8 @@ type ghaCacheStore struct { http *http.Client } +var errCacheAlreadyExists = errors.New("cache entry already exists") + const ( // ghaBlockSize is the size of each Azure Block Blob block. // 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 if resp.StatusCode != http.StatusOK { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode == http.StatusConflict { + return errors.Wrap(errCacheAlreadyExists, string(msg)) + } return errors.Errorf("%s: status %d: %s", method, resp.StatusCode, msg) } return json.NewDecoder(resp.Body).Decode(respBody) } +// deleteByKey deletes a cache entry via the GitHub Actions REST API. +// This is needed because the Twirp v2 API doesn't expose a delete RPC, but +// the REST API at /repos/{owner}/{repo}/actions/caches?key=... does. +func (g *ghaCacheStore) deleteByKey(ctx context.Context, key string) error { + repo := os.Getenv("GITHUB_REPOSITORY") + apiURL := os.Getenv("GITHUB_API_URL") + if apiURL == "" { + apiURL = "https://api.github.com" + } + + u := fmt.Sprintf("%s/repos/%s/actions/caches?key=%s", apiURL, repo, url.QueryEscape(key)) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil) + if err != nil { + return errors.Wrap(err, "build delete request") + } + // The REST API requires GITHUB_TOKEN, not the ACTIONS_RUNTIME_TOKEN used + // by the Twirp cache API. + ghToken := os.Getenv("GITHUB_TOKEN") + if ghToken == "" { + return errors.New("GITHUB_TOKEN is required to delete cache entries") + } + req.Header.Set("Authorization", "Bearer "+ghToken) + + resp, err := g.http.Do(req) + if err != nil { + return errors.Wrap(err, "delete cache entry") + } + defer func() { + io.Copy(io.Discard, resp.Body) //nolint:errcheck,gosec + resp.Body.Close() //nolint:errcheck,gosec + }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return errors.Errorf("delete cache entry: status %d: %s", resp.StatusCode, body) + } + return nil +} + // ─── Twirp request/response types ─────────────────────────────────────────── type ghaCacheMetadata struct { @@ -235,13 +280,23 @@ func (g *ghaCacheStore) createAndFinalize(ctx context.Context, commit, cacheKey key := ghaCacheKey(commit, cacheKey) version := ghaCacheVersion(cacheKey) - // 1. Create cache entry → get signed upload URL + // 1. Create cache entry → get signed upload URL. + // If the entry already exists (409), delete it and retry once. var createResp ghaCreateEntryResp - if err := g.twirpCall(ctx, "CreateCacheEntry", ghaCreateEntryReq{ + createReq := ghaCreateEntryReq{ Metadata: g.metadata(), Key: key, Version: version, - }, &createResp); err != nil { + } + if err := g.twirpCall(ctx, "CreateCacheEntry", createReq, &createResp); errors.Is(err, errCacheAlreadyExists) { + slog.Info("cache entry already exists, deleting and retrying", "key", key) + if delErr := g.deleteByKey(ctx, key); delErr != nil { + return errors.Wrap(delErr, "delete existing cache entry") + } + if err := g.twirpCall(ctx, "CreateCacheEntry", createReq, &createResp); err != nil { + return errors.Wrap(err, "gha cache create (after delete)") + } + } else if err != nil { return errors.Wrap(err, "gha cache create") } if !createResp.OK || createResp.SignedUploadURL == "" { @@ -293,11 +348,20 @@ func (g *ghaCacheStore) putStream(ctx context.Context, commit, cacheKey string, version := ghaCacheVersion(cacheKey) var createResp ghaCreateEntryResp - if err := g.twirpCall(ctx, "CreateCacheEntry", ghaCreateEntryReq{ + createReq := ghaCreateEntryReq{ Metadata: g.metadata(), Key: key, Version: version, - }, &createResp); err != nil { + } + if err := g.twirpCall(ctx, "CreateCacheEntry", createReq, &createResp); errors.Is(err, errCacheAlreadyExists) { + slog.Info("cache entry already exists, deleting and retrying", "key", key) + if delErr := g.deleteByKey(ctx, key); delErr != nil { + return 0, errors.Wrap(delErr, "delete existing cache entry") + } + if err := g.twirpCall(ctx, "CreateCacheEntry", createReq, &createResp); err != nil { + return 0, errors.Wrap(err, "gha cache create (after delete)") + } + } else if err != nil { return 0, errors.Wrap(err, "gha cache create") } if !createResp.OK || createResp.SignedUploadURL == "" {