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
1 change: 1 addition & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions .github/workflows/macos-x64-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
247 changes: 247 additions & 0 deletions ci/prune_obsolete_cook_caches.py
Original file line number Diff line number Diff line change
@@ -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<version>\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())
14 changes: 14 additions & 0 deletions ci/test_cache_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
r"^(?P<indent> *)- uses: zackees/setup-soldr@(?P<ref>\S+)(?P<comment>.*)$"
)

# 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
Expand Down Expand Up @@ -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"])]
Expand Down
Loading
Loading