diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index d32d2d8..43d0bbe 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -111,6 +111,7 @@ jobs: lfs: true - uses: zackees/setup-soldr@4df8db93438594f50505574d9dc8117505d33362 # v0.9.80 with: + version: 0.9.23 cook-delta: false # per-commit delta layer off (setup-soldr#528) # No durable saves from pull requests (setup-soldr#527, #355). save-cache: auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dec34c..471ebf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,7 @@ jobs: outputs: mode: ${{ steps.mode.outputs.mode }} sha: ${{ steps.mode.outputs.sha }} + soldr_version: ${{ steps.setup_soldr.outputs.soldr-version }} env: CARGO_PROFILE_DEV_DEBUG: line-tables-only steps: @@ -86,7 +87,9 @@ jobs: # the same thing with its own CARGO_HOME. Downloading sources costs a # minute; a poisoned registry costs the whole job. - uses: zackees/setup-soldr@4df8db93438594f50505574d9dc8117505d33362 # v0.9.80 + id: setup_soldr with: + version: 0.9.23 cook-delta: false # per-commit delta layer off (setup-soldr#528) # No durable saves from pull requests (setup-soldr#527, #355). save-cache: auto @@ -436,6 +439,7 @@ jobs: libgtk-3-dev libwebkit2gtk-4.1-dev - uses: zackees/setup-soldr@4df8db93438594f50505574d9dc8117505d33362 # v0.9.80 with: + version: 0.9.23 cook-delta: false # per-commit delta layer off (setup-soldr#528) # No durable saves from pull requests (setup-soldr#527, #355). save-cache: auto @@ -822,6 +826,7 @@ jobs: libgtk-3-dev libwebkit2gtk-4.1-dev - uses: zackees/setup-soldr@4df8db93438594f50505574d9dc8117505d33362 # v0.9.80 with: + version: 0.9.23 cook-delta: false # per-commit delta layer off (setup-soldr#528) # No durable saves from pull requests (setup-soldr#527, #355). save-cache: auto @@ -951,3 +956,40 @@ jobs: CI_NEEDS_JSON: ${{ toJSON(needs) }} GITHUB_TOKEN: ${{ github.token }} run: CHECKED_OUT_SHA="$(git rev-parse HEAD)" uv run --no-project --python 3.12 ci/full_coverage.py + + # setup-soldr cook bases restore exact-only and include the runtime Soldr + # version in the key. After every producer job has completed its post-step, + # retire only main-ref bases from older Soldr versions; current-version + # target/feature shapes and all other cache families are preserved. + cache-retention: + name: Retire obsolete cook-base generations + needs: [linux, build, test, dylints, full-coverage] + if: >- + ${{ + always() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.linux.result == 'success' && + (needs.build.result == 'success' || needs.build.result == 'skipped') && + (needs.test.result == 'success' || needs.test.result == 'skipped') && + (needs.dylints.result == 'success' || needs.dylints.result == 'skipped') && + (needs['full-coverage'].result == 'success' || needs['full-coverage'].result == 'skipped') + }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 + - name: Retire obsolete cook bases and enforce the live cache budget + env: + GITHUB_TOKEN: ${{ github.token }} + SOLDR_VERSION: ${{ needs.linux.outputs.soldr_version }} + run: >- + uv run --no-project ci/prune_obsolete_cook_caches.py + --version "${SOLDR_VERSION}" --apply diff --git a/.github/workflows/macos-x64-tests.yml b/.github/workflows/macos-x64-tests.yml index b3d0705..c5d4771 100644 --- a/.github/workflows/macos-x64-tests.yml +++ b/.github/workflows/macos-x64-tests.yml @@ -62,6 +62,7 @@ jobs: - uses: zackees/setup-soldr@4df8db93438594f50505574d9dc8117505d33362 # v0.9.80 with: + version: 0.9.23 cook-delta: false # per-commit delta layer off (setup-soldr#528) # No durable saves from pull requests (setup-soldr#527, #355). save-cache: auto diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d658f1c..38b2ab9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,6 +80,7 @@ jobs: # cook that profile and share the `linux` CI job's cook base (#355). - uses: zackees/setup-soldr@4df8db93438594f50505574d9dc8117505d33362 # v0.9.80 with: + version: 0.9.23 cook-delta: false # per-commit delta layer off (setup-soldr#528) # No durable saves from pull requests (setup-soldr#527, #355). save-cache: auto @@ -186,6 +187,7 @@ jobs: ref: ${{ inputs.source_sha }} - uses: zackees/setup-soldr@4df8db93438594f50505574d9dc8117505d33362 # v0.9.80 with: + version: 0.9.23 cook-delta: false # per-commit delta layer off (setup-soldr#528) # No durable saves from pull requests (setup-soldr#527, #355). save-cache: auto diff --git a/ci/prune_obsolete_cook_caches.py b/ci/prune_obsolete_cook_caches.py new file mode 100644 index 0000000..4e8fe84 --- /dev/null +++ b/ci/prune_obsolete_cook_caches.py @@ -0,0 +1,247 @@ +"""Retire cook bases that the current Soldr release cannot restore. + +Cook-base keys include the Soldr version and use exact-only restore. Once all +main-branch producers use a newer Soldr release, older-version cook bases are +unreachable and only consume the repository Actions cache quota. Other cache +families, refs, current-version keys, and future-version keys are never deleted. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +GIB = 1024**3 +BUDGET_BYTES = 19 * GIB // 2 # 9.5 GiB leaves room for ordinary cache growth. +MAIN_REF = "refs/heads/main" +COOK_BASE_PREFIX = "cook-base-v2-" +VERSION_RE = re.compile(r"(?:^|-)soldrv(?P\d+\.\d+\.\d+)(?:-|$)") + + +@dataclass(frozen=True) +class Cache: + cache_id: int + key: str + ref: str + size: int + + +def version_tuple(value: str) -> tuple[int, int, int]: + match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", value.strip()) + if match is None: + raise ValueError(f"invalid Soldr version: {value!r}") + return tuple(int(part) for part in match.groups()) + + +def cache_version(key: str) -> str | None: + match = VERSION_RE.search(key) + return match.group("version") if match else None + + +def stale_cook_bases(caches: list[Cache], current_version: str) -> list[Cache]: + current = version_tuple(current_version) + candidates = [] + for cache in caches: + if cache.ref != MAIN_REF or not cache.key.startswith(COOK_BASE_PREFIX): + continue + found = cache_version(cache.key) + if found is None: + raise ValueError(f"cannot safely classify cook-base cache {cache.cache_id}: {cache.key}") + version = version_tuple(found) + if version < current: + candidates.append(cache) + return candidates + + +def require_current_generation_present(caches: list[Cache], current_version: str) -> None: + """Permit old-generation pruning only when a usable current generation exists.""" + current = version_tuple(current_version) + versions = set() + for cache in caches: + if cache.ref != MAIN_REF or not cache.key.startswith(COOK_BASE_PREFIX): + continue + found = cache_version(cache.key) + if found is None: + raise ValueError(f"cannot safely classify cook-base cache {cache.cache_id}: {cache.key}") + parsed = version_tuple(found) + if parsed > current: + raise ValueError(f"future Soldr cook-base generation {found} exceeds current {current_version}") + versions.add(parsed) + if current not in versions: + raise ValueError( + f"refusing to prune old cook bases: no main cook-base exists for Soldr {current_version}" + ) + + +def require_single_current_generation(caches: list[Cache], current_version: str) -> None: + current = version_tuple(current_version) + versions = { + cache_version(cache.key) + for cache in caches + if cache.ref == MAIN_REF and cache.key.startswith(COOK_BASE_PREFIX) + } + if None in versions: + raise ValueError("an unparseable main cook-base key remains") + parsed = {version_tuple(value) for value in versions if value is not None} + if parsed != {current}: + actual = sorted(".".join(map(str, version)) for version in parsed) + raise ValueError( + f"main cook-base generations are {actual}, expected only {current_version}" + ) + + +class GitHub: + def __init__(self, repository: str, token: str): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError(f"invalid repository: {repository!r}") + self.base = f"https://api.github.com/repos/{repository}" + self.token = token + + def request(self, path: str, *, method: str = "GET") -> dict: + request = Request( + self.base + path, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self.token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urlopen(request, timeout=30) as response: + payload = response.read() + except HTTPError as exc: + if method == "DELETE" and exc.code == 404: + return {"already_absent": True} + raise RuntimeError(f"GitHub API {method} {path} failed: {exc}") from exc + except URLError as exc: + raise RuntimeError(f"GitHub API {method} {path} failed: {exc}") from exc + if not payload: + return {} + value = json.loads(payload) + if not isinstance(value, dict): + raise RuntimeError(f"GitHub API returned unexpected data for {path}") + return value + + def caches(self) -> list[Cache]: + result = [] + page = 1 + while True: + response = self.request(f"/actions/caches?per_page=100&page={page}") + entries = response.get("actions_caches", []) + if not isinstance(entries, list): + raise RuntimeError("GitHub API cache listing had no actions_caches array") + for entry in entries: + result.append( + Cache( + cache_id=int(entry["id"]), + key=str(entry["key"]), + ref=str(entry["ref"]), + size=int(entry["size_in_bytes"]), + ) + ) + if len(entries) < 100: + return result + page += 1 + + def usage_bytes(self) -> int: + response = self.request("/actions/cache/usage") + return int(response["active_caches_size_in_bytes"]) + + def delete_cache(self, cache_id: int) -> bool: + response = self.request(f"/actions/caches/{cache_id}", method="DELETE") + return not response.get("already_absent", False) + + +def settled_usage( + api: GitHub, + current_version: str, + *, + polls: int = 6, + interval: int = 10, +) -> tuple[int, int, int]: + """Return (max usage, endpoint usage, listed usage) after deletion settles.""" + last = (0, 0, 0) + last_generation_error: ValueError | None = None + for attempt in range(polls): + endpoint = api.usage_bytes() + caches = api.caches() + listed = sum(cache.size for cache in caches) + last = (max(endpoint, listed), endpoint, listed) + try: + require_single_current_generation(caches, current_version) + last_generation_error = None + except ValueError as exc: + last_generation_error = exc + if last[0] <= BUDGET_BYTES and last_generation_error is None: + return last + if attempt + 1 < polls: + time.sleep(interval) + if last_generation_error is not None: + raise last_generation_error + return last + + +def prune(api: GitHub, current_version: str, *, apply: bool) -> tuple[int, int]: + before = api.caches() + require_current_generation_present(before, current_version) + candidates = stale_cook_bases(before, current_version) + reclaimed = sum(cache.size for cache in candidates) + for cache in candidates: + if apply: + deleted = api.delete_cache(cache.cache_id) + state = "deleted" if deleted else "already absent" + else: + state = "would delete" + print( + f"{state} id={cache.cache_id} ref={cache.ref} " + f"size={cache.size} key={cache.key}" + ) + + if apply: + usage, endpoint, listed = settled_usage(api, current_version) + else: + remaining = [c for c in before if c not in candidates] + require_single_current_generation(remaining, current_version) + endpoint = api.usage_bytes() + listed = sum(cache.size for cache in before) + usage = max(endpoint - reclaimed, listed - reclaimed) + print( + f"dry-run current: max(endpoint={endpoint}, listed={listed}) bytes; " + f"projected after deletion={usage} bytes" + ) + print( + f"cache budget: max(endpoint={endpoint}, listed={listed})={usage} bytes; " + f"limit={BUDGET_BYTES} bytes; retired={len(candidates)} entries/{reclaimed} bytes" + ) + if usage > BUDGET_BYTES: + raise RuntimeError(f"Actions cache usage {usage} exceeds {BUDGET_BYTES}-byte budget") + return len(candidates), reclaimed + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True, help="Soldr version from the producer action output") + parser.add_argument("--apply", action="store_true", help="actually delete obsolete main cook bases") + args = parser.parse_args() + token = os.environ.get("GITHUB_TOKEN", "") + repository = os.environ.get("GITHUB_REPOSITORY", "") + if not token or not repository: + parser.error("GITHUB_TOKEN and GITHUB_REPOSITORY must be set") + try: + prune(GitHub(repository, token), args.version, apply=args.apply) + except (RuntimeError, ValueError, KeyError, json.JSONDecodeError) as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/test_cache_policy.py b/ci/test_cache_policy.py index cdf6032..3fdd032 100644 --- a/ci/test_cache_policy.py +++ b/ci/test_cache_policy.py @@ -27,6 +27,11 @@ r"^(?P *)- uses: zackees/setup-soldr@(?P\S+)(?P.*)$" ) +# Pin the runtime independently of the action SHA. A floating `latest` Soldr +# release creates another cache generation and makes concurrent producers +# disagree about which exact-only cook base they can restore. +SOLDR_RUNTIME_VERSION = "0.9.23" + # Every setup-soldr step, keyed by (workflow, job): the target it cooks for # and the profile it compiles. A new step must be classified here, so its # cook base is weighed against the others before it lands. `matrix` targets @@ -153,6 +158,15 @@ def test_one_pinned_revision(self): (ref,) = refs self.assertRegex(ref, r"^[0-9a-f]{40}$", "pin setup-soldr to a full commit SHA") + def test_one_pinned_soldr_runtime_version(self): + for step in self.steps(): + with self.subTest(workflow=step["workflow"], job=step["job"]): + self.assertEqual( + step["inputs"].get("version"), + SOLDR_RUNTIME_VERSION, + "pin the Soldr runtime so main producers share one cache generation", + ) + def test_cook_flags_match_the_compiled_profile(self): for step in self.steps(): target, profile = COOK_SHAPES[(step["workflow"], step["job"])] diff --git a/ci/test_native_proof_jobs.py b/ci/test_native_proof_jobs.py index dfa4620..8ba7c15 100644 --- a/ci/test_native_proof_jobs.py +++ b/ci/test_native_proof_jobs.py @@ -2,9 +2,10 @@ The workflow has four work jobs: `linux` (the gate), `build` and `test` (one runner per other target each), and `dylints`. Full mode adds a small coverage -sentinel after the work jobs. These -guards pin the properties that shape was chosen for, one test per property, so -a later edit that quietly undoes one fails here instead of on a runner bill. +sentinel after the work jobs. Main pushes add one non-compiling cache-retention +job after all cache producers finish. These guards pin the properties that +shape was chosen for, one test per property, so a later edit that quietly +undoes one fails here instead of on a runner bill. """ import re @@ -26,7 +27,14 @@ # x86_64 Linux is the gate: its builder is its host, so it builds and runs in # `linux` rather than in the per-target matrices. GATE_TARGET = "x86_64-unknown-linux-gnu" -EXPECTED_JOBS = {"linux", "build", "test", "dylints", "full-coverage"} +EXPECTED_JOBS = { + "linux", + "build", + "test", + "dylints", + "full-coverage", + "cache-retention", +} # `cargo-nextest` is the replay driver, not a compiler: it runs binaries out of # a prebuilt archive. `.cargo/bin` is a path. Everything else named here would # compile on the host running it. @@ -67,6 +75,14 @@ def test_the_pipeline_is_four_work_jobs_and_coverage(self): jobs = set(re.findall(r"(?m)^ ([\w-]+):\n", self.text().split("\njobs:\n", 1)[1])) self.assertEqual(jobs, EXPECTED_JOBS) + def test_cache_retention_is_an_after_producer_maintenance_job(self): + """Cache deletion must wait for all producer post-steps and run only on main.""" + job = self.job("cache-retention") + self.assertIn("needs: [linux, build, test, dylints, full-coverage]", job) + self.assertIn("github.event_name == 'push'", job) + self.assertIn("github.ref == 'refs/heads/main'", job) + self.assertIn("actions: write", job) + def test_only_python_310_is_tested(self): """The package is tested on its supported floor alone.""" self.assertNotIn("3.13", self.text()) diff --git a/ci/test_prune_obsolete_cook_caches.py b/ci/test_prune_obsolete_cook_caches.py new file mode 100644 index 0000000..5481cf4 --- /dev/null +++ b/ci/test_prune_obsolete_cook_caches.py @@ -0,0 +1,235 @@ +import unittest +from pathlib import Path + +from ci.prune_obsolete_cook_caches import ( + BUDGET_BYTES, + Cache, + cache_version, + GitHub, + prune, + require_single_current_generation, + stale_cook_bases, + version_tuple, +) + + +class CookCacheRetentionTests(unittest.TestCase): + def test_only_old_main_cook_bases_are_prunable(self): + caches = [ + Cache(1, "cook-base-v2-linux-x64-soldrv0.9.22", "refs/heads/main", 3), + Cache(2, "cook-base-v2-linux-x64-soldrv0.9.23", "refs/heads/main", 5), + Cache(3, "cook-base-v2-linux-arm64-soldrv0.9.22", "refs/pull/99/merge", 7), + Cache(4, "solo-toolchain-v3-linux-x64-soldrv0.9.22", "refs/heads/main", 11), + Cache(5, "cook-base-v2-linux-x64-soldrv0.9.24", "refs/heads/main", 13), + ] + + self.assertEqual(stale_cook_bases(caches, "0.9.23"), [caches[0]]) + + def test_current_generation_guard_rejects_mixed_versions(self): + caches = [ + Cache(1, "cook-base-v2-linux-x64-soldrv0.9.23", "refs/heads/main", 3), + Cache(2, "cook-base-v2-linux-arm64-soldrv0.9.22", "refs/heads/main", 7), + ] + + with self.assertRaisesRegex(ValueError, "expected only 0.9.23"): + require_single_current_generation(caches, "0.9.23") + + def test_current_generation_guard_preserves_distinct_shapes(self): + caches = [ + Cache(1, "cook-base-v2-linux-x64-soldrv0.9.23", "refs/heads/main", 3), + Cache(2, "cook-base-v2-linux-arm64-soldrv0.9.23", "refs/heads/main", 7), + ] + + require_single_current_generation(caches, "0.9.23") + + def test_unparseable_old_cache_is_never_pruned(self): + cache = Cache(1, "cook-base-v2-linux-x64", "refs/heads/main", 3) + + with self.assertRaisesRegex(ValueError, "cannot safely classify"): + stale_cook_bases([cache], "0.9.23") + + def test_prune_refuses_to_delete_old_generation_without_current_replacement(self): + class FakeGitHub: + def __init__(self): + self.entries = [ + Cache(1, "cook-base-v2-linux-x64-soldrv0.9.22", "refs/heads/main", 900) + ] + self.deletions = [] + + def caches(self): + return list(self.entries) + + def delete_cache(self, cache_id): + self.deletions.append(cache_id) + self.entries = [entry for entry in self.entries if entry.cache_id != cache_id] + return True + + api = FakeGitHub() + with self.assertRaisesRegex(ValueError, "no main cook-base exists"): + prune(api, "0.9.23", apply=True) + self.assertEqual(api.deletions, []) + self.assertEqual(api.entries[0].cache_id, 1) + + def test_versions_and_budget_use_gib_units(self): + self.assertEqual(version_tuple("v0.9.23"), (0, 9, 23)) + self.assertEqual(cache_version("cook-base-v2-linux-x64-soldrv0.9.23-xlinux"), "0.9.23") + self.assertEqual(BUDGET_BYTES, 10_200_547_328) + + def test_prune_deletes_only_obsolete_main_cook_bases(self): + class FakeGitHub: + def __init__(self): + self.entries = [ + Cache(1, "cook-base-v2-linux-x64-soldrv0.9.22", "refs/heads/main", 900), + Cache(2, "cook-base-v2-linux-x64-soldrv0.9.23", "refs/heads/main", 100), + Cache(3, "cook-base-v2-linux-arm64-soldrv0.9.23", "refs/heads/main", 200), + Cache(4, "cook-base-v2-linux-x64-soldrv0.9.22", "refs/pull/99/merge", 300), + Cache(5, "solo-toolchain-v3-linux-x64-soldrv0.9.22", "refs/heads/main", 400), + ] + + def caches(self): + return list(self.entries) + + def delete_cache(self, cache_id): + self.entries = [entry for entry in self.entries if entry.cache_id != cache_id] + return True + + def usage_bytes(self): + return sum(entry.size for entry in self.entries) + + api = FakeGitHub() + deleted, reclaimed = prune(api, "0.9.23", apply=True) + + self.assertEqual((deleted, reclaimed), (1, 900)) + self.assertEqual([cache.cache_id for cache in api.entries], [2, 3, 4, 5]) + + def test_concurrent_delete_404_is_idempotent_and_inventory_is_rechecked(self): + from unittest.mock import patch + + from urllib.error import HTTPError + + api = GitHub("zackees/kernal-api", "test-token") + missing = HTTPError("https://api.github.com/cache/1", 404, "Not Found", None, None) + with patch("ci.prune_obsolete_cook_caches.urlopen", side_effect=missing): + self.assertFalse(api.delete_cache(1)) + + class ConcurrentGitHub: + def __init__(self): + self.entries = [ + Cache(1, "cook-base-v2-linux-x64-soldrv0.9.22", "refs/heads/main", 900), + Cache(2, "cook-base-v2-linux-x64-soldrv0.9.23", "refs/heads/main", 10), + ] + self.list_count = 0 + + def caches(self): + self.list_count += 1 + return list(self.entries) + + def delete_cache(self, cache_id): + # Another cleanup already deleted the candidate after our snapshot. + self.entries = [entry for entry in self.entries if entry.cache_id != cache_id] + return False + + def usage_bytes(self): + return sum(entry.size for entry in self.entries) + + concurrent = ConcurrentGitHub() + with patch("ci.prune_obsolete_cook_caches.time.sleep"): + deleted, reclaimed = prune(concurrent, "0.9.23", apply=True) + self.assertEqual((deleted, reclaimed), (1, 900)) + self.assertGreaterEqual(concurrent.list_count, 2) + self.assertEqual([cache.cache_id for cache in concurrent.entries], [2]) + + def test_live_budget_uses_larger_of_usage_and_list(self): + class FakeGitHub: + def __init__(self): + self.entries = [ + Cache(1, "cook-base-v2-linux-x64-soldrv0.9.23", "refs/heads/main", 10), + ] + + def caches(self): + return self.entries + + def usage_bytes(self): + return BUDGET_BYTES + 1 + + # The usage endpoint is authoritative when it is higher than the list. + from unittest.mock import patch + + with patch("ci.prune_obsolete_cook_caches.time.sleep"): + from ci.prune_obsolete_cook_caches import settled_usage + + usage, endpoint, listed = settled_usage( + FakeGitHub(), "0.9.23", polls=1, interval=0 + ) + self.assertEqual((usage, endpoint, listed), (BUDGET_BYTES + 1, BUDGET_BYTES + 1, 10)) + + def test_live_budget_fails_when_either_source_exceeds_cap(self): + from unittest.mock import patch + + for endpoint, listed in ( + (BUDGET_BYTES + 1, 10), + (10, BUDGET_BYTES + 1), + ): + with self.subTest(endpoint=endpoint, listed=listed): + class FakeGitHub: + def caches(self): + return [ + Cache( + 1, + "cook-base-v2-linux-x64-soldrv0.9.23", + "refs/heads/main", + listed, + ) + ] + + def usage_bytes(self): + return endpoint + + with patch("ci.prune_obsolete_cook_caches.time.sleep"): + with self.assertRaisesRegex(RuntimeError, "exceeds"): + prune(FakeGitHub(), "0.9.23", apply=True) + + def test_live_budget_retries_until_deleted_key_disappears_from_listing(self): + class FakeGitHub: + def __init__(self): + self.read_count = 0 + self.current = Cache( + 2, "cook-base-v2-linux-x64-soldrv0.9.23", "refs/heads/main", 10 + ) + self.stale = Cache( + 1, "cook-base-v2-linux-x64-soldrv0.9.22", "refs/heads/main", 900 + ) + + def caches(self): + self.read_count += 1 + if self.read_count == 1: + return [self.current, self.stale] + if self.read_count == 2: + return [self.current, self.stale] # deletion/list lag + return [self.current] + + def delete_cache(self, cache_id): + self.asserted_id = cache_id + + def usage_bytes(self): + return 910 if self.read_count < 3 else 10 + + from unittest.mock import patch + + api = FakeGitHub() + with patch("ci.prune_obsolete_cook_caches.time.sleep"): + deleted, reclaimed = prune(api, "0.9.23", apply=True) + self.assertEqual((deleted, reclaimed, api.asserted_id), (1, 900, 1)) + + def test_cleanup_job_runs_after_all_producers_and_uses_their_soldr_version(self): + workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml").read_text() + + self.assertIn("soldr_version: ${{ steps.setup_soldr.outputs.soldr-version }}", workflow) + self.assertIn("needs: [linux, build, test, dylints, full-coverage]", workflow) + self.assertIn("github.event_name == 'push'", workflow) + self.assertIn("github.ref == 'refs/heads/main'", workflow) + self.assertIn("--version \"${SOLDR_VERSION}\" --apply", workflow) + + +if __name__ == "__main__": + unittest.main()