Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/docker-image-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
3 changes: 3 additions & 0 deletions .github/workflows/mutation-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions actions/setup-java-gradle/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }}
31 changes: 30 additions & 1 deletion actions/setup-node/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<os>-<arch>-<package manager>-<lockfile hash>.
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
Expand Down
75 changes: 75 additions & 0 deletions tests/test_caches_save_only_on_default_branch.py
Original file line number Diff line number Diff line change
@@ -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()
Loading