|
| 1 | +# SPDX-License-Identifier: AGPL-3.0-only |
| 2 | +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 |
| 3 | + |
| 4 | +"""The frontend dist cache and setup.sh's rebuild check must read the same inputs. |
| 5 | +
|
| 6 | +Measured over 13 distinct Linux jobs on main, the frontend build is a median 36s of a |
| 7 | +74s `install-unsloth-local`, 49% of it, and 468s per commit producing byte-identical |
| 8 | +output. The cache exists to stop paying that 13 times. |
| 9 | +
|
| 10 | +What makes it safe is not the cache action, it is the agreement between two places: |
| 11 | +
|
| 12 | + studio/setup.sh rebuilds when anything under frontend/ (maxdepth 1, minus |
| 13 | + bun.lock), frontend/src or frontend/public is NEWER than |
| 14 | + frontend/dist |
| 15 | + the action's cache key hashes exactly those three path groups |
| 16 | +
|
| 17 | +A hit therefore means the build inputs are byte-identical, which is strictly stronger |
| 18 | +than the mtime test it rides on. Break the agreement and nothing goes red: the cache |
| 19 | +keeps hitting and quietly starts serving a dist built from inputs the key no longer |
| 20 | +covers, and every job downstream tests a stale bundle that passes. That is the whole |
| 21 | +reason this file exists, and it is why it asserts against setup.sh's own source rather |
| 22 | +than a list written down here. |
| 23 | +
|
| 24 | +Three subtler failure modes are pinned too, each of which looks like success: |
| 25 | +
|
| 26 | + * `restore-keys` on this cache. A near-miss download cache still supplies most of the |
| 27 | + wheels; a near-miss dist is a bundle built from different source. Wrong, not partial. |
| 28 | + * A restore with no `touch`. actions/cache restores through tar, which preserves the |
| 29 | + original mtimes, so the restored dist is older than the checkout that just wrote |
| 30 | + every source file and setup.sh rebuilds anyway. The cache would cost a download, |
| 31 | + save nothing, and report a hit. |
| 32 | + * An empty `hashFiles`. It returns "" when a glob matches nothing, collapsing every |
| 33 | + commit onto one key and serving an arbitrary dist. |
| 34 | +""" |
| 35 | + |
| 36 | +from __future__ import annotations |
| 37 | + |
| 38 | +import re |
| 39 | +from pathlib import Path |
| 40 | + |
| 41 | +import yaml |
| 42 | + |
| 43 | + |
| 44 | +REPO = Path(__file__).resolve().parents[2] |
| 45 | +ACTION = REPO / ".github" / "actions" / "install-unsloth-local" / "action.yml" |
| 46 | +SETUP_SH = REPO / "studio" / "setup.sh" |
| 47 | + |
| 48 | + |
| 49 | +def _steps() -> list[dict]: |
| 50 | + doc = yaml.safe_load(ACTION.read_text(encoding = "utf-8")) or {} |
| 51 | + return [s for s in (doc.get("runs") or {}).get("steps") or [] if isinstance(s, dict)] |
| 52 | + |
| 53 | + |
| 54 | +def _step(fragment: str) -> dict | None: |
| 55 | + for step in _steps(): |
| 56 | + if fragment.lower() in str(step.get("name", "")).lower(): |
| 57 | + return step |
| 58 | + return None |
| 59 | + |
| 60 | + |
| 61 | +def _restore_step() -> dict: |
| 62 | + step = _step("Restore the built frontend") |
| 63 | + assert step is not None, ( |
| 64 | + "install-unsloth-local no longer restores a built frontend. If the cache was " |
| 65 | + "removed on purpose, delete this file; if it was renamed, retarget it." |
| 66 | + ) |
| 67 | + return step |
| 68 | + |
| 69 | + |
| 70 | +def _key() -> str: |
| 71 | + return str((_restore_step().get("with") or {}).get("key", "")) |
| 72 | + |
| 73 | + |
| 74 | +def _key_globs() -> set[str]: |
| 75 | + """The paths hashFiles() reads, normalised so a trailing /** does not matter.""" |
| 76 | + inner = re.search(r"hashFiles\((.*?)\)", _key()) |
| 77 | + assert inner, f"the dist cache key does not call hashFiles: {_key()!r}" |
| 78 | + return { |
| 79 | + g.strip().strip("'\"").removesuffix("/**").rstrip("/*").rstrip("/") |
| 80 | + for g in inner.group(1).split(",") |
| 81 | + } |
| 82 | + |
| 83 | + |
| 84 | +def _staleness_inputs() -> set[str]: |
| 85 | + """The paths setup.sh's rebuild check compares against frontend/dist. |
| 86 | +
|
| 87 | + Read out of setup.sh rather than hardcoded: a list written here would agree with |
| 88 | + itself forever while setup.sh moved. |
| 89 | + """ |
| 90 | + text = SETUP_SH.read_text(encoding = "utf-8") |
| 91 | + block = re.search( |
| 92 | + r"Detect whether frontend needs building(.*?)end packaged/Tauri guard", text, re.S |
| 93 | + ) |
| 94 | + assert block, "could not find the frontend staleness check in studio/setup.sh" |
| 95 | + body = block.group(1) |
| 96 | + found = set() |
| 97 | + for m in re.finditer(r'"\$SCRIPT_DIR/(frontend[^"]*)"', body): |
| 98 | + path = m.group(1) |
| 99 | + if path.endswith("/dist"): |
| 100 | + continue |
| 101 | + found.add("studio/" + path) |
| 102 | + return found |
| 103 | + |
| 104 | + |
| 105 | +def test_the_key_covers_every_path_the_rebuild_check_reads() -> None: |
| 106 | + missing = sorted(_staleness_inputs() - _key_globs()) |
| 107 | + assert not missing, ( |
| 108 | + f"studio/setup.sh decides to rebuild the frontend by looking at {missing}, and the " |
| 109 | + f"dist cache key does not hash them. A change to those files would not change the " |
| 110 | + f"key, so the cache would hit and serve a dist built from different source, and " |
| 111 | + f"nothing would go red. Key: {_key()!r}" |
| 112 | + ) |
| 113 | + |
| 114 | + |
| 115 | +def test_the_key_does_not_hash_paths_the_rebuild_check_ignores() -> None: |
| 116 | + """Not a style rule: an over-broad key silently destroys the hit rate. |
| 117 | +
|
| 118 | + bun.lock is the deliberate exception. setup.sh must exclude it because the install |
| 119 | + regenerates it and it would self-trigger every run; the cache has no such problem, |
| 120 | + and a lockfile change means different dependencies and so a different bundle. It is |
| 121 | + covered by the `studio/frontend/*` glob, which is why that glob is allowed to be |
| 122 | + broader than the check's maxdepth-1 scan rather than being narrowed to match it. |
| 123 | + """ |
| 124 | + extra = sorted(_key_globs() - _staleness_inputs()) |
| 125 | + assert extra == [], ( |
| 126 | + f"the dist cache key hashes {extra}, which setup.sh's rebuild check does not " |
| 127 | + f"read. Every unrelated edit to those paths would miss the cache for no reason. " |
| 128 | + f"If the extra path genuinely affects the built bundle, say so where the key is " |
| 129 | + f"defined and widen this test deliberately." |
| 130 | + ) |
| 131 | + |
| 132 | + |
| 133 | +def test_the_dist_cache_has_no_restore_keys() -> None: |
| 134 | + with_ = _restore_step().get("with") or {} |
| 135 | + assert "restore-keys" not in with_, ( |
| 136 | + "the frontend dist cache has restore-keys. A prefix hit would serve a bundle " |
| 137 | + "built from DIFFERENT source, which is wrong rather than partial. The uv " |
| 138 | + "download cache in the same action does want them, and that contrast is the " |
| 139 | + "point: a near-miss download still supplies most of the wheels." |
| 140 | + ) |
| 141 | + |
| 142 | + |
| 143 | +def test_a_restored_dist_is_made_newer_than_the_checkout() -> None: |
| 144 | + step = _step("outrank its sources") |
| 145 | + assert step is not None, ( |
| 146 | + "nothing touches the restored dist. actions/cache restores through tar, which " |
| 147 | + "preserves the original mtimes, so setup.sh's `find -newer dist` sees the whole " |
| 148 | + "freshly checked-out tree as newer and rebuilds anyway. The cache would report a " |
| 149 | + "hit, cost a download and save nothing." |
| 150 | + ) |
| 151 | + body = str(step.get("run", "")) |
| 152 | + assert re.search(r"^\s*touch studio/frontend/dist\s*$", body, re.M), ( |
| 153 | + f"the step meant to make the restored dist outrank its sources does not touch " |
| 154 | + f"studio/frontend/dist: {body!r}" |
| 155 | + ) |
| 156 | + assert str(step.get("if", "")).strip() == "steps.fe-dist.outputs.cache-hit == 'true'", ( |
| 157 | + "the touch must be gated on a cache hit, or a miss would touch a dist that was " |
| 158 | + "never restored and suppress the build that has to happen" |
| 159 | + ) |
| 160 | + |
| 161 | + |
| 162 | +def test_a_degenerate_key_is_refused() -> None: |
| 163 | + step = _step("hashes nothing") |
| 164 | + assert step is not None, ( |
| 165 | + 'nothing refuses an empty hashFiles result. It returns "" when a glob matches ' |
| 166 | + "no file, which collapses every commit onto one key and serves an arbitrary " |
| 167 | + "dist, with the restore succeeding and the build skipped." |
| 168 | + ) |
| 169 | + assert "exit 1" in str(step.get("run", "")), "the degenerate-key check does not fail the job" |
| 170 | + |
| 171 | + |
| 172 | +def test_the_dist_cache_is_saved_on_main_only() -> None: |
| 173 | + step = _step("Save the built frontend") |
| 174 | + assert step is not None, "the dist cache is restored but never saved, so it can only ever miss" |
| 175 | + cond = str(step.get("if", "")) |
| 176 | + assert "refs/heads/main" in cond, ( |
| 177 | + f"the dist cache is saved off main: {cond!r}. A PR-scoped entry can only be " |
| 178 | + f"restored by re-runs of that same PR while still counting against the shared " |
| 179 | + f"budget, evicting the copy every PR can read." |
| 180 | + ) |
| 181 | + |
| 182 | + |
| 183 | +def test_the_guard_is_reading_real_files() -> None: |
| 184 | + """Every assertion above passes vacuously if these two files stop being found.""" |
| 185 | + assert ACTION.is_file(), ACTION |
| 186 | + assert SETUP_SH.is_file(), SETUP_SH |
| 187 | + assert len(_staleness_inputs()) >= 2, _staleness_inputs() |
| 188 | + assert len(_key_globs()) >= 2, _key_globs() |
0 commit comments