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
5 changes: 4 additions & 1 deletion .github/workflows/_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ jobs:
- name: Rust build cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
# #1216: preserve shared dev-build saves, but keep the infrequent
# release-only target cache restore-only. Release builds still run
# fully; the next release may compile its target cache cold.
save-if: ${{ github.ref == 'refs/heads/main' && inputs['build-mode'] != 'release' }}
key: ${{ inputs.runs-on }}-${{ inputs.build-mode || 'dev' }}
cache-on-failure: true

Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -539,12 +539,13 @@ jobs:
toolchain: "1.95.0"
targets: ${{ matrix.target }}

# Save only on main pushes (#1216). A cache written on a tag ref is
# scoped to that tag and no later release can restore it, while tag and
# dispatch runs still restore main's entry for the same key.
# Release targets are infrequent and their 1.6GB cross-build cache
# family crowds out per-commit test caches (#1216). Keep any existing
# main entry restorable, but do not save new release target caches; the
# next release may compile these targets cold.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: false
key: release-binaries-${{ matrix.target }}

- uses: actions/setup-python@v5
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cache-budget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ permissions:

jobs:
enforce-cache-budget:
name: Retire disabled all-features caches and check repository budget
name: Retire disabled target caches and check repository budget
runs-on: ubuntu-latest
timeout-minutes: 240
permissions:
Expand Down
37 changes: 29 additions & 8 deletions ci/cache_budget.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Retire the disabled all-features Rust target cache and enforce budget."""
"""Retire low-value Rust target cache families and enforce the live budget."""

from __future__ import annotations

Expand Down Expand Up @@ -58,16 +58,37 @@
"v0-rust-macos-x86-all-features-",
"v0-rust-macos-arm-all-features-",
)
RELEASE_BUILD_CACHE_PREFIXES = (
"v0-rust-windows-11-arm-release-build-",
"v0-rust-macos-15-intel-release-build-",
"v0-rust-windows-2025-release-build-",
"v0-rust-ubuntu-24.04-release-build-",
"v0-rust-ubuntu-24.04-arm-release-build-",
"v0-rust-macos-15-release-build-",
)
RELEASE_BINARY_CACHE_PREFIXES = (
"v0-rust-release-binaries-x86_64-unknown-linux-gnu-build-binaries-",
"v0-rust-release-binaries-aarch64-unknown-linux-gnu-build-binaries-",
"v0-rust-release-binaries-x86_64-apple-darwin-build-binaries-",
"v0-rust-release-binaries-aarch64-apple-darwin-build-binaries-",
"v0-rust-release-binaries-x86_64-pc-windows-msvc-build-binaries-",
"v0-rust-release-binaries-aarch64-pc-windows-msvc-build-binaries-",
)
RETIRED_CACHE_PREFIXES = (
*ALL_FEATURES_CACHE_PREFIXES,
*RELEASE_BUILD_CACHE_PREFIXES,
*RELEASE_BINARY_CACHE_PREFIXES,
)


def is_retired_cache(cache: Mapping[str, Any]) -> bool:
"""Select only the three disabled cache families on main."""
"""Select only explicitly disabled cache families on main."""
ref = cache.get("ref")
key = cache.get("key")
return (
ref == MAIN_REF
and isinstance(key, str)
and key.startswith(ALL_FEATURES_CACHE_PREFIXES)
and key.startswith(RETIRED_CACHE_PREFIXES)
)


Expand Down Expand Up @@ -219,7 +240,7 @@ def retire_disabled_caches(
*,
expected_sha: str | None = None,
) -> list[Mapping[str, Any]]:
"""Retire only disabled all-features cache families on main."""
"""Retire only explicitly disabled cache families on main."""
if expected_sha is not None:
assert_main_sha(api, expected_sha)
caches = api.list_caches()
Expand All @@ -229,7 +250,7 @@ def retire_disabled_caches(
assert_main_sha(api, expected_sha)
cache_id = int(cache["id"])
print(
"retiring disabled all-features cache "
"retiring disabled target cache "
f"id={cache_id} ref={cache['ref']} size={cache['size_in_bytes']} "
f"key={cache['key']}"
)
Expand Down Expand Up @@ -262,7 +283,7 @@ def enforce_budget(
f"{effective_size} bytes; limit={BUDGET_LIMIT_BYTES}; "
f"retired={len(candidates)} entries/"
f"{sum(int(c['size_in_bytes']) for c in candidates)} bytes; "
f"remaining_disabled={len(remaining)}"
f"remaining_retired={len(remaining)}"
)

snapshot = (endpoint_bytes, listed_size)
Expand Down Expand Up @@ -305,8 +326,8 @@ def main() -> int:

api = GitHubCacheAPI(repository, token)
try:
# Free this known-dead space before other main workflows reach their
# post-job cache uploads. A later writer may recreate a key, so the
# Free disabled cache families before main workflows reach their
# post-job cache uploads. A writer may recreate a key, so the
# post-barrier enforcement below repeats this exact-family cleanup.
retire_disabled_caches(api, expected_sha=expected_sha)
wait_for_main_writers(
Expand Down
18 changes: 18 additions & 0 deletions docs/cache-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# GitHub Actions cache budget

The main-only cache-budget workflow keeps repository Actions-cache usage below
9.5 billion bytes, leaving margin under GitHub's 10 GiB eviction threshold.
It removes only explicitly retired cache-key families on `refs/heads/main`;
PR refs and unrelated target/profile caches are never selected.

The optional all-features matrix remains enabled, but its Windows/macOS Rust
target caches are restore-only. This retains platform coverage without growing
three multi-hundred-megabyte copies on each run.

The six platform release-build caches and six cross-target release-binaries
caches are also restore-only and retired from the main ref. The measured
12-entry family occupied 4,078,226,358 bytes. Release packaging, target builds,
and validation still run unchanged; the tradeoff is that a future release can
start those Rust target builds cold instead of restoring the old multi-gigabyte
archives. Shared development/preflight, coverage, musl, security, and Dylint
caches remain eligible for main saves.
147 changes: 145 additions & 2 deletions tests/test_cache_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,63 @@ def test_selects_only_exact_disabled_families_on_main(self) -> None:
"v0-rust-windows-x86-all-features-all-features-Windows-x64-digest",
ref="refs/pull/1216/merge",
),
entry(
7,
"v0-rust-windows-11-arm-release-build-Windows_NT-arm64-hash-version",
),
entry(
8,
"v0-rust-macos-15-intel-release-build-Darwin-x64-hash-version",
),
entry(
9,
"v0-rust-windows-2025-release-build-Windows_NT-x64-hash-version",
),
entry(
10,
"v0-rust-ubuntu-24.04-release-build-Linux-x64-hash-version",
),
entry(
11,
"v0-rust-ubuntu-24.04-arm-release-build-Linux-arm64-hash-version",
),
entry(
12,
"v0-rust-macos-15-release-build-Darwin-arm64-hash-version",
),
entry(
13,
"v0-rust-release-binaries-x86_64-unknown-linux-gnu-build-binaries-Linux-x64-hash-version",
),
entry(
14,
"v0-rust-release-binaries-aarch64-unknown-linux-gnu-build-binaries-Linux-x64-hash-version",
),
entry(
15,
"v0-rust-release-binaries-x86_64-apple-darwin-build-binaries-Linux-x64-hash-version",
),
entry(
16,
"v0-rust-release-binaries-aarch64-apple-darwin-build-binaries-Linux-x64-hash-version",
),
entry(
17,
"v0-rust-release-binaries-x86_64-pc-windows-msvc-build-binaries-Linux-x64-hash-version",
),
entry(
18,
"v0-rust-release-binaries-aarch64-pc-windows-msvc-build-binaries-Linux-x64-hash-version",
),
entry(
19,
"v0-rust-windows-11-arm-dev-build-Windows_NT-arm64-hash-version",
),
entry(
20,
"v0-rust-release-binaries-x86_64-pc-windows-msvc-build-binaries-Linux-x64-hash-version",
ref="refs/tags/v4.10.15",
),
]

self.assertEqual(
Expand All @@ -58,8 +115,84 @@ def test_selects_only_exact_disabled_families_on_main(self) -> None:
for cache in candidates
if cache_budget.is_retired_cache(cache)
],
[1, 2, 3],
list(range(1, 4)) + list(range(7, 19)),
)

def test_release_target_caches_are_restore_only(self) -> None:
root = Path(__file__).resolve().parents[1]
build_workflow = (root / ".github/workflows/_build.yml").read_text(
encoding="utf-8"
)
release_cache_step = next(
block
for block in build_workflow.split("\n - ")
if "uses: Swatinem/rust-cache@v2" in block
)
self.assertIn(
"save-if: ${{ github.ref == 'refs/heads/main' && inputs['build-mode'] != 'release' }}",
release_cache_step,
)
self.assertNotIn("lookup-only: true", release_cache_step)

release_workflow = (root / ".github/workflows/auto-release.yml").read_text(
encoding="utf-8"
)
binary_cache_step = next(
block
for block in release_workflow.split("\n - ")
if "key: release-binaries-${{ matrix.target }}" in block
)
self.assertIn("save-if: false", binary_cache_step)
self.assertNotIn("lookup-only: true", binary_cache_step)

def test_release_cache_tradeoff_is_documented(self) -> None:
doc = (
Path(__file__).resolve().parents[1] / "docs" / "cache-budget.md"
).read_text(encoding="utf-8")
self.assertIn("release", doc.lower())
self.assertIn("cold", doc.lower())
self.assertIn("4,078,226,358", doc)

def test_release_retention_prefixes_cover_workflow_targets(self) -> None:
workflows = Path(__file__).resolve().parents[1] / ".github/workflows"
release_workflow = (workflows / "auto-release.yml").read_text(encoding="utf-8")

release_build_labels: set[str] = set()
job_blocks = re.split(r"(?=^ [\w-]+:)", release_workflow, flags=re.MULTILINE)
for job in job_blocks:
if (
"uses: ./.github/workflows/_build.yml" not in job
or "build-mode: release" not in job
):
continue
runner = re.search(r"^\s+runs-on:\s*([^\s]+)", job, re.MULTILINE)
self.assertIsNotNone(runner, "release build callsite needs runs-on")
release_build_labels.add(runner.group(1))

retired_release_build_labels = {
prefix.removeprefix("v0-rust-").removesuffix("-release-build-")
for prefix in cache_budget.RELEASE_BUILD_CACHE_PREFIXES
}
self.assertEqual(retired_release_build_labels, release_build_labels)

build_binaries = re.search(
r"(?ms)^ build-binaries:.*?(?=^ [\w-]+:|\Z)", release_workflow
)
self.assertIsNotNone(build_binaries)
targets = set(
re.findall(
r"^\s+- target:\s*([^\s]+)",
build_binaries.group(),
re.MULTILINE,
)
)
retired_targets = {
prefix.removeprefix("v0-rust-release-binaries-").removesuffix(
"-build-binaries-"
)
for prefix in cache_budget.RELEASE_BINARY_CACHE_PREFIXES
}
self.assertEqual(retired_targets, targets)

def test_budget_uses_max_of_usage_endpoint_and_listing(self) -> None:
caches = [entry(1, "v0-rust-current", size=200)]
Expand Down Expand Up @@ -109,6 +242,16 @@ def test_enforcer_retires_only_the_disabled_main_families(self) -> None:
size=600,
),
entry(4, "v0-rust-windows-x86-shared-preflight-Windows-x64", size=200),
entry(
5,
"v0-rust-windows-11-arm-release-build-Windows_NT-arm64-hash-version",
size=400,
),
entry(
6,
"v0-rust-release-binaries-aarch64-pc-windows-msvc-build-binaries-Linux-x64-hash-version",
size=500,
),
]

class FakeAPI:
Expand All @@ -133,7 +276,7 @@ def delete_cache(self, cache_id: int) -> None:
api, sleep=lambda _seconds: None, attempts=2
)

self.assertEqual(api.deleted, [1, 2, 3])
self.assertEqual(api.deleted, [1, 2, 3, 5, 6])
self.assertEqual([int(cache["id"]) for cache in api.caches], [4])
self.assertEqual(budget, 200)

Expand Down
Loading