diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index c786c4d6..f3259401 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -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 diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 1b602cb8..2e09856e 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -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 diff --git a/.github/workflows/cache-budget.yml b/.github/workflows/cache-budget.yml index 8374abd6..ebccc440 100644 --- a/.github/workflows/cache-budget.yml +++ b/.github/workflows/cache-budget.yml @@ -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: diff --git a/ci/cache_budget.py b/ci/cache_budget.py index 73a90ab1..b5a10796 100644 --- a/ci/cache_budget.py +++ b/ci/cache_budget.py @@ -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 @@ -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) ) @@ -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() @@ -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']}" ) @@ -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) @@ -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( diff --git a/docs/cache-budget.md b/docs/cache-budget.md new file mode 100644 index 00000000..e9ef9158 --- /dev/null +++ b/docs/cache-budget.md @@ -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. diff --git a/tests/test_cache_budget.py b/tests/test_cache_budget.py index 01848b0b..5fe7124a 100644 --- a/tests/test_cache_budget.py +++ b/tests/test_cache_budget.py @@ -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( @@ -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)] @@ -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: @@ -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)