From b4ff23d08754bf0e5c1e7124fb4922be9870146e Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 25 Sep 2026 20:42:48 -0700 Subject: [PATCH] ci(cache): bump setup-soldr to v0.9.78, save-cache: auto, share the Linux dev cook base, guard the policy (#355) - Bump all seven setup-soldr pins to v0.9.78 (fabebf4), which adds `save-cache` (setup-soldr#527); every step sets `save-cache: auto`, so pull-request runs restore but upload no durable caches. - release.yml validate-and-package cooked a `--release` base under no suffix although every step compiles the dev profile. It now cooks the dev profile with suffix `linux` and ci.yml's CARGO_PROFILE_DEV_DEBUG, so it restores the one ~2.4 GB base ci.yml's `linux` job saves on main. - ci/test_cache_policy.py (run by ci.yml's guard step): one pinned SHA, every step classified by target and profile, cook flags match the profile, one suffix per target and graph unless justified, and no step saves on pull_request without a v0.9.78+ pin and `save-cache: auto` (or a listed justification for `true`). Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/auto-release.yml | 4 +- .github/workflows/ci.yml | 15 +- .github/workflows/macos-x64-tests.yml | 4 +- .github/workflows/release.yml | 20 ++- ci/test_cache_policy.py | 207 ++++++++++++++++++++++++++ 5 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 ci/test_cache_policy.py diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 0e6baa6..094264b 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -109,8 +109,10 @@ jobs: with: ref: ${{ inputs.candidate_sha }} lfs: true - - uses: zackees/setup-soldr@c2a3b964193132387c564430eb7d295af0a6d021 # v0.9.77 + - uses: zackees/setup-soldr@fabebf4ac3867b0008576797d566db0cb18d43c3 # v0.9.78 with: + # No durable saves from pull requests (setup-soldr#527, #355). + save-cache: auto # A release restores nothing. Every cache is a key that another job # can write -- the v0.1.14 aarch64 worker restored a toolchain whose # rustup records listed an aarch64 std that the saved archive did not diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7930ac6..06183f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,8 +85,10 @@ jobs: # restore where the gate had restored 26,983. `dylints` guards against # the same thing with its own CARGO_HOME. Downloading sources costs a # minute; a poisoned registry costs the whole job. - - uses: zackees/setup-soldr@c2a3b964193132387c564430eb7d295af0a6d021 # v0.9.77 + - uses: zackees/setup-soldr@fabebf4ac3867b0008576797d566db0cb18d43c3 # v0.9.78 with: + # No durable saves from pull requests (setup-soldr#527, #355). + save-cache: auto ci-tests: true cache-key-suffix: linux prebuild-deps-flags: "" @@ -101,7 +103,8 @@ jobs: uv run --no-project -m unittest \ ci.test_native_proof ci.test_native_proof_jobs ci.test_macos_x64_guest \ ci.test_ci_modes ci.test_release_ci_gate \ - ci.test_nextest_config ci.test_deny_warnings ci.test_target_features + ci.test_nextest_config ci.test_deny_warnings ci.test_target_features \ + ci.test_cache_policy bash tests/screenshot-build-arguments.sh bash tests/screenshot-target-repair.sh pwsh -NoProfile -File tests/screenshot-target-repair.ps1 @@ -430,8 +433,10 @@ jobs: run: >- sudo apt-get update && sudo apt-get install --yes libgtk-3-dev libwebkit2gtk-4.1-dev - - uses: zackees/setup-soldr@c2a3b964193132387c564430eb7d295af0a6d021 # v0.9.77 + - uses: zackees/setup-soldr@fabebf4ac3867b0008576797d566db0cb18d43c3 # v0.9.78 with: + # No durable saves from pull requests (setup-soldr#527, #355). + save-cache: auto ci-tests: true cache-key-suffix: build-${{ matrix.target }} cross-targets: ${{ matrix.cross }} @@ -813,8 +818,10 @@ jobs: run: >- sudo apt-get update && sudo apt-get install --yes libgtk-3-dev libwebkit2gtk-4.1-dev - - uses: zackees/setup-soldr@c2a3b964193132387c564430eb7d295af0a6d021 # v0.9.77 + - uses: zackees/setup-soldr@fabebf4ac3867b0008576797d566db0cb18d43c3 # v0.9.78 with: + # No durable saves from pull requests (setup-soldr#527, #355). + save-cache: auto toolchain: 1.95.0 cache: true build-cache: false diff --git a/.github/workflows/macos-x64-tests.yml b/.github/workflows/macos-x64-tests.yml index 4cb21df..6d11ed4 100644 --- a/.github/workflows/macos-x64-tests.yml +++ b/.github/workflows/macos-x64-tests.yml @@ -60,8 +60,10 @@ jobs: with: lfs: true - - uses: zackees/setup-soldr@c2a3b964193132387c564430eb7d295af0a6d021 # v0.9.77 + - uses: zackees/setup-soldr@fabebf4ac3867b0008576797d566db0cb18d43c3 # v0.9.78 with: + # No durable saves from pull requests (setup-soldr#527, #355). + save-cache: auto cache: false cross-targets: x86_64-apple-darwin diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ff4930..05dba80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,11 @@ jobs: needs: release-guard runs-on: ubuntu-latest environment: release + # Matches `linux` in ci.yml so this job's cook base is that job's (#355): + # the same target, dev profile and suffix restore one ~2.4 GB base + # instead of storing a second, `--release` one no step here compiles. + env: + CARGO_PROFILE_DEV_DEBUG: line-tables-only steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -70,8 +75,15 @@ jobs: - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 # Caches are fine here: this job tests the release and packages the # sources (`.crate`, sdist, wheel), which no cache can change. The - # jobs that build shipped binaries restore nothing. - - uses: zackees/setup-soldr@c2a3b964193132387c564430eb7d295af0a6d021 # v0.9.77 + # jobs that build shipped binaries restore nothing. Every step here + # compiles the dev profile (`cargo test`, the package verification), so + # cook that profile and share the `linux` CI job's cook base (#355). + - uses: zackees/setup-soldr@fabebf4ac3867b0008576797d566db0cb18d43c3 # v0.9.78 + with: + # No durable saves from pull requests (setup-soldr#527, #355). + save-cache: auto + cache-key-suffix: linux + prebuild-deps-flags: "" - name: Install native webview development packages run: >- sudo apt-get update && sudo apt-get install --yes @@ -171,8 +183,10 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ inputs.source_sha }} - - uses: zackees/setup-soldr@c2a3b964193132387c564430eb7d295af0a6d021 # v0.9.77 + - uses: zackees/setup-soldr@fabebf4ac3867b0008576797d566db0cb18d43c3 # v0.9.78 with: + # No durable saves from pull requests (setup-soldr#527, #355). + save-cache: auto # Shipped binaries restore nothing. Every cache is a key another job # can write -- the v0.1.14 aarch64 worker restored a toolchain whose # rustup records listed an aarch64 std that the saved archive did not diff --git a/ci/test_cache_policy.py b/ci/test_cache_policy.py new file mode 100644 index 0000000..37c9c7f --- /dev/null +++ b/ci/test_cache_policy.py @@ -0,0 +1,207 @@ +"""Keep the setup-soldr cache footprint inside the 10 GB repository budget. + +One pull request once offered about 23 GB of cache entries, 14.6 GB of them +~2.4 GB cook bases (#355, zackees/setup-soldr#528). These guards pin the +properties that keep the footprint bounded: + +- every setup-soldr step runs one pinned revision, so two versions never + write two copies of the same key; +- steps that cook the same graph for the same target share one + `cache-key-suffix` (the suffix is part of the cook-base key), unless a + listed reason says why the graphs differ; +- no step saves durable caches from a `pull_request` run, whose entries only + that pull request can restore: every pin is v0.9.78+ with `save-cache: auto` + unless a listed reason needs `true`. +""" + +import re +import unittest +from pathlib import Path + +WORKFLOWS = Path(__file__).resolve().parents[1] / ".github/workflows" +SETUP_SOLDR = re.compile( + r"^(?P *)- uses: zackees/setup-soldr@(?P\S+)(?P.*)$" +) + +# 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 +# carry `${{ matrix.target }}` in their suffix, one distinct target per entry. +COOK_SHAPES = { + ("ci.yml", "linux"): ("x86_64-unknown-linux-gnu", "dev"), + ("ci.yml", "build"): ("matrix", "dev"), + ("ci.yml", "dylints"): ("dylint-nightly", "none"), + ("release.yml", "validate-and-package"): ("x86_64-unknown-linux-gnu", "dev"), + ("release.yml", "symbolizer-workers"): ("matrix", "none"), + ("auto-release.yml", "publish-crates"): ("x86_64-unknown-linux-gnu", "none"), + ("macos-x64-tests.yml", "intel-macos-tests"): ("x86_64-apple-darwin", "none"), +} + +# (target, profile) pairs whose steps may keep different suffixes, with the +# reason their cook graphs genuinely differ. Empty today: every cooking pair +# on one target shares one suffix. +JUSTIFIED_SPLITS: dict[tuple[str, str], str] = {} + +# setup-soldr v0.9.78 (zackees/setup-soldr#527) added `save-cache`, whose +# default `auto` skips every durable save on `pull_request`. Older pins save +# unconditionally. +SAVE_POLICY_MIN_VERSION = (0, 9, 78) + +# Steps allowed `save-cache: "true"` on pull requests, with the reason: only +# when a later job in the same run restores what this step saved. Empty +# today: no job in a pull-request run restores another job's entry (`linux`, +# each `build` target and `dylints` key their own caches; `test` compiles +# nothing). +PR_SAVE_JUSTIFICATIONS: dict[tuple[str, str], str] = {} + +# What `prebuild-deps-flags` must be for a job compiling each profile. The +# flags are hashed into the cook key; cooking `--release` for a job that only +# builds the dev profile stores a base nothing reuses. +PROFILE_FLAGS = {"dev": "", "release": "--release"} +DEFAULT_COOK_FLAGS = "--release" + + +def workflow_triggers(text): + block = re.search(r"(?ms)^on:\n(.*?)(?=^\S)", text) + return set(re.findall(r"(?m)^ ([\w-]+):", block.group(1))) if block else set() + + +def setup_soldr_steps(): + """Yield one dict per setup-soldr step across every workflow.""" + for path in sorted(WORKFLOWS.glob("*.yml")): + text = path.read_text(encoding="utf-8") + triggers = workflow_triggers(text) + lines = text.splitlines() + job = None + for index, line in enumerate(lines): + header = re.match(r"^ ([\w-]+):\s*$", line) + if header and text.find("\njobs:\n") < sum(len(x) + 1 for x in lines[:index]): + job = header.group(1) + match = SETUP_SOLDR.match(line) + if not match: + continue + step_indent = len(match.group("indent")) + inputs = {} + for body in lines[index + 1 :]: + if body.strip() and len(body) - len(body.lstrip()) <= step_indent: + break + pair = re.match(r"^\s+([\w-]+):\s*(.*?)\s*$", body) + if pair and not body.lstrip().startswith("#") and pair.group(1) != "with": + inputs[pair.group(1)] = pair.group(2).strip('"').strip("'") + yield { + "workflow": path.name, + "job": job, + "ref": match.group("ref"), + "comment": match.group("comment"), + "inputs": inputs, + "triggers": triggers, + } + + +def cooks(step): + inputs = step["inputs"] + return inputs.get("cache", "true") != "false" and inputs.get("prebuild-deps", "") != "none" + + +def pin_version(step): + match = re.search(r"#\s*v(\d+)\.(\d+)\.(\d+)", step["comment"]) + return tuple(int(part) for part in match.groups()) if match else None + + +def saves_on_pull_request(step): + """Whether the step would upload durable caches from a pull request.""" + inputs = step["inputs"] + if all( + inputs.get(name, "true") == "false" + for name in ("cache", "build-cache", "solo-toolchain-cache") + ): + return False + version = pin_version(step) + if version is None or version < SAVE_POLICY_MIN_VERSION: + return True + return inputs.get("save-cache", "auto") not in {"auto", "false"} + + +class CachePolicyTests(unittest.TestCase): + def steps(self): + steps = list(setup_soldr_steps()) + self.assertTrue(steps, "found no setup-soldr steps") + return steps + + def test_every_step_is_classified(self): + """A new setup-soldr step names its target and profile here first.""" + found = {(s["workflow"], s["job"]) for s in self.steps()} + self.assertEqual(found, set(COOK_SHAPES)) + + def test_one_pinned_revision(self): + """Mixed setup-soldr versions write duplicate keys.""" + refs = {s["ref"] for s in self.steps()} + self.assertEqual(len(refs), 1, f"setup-soldr pins differ: {sorted(refs)}") + (ref,) = refs + self.assertRegex(ref, r"^[0-9a-f]{40}$", "pin setup-soldr to a full commit SHA") + + def test_cook_flags_match_the_compiled_profile(self): + for step in self.steps(): + target, profile = COOK_SHAPES[(step["workflow"], step["job"])] + with self.subTest(workflow=step["workflow"], job=step["job"]): + if profile == "none": + self.assertFalse(cooks(step), "classified as not cooking, but cooks") + continue + self.assertTrue(cooks(step), "classified as cooking, but does not") + flags = step["inputs"].get("prebuild-deps-flags", DEFAULT_COOK_FLAGS) + if target == "matrix": + self.assertIn("matrix.", step["inputs"].get("cache-key-suffix", "")) + continue + self.assertEqual(flags, PROFILE_FLAGS[profile]) + + def test_one_suffix_per_target_and_graph(self): + """Each cook base is ~2.4 GB; two suffixes for one graph store it twice.""" + suffixes = {} + for step in self.steps(): + target, profile = COOK_SHAPES[(step["workflow"], step["job"])] + if not cooks(step) or target == "matrix": + continue + key = (target, profile, step["inputs"].get("toolchain", "")) + suffixes.setdefault(key, {}).setdefault( + step["inputs"].get("cache-key-suffix", ""), [] + ).append(f"{step['workflow']}:{step['job']}") + for (target, profile, _), by_suffix in suffixes.items(): + if (target, profile) in JUSTIFIED_SPLITS: + continue + with self.subTest(target=target, profile=profile): + self.assertEqual(len(by_suffix), 1, f"one graph, several suffixes: {by_suffix}") + + def test_justified_splits_are_live(self): + for pair, reason in JUSTIFIED_SPLITS.items(): + self.assertTrue(reason.strip(), pair) + self.assertIn(pair, set(COOK_SHAPES.values())) + + def test_no_step_saves_on_pull_request(self): + """A pull-request cache is restorable only by that pull request.""" + for step in self.steps(): + if "pull_request" not in step["triggers"] or not saves_on_pull_request(step): + continue + where = (step["workflow"], step["job"]) + with self.subTest(workflow=where[0], job=where[1]): + self.assertIn( + where, + PR_SAVE_JUSTIFICATIONS, + "pin setup-soldr v0.9.78+ and leave `save-cache` unset or `auto`", + ) + + def test_every_step_states_its_save_policy(self): + """Every step names `save-cache`, so the policy is visible in review.""" + for step in self.steps(): + with self.subTest(workflow=step["workflow"], job=step["job"]): + self.assertIn(step["inputs"].get("save-cache"), {"auto", "true", "false"}) + + def test_pr_save_justifications_are_live(self): + """Drop a justification once its step stops saving on pull requests.""" + for where, reason in PR_SAVE_JUSTIFICATIONS.items(): + steps = [s for s in self.steps() if (s["workflow"], s["job"]) == where] + with self.subTest(workflow=where[0], job=where[1]): + self.assertTrue(reason.strip()) + self.assertTrue(steps and all(saves_on_pull_request(s) for s in steps)) + +if __name__ == "__main__": + unittest.main()