From ccf6dacab7aabfbd59446dbe8b44473d932c95a9 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Wed, 21 Oct 2026 12:09:24 +0200 Subject: [PATCH] fix: only a push to the default branch saves the Gradle, Node, Docker and mise caches --- .github/workflows/docker-image-ci.yml | 3 +- .github/workflows/mutation-report.yml | 3 + actions/setup-java-gradle/action.yml | 6 +- actions/setup-node/action.yml | 31 +++++++- ...test_caches_save_only_on_default_branch.py | 75 +++++++++++++++++++ 5 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 tests/test_caches_save_only_on_default_branch.py diff --git a/.github/workflows/docker-image-ci.yml b/.github/workflows/docker-image-ci.yml index ddda104..a065e88 100644 --- a/.github/workflows/docker-image-ci.yml +++ b/.github/workflows/docker-image-ci.yml @@ -74,7 +74,8 @@ jobs: push: false tags: ${{ steps.image.outputs.tag }} cache-from: type=gha,scope=${{ inputs.image-name }} - cache-to: type=gha,mode=max,scope=${{ inputs.image-name }} + # Only a default-branch push writes the cache; pull requests read it and never evict it. + cache-to: ${{ (github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) && format('type=gha,mode=max,scope={0}', inputs.image-name) || '' }} secrets: | github_token=${{ secrets.packages-token || github.token }} github_actor=${{ github.actor }} diff --git a/.github/workflows/mutation-report.yml b/.github/workflows/mutation-report.yml index 356ec7d..8db011b 100644 --- a/.github/workflows/mutation-report.yml +++ b/.github/workflows/mutation-report.yml @@ -51,7 +51,10 @@ jobs: path: .github-workflows persist-credentials: false + # Pull-request only, so it reads the default branch's mise cache and never saves its own. - uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0 + with: + cache_save: false - name: Mutate the changed Go id: go diff --git a/actions/setup-java-gradle/action.yml b/actions/setup-java-gradle/action.yml index 83a748a..2e4727e 100644 --- a/actions/setup-java-gradle/action.yml +++ b/actions/setup-java-gradle/action.yml @@ -15,9 +15,9 @@ inputs: required: false default: 'false' gradle-cache-read-only: - description: Whether Gradle cache entries should be restored without saving updates. + description: Whether Gradle cache entries should be restored without saving updates. Empty means read-only everywhere but a push to the default branch, so pull requests never evict its cache. required: false - default: 'false' + default: '' github-packages-actor: description: GitHub Packages actor exposed to Gradle as GITHUB_ACTOR when github-packages-token is set. Empty defaults to github.actor. required: false @@ -54,4 +54,4 @@ runs: - uses: gradle/actions/setup-gradle@v6 with: cache-disabled: ${{ inputs.gradle-cache-disabled }} - cache-read-only: ${{ inputs.gradle-cache-read-only }} + cache-read-only: ${{ inputs.gradle-cache-read-only != '' && inputs.gradle-cache-read-only || !(github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) }} diff --git a/actions/setup-node/action.yml b/actions/setup-node/action.yml index c16d935..dc38538 100644 --- a/actions/setup-node/action.yml +++ b/actions/setup-node/action.yml @@ -61,12 +61,41 @@ runs: if: ${{ inputs.package-manager == 'pnpm' }} uses: pnpm/action-setup@v6 + # Only a push to the default branch saves the cache; other runs restore its latest below, so + # pull requests never evict it from the repository's 10 GB. - uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} + cache: ${{ (github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) && inputs.package-manager || '' }} + package-manager-cache: ${{ (github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) }} cache-dependency-path: ${{ inputs.cache-dependency-path }} + - name: Locate the package cache + id: package-cache + if: ${{ !(github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) }} + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + set -euo pipefail + + case "$PACKAGE_MANAGER" in + npm) dir=$(npm config get cache) ;; + pnpm) dir=$(pnpm store path --silent) ;; + yarn) dir=$(yarn config get cacheFolder 2>/dev/null || yarn cache dir) ;; + esac + # setup-node's own key: node-cache----. + printf 'path=%s\nprefix=node-cache-%s-%s-%s-\n' "$dir" "$RUNNER_OS" "$(node -p process.arch)" "$PACKAGE_MANAGER" >> "$GITHUB_OUTPUT" + + - name: Restore the default branch's package cache + if: ${{ !(github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.package-cache.outputs.path }} + key: ${{ steps.package-cache.outputs.prefix }}restore-only + restore-keys: ${{ steps.package-cache.outputs.prefix }} + - name: Configure GitHub Packages for npm if: ${{ inputs.github-packages-token != '' }} shell: bash diff --git a/tests/test_caches_save_only_on_default_branch.py b/tests/test_caches_save_only_on_default_branch.py new file mode 100644 index 0000000..263612e --- /dev/null +++ b/tests/test_caches_save_only_on_default_branch.py @@ -0,0 +1,75 @@ +"""Only a push to the default branch saves a cache; pull requests restore and never save. + +A repository holds 10 GB of Actions cache, and GitHub evicts the least recently used past +that. Every pull request saving its own Gradle, Node, Docker and mise caches filled it within +days and evicted the default branch's, so pull requests that could only restore from the +default branch started cold. On 2026-10-21 the estate held 944 pull-request caches, about 61 GB. +""" +from __future__ import annotations + +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + +ON_DEFAULT_BRANCH_PUSH = ( + "github.event_name == 'push' && github.ref_name == github.event.repository.default_branch" +) + + +def load(relative): + return yaml.safe_load((ROOT / relative).read_text(encoding="utf-8")) + + +def steps_using(doc, action_prefix): + if "runs" in doc: + steps = doc["runs"]["steps"] + else: + steps = [s for job in doc["jobs"].values() for s in job.get("steps", [])] + return [s for s in steps if str(s.get("uses", "")).startswith(action_prefix)] + + +class GradleCacheIsReadOnlyOffTheDefaultBranch(unittest.TestCase): + def setUp(self): + self.action = load("actions/setup-java-gradle/action.yml") + + def test_the_read_only_input_defaults_to_deciding_from_the_event(self): + self.assertEqual(self.action["inputs"]["gradle-cache-read-only"]["default"], "") + + def test_setup_gradle_is_read_only_unless_a_default_branch_push(self): + (step,) = steps_using(self.action, "gradle/actions/setup-gradle") + self.assertIn(ON_DEFAULT_BRANCH_PUSH, step["with"]["cache-read-only"]) + + +class NodeCacheIsSavedOnlyOnADefaultBranchPush(unittest.TestCase): + def setUp(self): + self.action = load("actions/setup-node/action.yml") + + def test_setup_node_caches_only_on_a_default_branch_push(self): + (step,) = steps_using(self.action, "actions/setup-node") + self.assertIn(ON_DEFAULT_BRANCH_PUSH, step["with"]["cache"]) + self.assertIn(ON_DEFAULT_BRANCH_PUSH, step["with"]["package-manager-cache"]) + + def test_other_runs_restore_the_default_branch_cache_without_saving(self): + (step,) = steps_using(self.action, "actions/cache/restore") + self.assertIn("!(" + ON_DEFAULT_BRANCH_PUSH + ")", step["if"]) + self.assertEqual(steps_using(self.action, "actions/cache/save"), []) + + +class DockerCiBuildWritesItsCacheOnlyOnADefaultBranchPush(unittest.TestCase): + def test_cache_to_is_empty_off_a_default_branch_push(self): + (step,) = steps_using(load(".github/workflows/docker-image-ci.yml"), "docker/build-push-action") + self.assertIn(ON_DEFAULT_BRANCH_PUSH, step["with"]["cache-to"]) + self.assertIn("type=gha", step["with"]["cache-from"]) + + +class MutationReportNeverSavesAMiseCache(unittest.TestCase): + def test_mise_runs_with_cache_save_off(self): + (step,) = steps_using(load(".github/workflows/mutation-report.yml"), "jdx/mise-action") + self.assertIs(step["with"]["cache_save"], False) + + +if __name__ == "__main__": + unittest.main()