Stop spending the Actions cache budget on caches nothing reads (#9151)

* Stop spending the Actions cache budget on caches nothing reads

The repo holds 50.0 GB of Actions cache against a 10 GB limit, so LRU eviction runs
continuously and the entries CI actually depends on are the ones being thrown
away. Measured:

  28.9 GB   60 entries  setup-python pip     58% of the budget, only 18 distinct keys
   7.2 GB    8 entries  v0-rust
  11.8 GB    5 entries  the GGUF / HF model caches CI depends on
  25.9 GB  214 entries  on PR refs, restorable only by re-runs of that same PR

Two doors let that happen, and both are silent: nothing goes red when a cache is
evicted, CI just re-downloads a 4.6 GB model and everyone assumes that is the cost.

setup-python derives one pip key per interpreter from dependency files across the
whole repo, so dozens of unrelated jobs share it and race to save under it. The
entries measure 666-715 MB. Jobs that only pip-install huggingface_hub, pytest or
playwright were paying that for the 0-7s their restore step takes. Those 24 call
sites give the cache back; the 9 that install torch/transformers keep it.

The Playwright browser caches in studio-ui-smoke.yml used the read-write
actions/cache, which saves from its post-step on every ref, at ~470 MB per browser
set. They now restore always and save on main only, matching what every model
cache in this repo already does.

This is the same thrash loop the GGUF caches were fixed for, arriving through a
different door: PR misses, downloads, saves its own copy, evicts main's, next PR
misses.

Windows benefits most. Its model caches are among the evicted, and a Windows cache
miss costs about 3x a Linux one.

tests/studio/test_cache_budget_discipline.py asserts both doors stay shut, that
cold-install lanes are never warmed by a cache, and that every setup-python step
still pins an interpreter -- the last because removing cache: 'pip' from an
inline-flow mapping by deleting the line takes python-version with it, which this
change did once before it was caught.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Correct the cache budget figure: 50 GiB, not GitHub's 10GB default

The budget comments across eleven workflows quoted GitHub's 10GB default. This
repo's quota is 50 GiB, so the earlier framing (and the 33.3GB / 3.3x-over
measurement they carry) understated the headroom and overstated the severity.

Re-measured with the correct quota: 49.63 GiB across 258 entries, 99.3% full. The
conclusion does not change, only the reason. Eviction runs at the margin, and what
fills the budget is redundancy rather than useful payload:

  20.74 GiB  the SAME key held on several refs (42% of the whole cache)
               6.67 GiB  13 copies  setup-python ... python-3.13.15-pip-85e247d7...
               6.50 GiB  11 copies  setup-python ... python-3.11.15-pip-85e247d7...
               3.83 GiB  10 copies  setup-python ... python-3.12.13-pip-85e247d7...
               0.91 GiB   3 copies  ms-playwright-Linux-1.62.0-cfw-v1
  10.70 GiB  84 entries written and never read again, 10.60 GiB of it setup-python
  24.01 GiB  198 entries on PR refs, restorable only by re-runs of that same PR
   0.00 GiB  entries unread for 7+ days: nothing is idle, the cache is churning

Every duplicated key already has a copy on main, which every PR can restore from,
so the PR-scoped copies buy no hit rate and evict the copy that does.

Worth recording for whoever adds the next cache: the duplication is a SYMPTOM of
sitting at quota, not an independent cause. A PR only writes its own copy after
missing, and it only misses because main's copy was evicted. Getting back under
the quota stops the loop on its own, which is why this change reclaims claimants
rather than restructuring the nine jobs that legitimately cache torch.

* Scope every pip cache key to what its job actually installs

Five of the nine jobs that keep a pip cache had no cache-dependency-path, so
setup-python hashed dependency files across the whole repo to build their key.
That is the second multiplier behind the 27.05 GiB those caches occupy: 16
distinct keys appeared in a week, because one requirements edit anywhere
invalidates every interpreter's entry at once and orphans the old ones at ~700MB
each. The four already-scoped jobs (consolidated-tests, studio-backend) show the
pattern; it just was not applied consistently.

mlx-ci installs from studio/backend/requirements/studio.txt, so it now points
there like its siblings. The other four pin their dependencies inline in the
workflow (a torch CPU index URL, pinned transformers/trl/peft), so there is no
requirements file to name and their key was describing files they never read.
For those the workflow file IS the dependency spec, so the key hashes that: it
moves when the install actually changes and not otherwise.

notebooks-ci's step had to be expanded from the inline-flow form
(with: { python-version: '3.12', cache: 'pip' }) to take the new key.

The guard test now asserts every allowed pip cache scopes its key, since an
unscoped one costs budget quietly rather than failing.

* Save the Playwright browsers only when the download succeeded

The save ran under a bare always(), so a browser install that failed part-way still stored
whatever had landed on disk. The key is pinned to the resolved Playwright version and never
rolls over, so every later run would restore that partial tree, report a cache hit, skip the
download and run install-deps against engines that are not there. Every UI job fails until
someone deletes the entry by hand, and nothing in the log points at the cache.

always() stays, so browsers that did download are not thrown away because an unrelated
earlier step failed; the install step now carries an id and the save checks its outcome.
Both jobs in the workflow had the same condition.

* Point the scoped cache keys at the nested checkout, and run the guard on workflow-only PRs

Three jobs check the repo out under `unsloth/` because they need a second repo beside it:
notebooks-ci api-introspect, and version-compat-ci zoo-imports-under-spoof and
grpo-fake-run. The cache-dependency-path added for them was workspace-root-relative, so it
matched no file. setup-python treats that as fatal ("No file in ... matched to ..."), not
as a reason to skip the cache, so all three jobs would have stopped before installing
anything. Prefixed with the checkout directory, and guarded: the new test resolves every
cache-dependency-path against its own job's checkout paths.

The discipline test is also wired into workflow-trigger-lint, which carries no paths
filter. Repo tests (CPU) does collect the file, but studio-backend-ci.yml's paths do not
match a workflow-only PR, so the job never starts for exactly the change this guard exists
to reject. Collection is not coverage if nothing triggers the workflow that collects it.

* Count setup-python's implicit save, and drop a cache path with no cache

The save scan only looked for actions/cache steps, so it reported clean while nine jobs
wrote a PR-scoped entry on every run. setup-python's cache: input registers a post-step
(post: dist/cache-save/index.js in its own action.yml) that saves after the job on
whatever ref it ran on, with no condition to gate it. Those nine are now named in
PIP_CACHE_JOBS_PENDING_CONVERSION rather than skipped silently, so a tenth joining them
fails the check, and the follow-up that converts them to an explicit restore plus
main-only save empties the set.

lint-ci.yml also kept a cache-dependency-path after its cache: 'pip' was removed. That is
inert, since setup-python only reads the path when caching is on, but it reads as a
scoped cache key and the next person believes the job is cached. Removed, with the
comment rewritten to say why the job has no cache, and a check for the same shape
elsewhere.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Read the save condition and the cache path, rather than searching them

Two guards in this file passed on text that looked right rather than on the
property they claim.

The main-only check was a substring search for "refs/heads/main". Three
conditions contain that literal while still saving on every PR: a `!=`
comparison, an `||` that admits another event, and the check appearing in only
one branch of one. It now splits the expression on its top-level `||` and
requires a positive `github.ref == 'refs/heads/main'` in every alternative,
which is conservative in the safe direction. The splitter is itself tested
against nine expressions, including a `||` inside a string.

The cache-dependency-path check compared prefixes and skipped root-checkout
jobs entirely, so `unsloth/.github/workflows/typo.yml` read as correct and a
misspelling in a root-checkout job was never examined. Each entry is now
resolved against the checkout it belongs to and globbed against the tree.
Paths under a checkout of a DIFFERENT repository are skipped rather than
reported: notebooks-ci api-introspect checks out unslothai/notebooks beside
this one, and a path under it cannot be resolved here.

Both matter because setup-python does not skip a cache it cannot resolve, it
fails the job with "No file in ... matched" before anything is installed.

All four shapes verified red: the inverted comparison, the widened `||`, a
correctly prefixed but misspelled nested path, and a misspelled root path.

* Save the pip cache on main only, via a restore/save action pair (#9165)

* Save the pip cache on main only, via a restore/save action pair

setup-python's built-in cache: 'pip' is the read-write form. It restores in the step and
saves from its own post-step on whatever ref the job ran on, and exposes no condition to
stop that. An entry written on a pull_request ref can only be restored by re-runs of that
same pull request, so it buys no hit rate while competing for the shared 50 GiB budget
against the copy on main that every PR can read.

Measured on this repo before the change: setup-python entries were 19.45 GiB across 40
entries, 15.49 GiB of it on PR refs, with four interpreter keys duplicated four to six
times each for 9.60 GiB of pure waste. None of it shows up as a failure. Over quota,
GitHub evicts least-recently-used, so main's copy goes, the next PR misses, downloads and
writes its own, and CI simply gets slower. The repo had already diagnosed and fixed this
same loop for the GGUF caches and the Playwright browsers; setup-python was left doing it
because the built-in cache has no save-gating knob.

Splitting restore from save is how you get one. The nine jobs that genuinely install a
torch/transformers-class dependency set now use .github/actions/pip-cache-restore and
.github/actions/pip-cache-save, with the save gated on refs/heads/main. Defined once as a
composite pair rather than inlined nine times, following install-unsloth-local: the gate
is the whole point of the change and nine copies of it would drift.

The action resolves pip's cache directory with pip cache dir rather than hardcoding a path,
so the macOS and Windows call sites work unchanged, and it fails loudly when key-files
matches nothing, since an empty hash would silently collapse every job onto one key.

The guards move with the architecture. The save scan now reads composite actions too,
which is where the save now lives; a job using the built-in cache at all is now a failure
rather than an allowlist question; and each of the nine is checked to restore and save as
a wired pair.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Point the local action references at the nested checkout

`uses: ./...` resolves from GITHUB_WORKSPACE, not from the workflow file, and three jobs
check the repo out under `unsloth/` because they need a second repo beside it. The
unprefixed path is a directory that does not exist there, so all six references would have
failed with "Can't find 'action.yml', 'action.yaml' or 'Dockerfile'" before installing
anything.

Same root cause as the cache key paths, one level out: those were fixed for these jobs and
the action paths were not. The guard now resolves every local action reference against its
own job's checkout paths.

* Discover pip cache users, rather than only iterating the allowlist

Replacing setup-python's built-in `cache: 'pip'` removed the mechanism that
found claimants. Every remaining check is parametrized over PIP_CACHE_JOBS, so
a new job adding the restore/save pair was never visited: it would get a
~700MB entry with no scoping check, no wiring check and no justification, and
this file would stay green. The allowlist stopped being enforced and became a
list to iterate.

Two checks now read the workflows instead. One rejects any job using either
half of the pair without being listed. The other re-applies the heavy-install
requirement to the discovered set, so a job that keeps its cache after its
torch-class install moves elsewhere is reported rather than grandfathered.

Verified red by giving workflow-trigger-lint, which installs nothing heavy,
a pip-cache-restore step.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-08-18 04:47:22 -07:00 committed by GitHub
parent aa32c1861c
commit 54b6ca4c3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1043 additions and 106 deletions

View file

@ -0,0 +1,83 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Restore half of the pip cache, replacing `actions/setup-python`'s built-in
# `cache: 'pip'`.
#
# The built-in cache is read-write and saves from its own post-step on WHATEVER
# REF the job ran on. A cache written on a pull_request ref can only be restored
# by re-runs of that same pull request ("caches created on the base branch are
# available to the topic branch, but not the other way round"), so every PR
# writes a ~700MB copy that nobody else can ever read, and that copy competes for
# the shared 50 GiB budget against main's copy, which every PR CAN read. Measured
# on this repo: setup-python entries were 19.45 GiB across 40 entries, 15.49 GiB
# of it on PR refs, with four interpreter keys duplicated 4-6 times each.
#
# Nothing about that is visible as a failure. Over quota, GitHub evicts
# least-recently-used, so main's copy goes, the next PR misses, downloads, and
# writes its own copy. CI just gets slower and everyone assumes that is the cost.
# The repo had already diagnosed and fixed this exact loop for the GGUF caches
# (see the save step in studio-inference-smoke.yml) and for the Playwright
# browsers; setup-python was left doing it because its built-in cache has no
# save-gating knob. Splitting restore from save is how you get one.
#
# Pair this with pip-cache-save AFTER the install, passing the outputs below.
name: Restore the pip cache
description: >-
Restore pip's HTTP cache for this runner and interpreter, keyed on the files
the job actually installs from. Read-only: the save is a separate action and
runs on the default branch only.
inputs:
key-files:
description: >-
Newline-separated glob(s) whose hash keys the cache. Pass the files this
job installs from, NOT a repo-wide pattern: the built-in cache hashed
dependency files across the whole repo, so an unrelated requirements edit
invalidated every interpreter's entry at once and orphaned the old ones.
Paths resolve from the workspace root, so a job that checks out into a
subdirectory must include it.
required: true
outputs:
dir:
description: pip's cache directory on this runner.
value: ${{ steps.probe.outputs.dir }}
key:
description: The full cache key, to hand to pip-cache-save.
value: ${{ steps.probe.outputs.key }}
cache-hit:
description: 'true when the exact key was restored.'
value: ${{ steps.restore.outputs.cache-hit }}
runs:
using: composite
steps:
# `pip cache dir` rather than a hardcoded path per OS: it differs on Linux,
# macOS and Windows, and pip itself is the authority on where it put things.
- name: Resolve the pip cache directory and key
id: probe
shell: bash
run: |
set -euo pipefail
dir="$(python -m pip cache dir)"
echo "dir=$dir" >> "$GITHUB_OUTPUT"
pyver="$(python -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])')"
hash="${{ hashFiles(inputs.key-files) }}"
# Empty means the globs matched nothing, which would silently collapse
# every job onto one key. Loud here, where the cause is one line away.
if [ -z "$hash" ]; then
echo "::error::pip-cache: key-files matched no file, so the cache key would not distinguish anything. Given: ${{ inputs.key-files }}"
exit 1
fi
echo "key=pip-${{ runner.os }}-${{ runner.arch }}-py${pyver}-${hash}" >> "$GITHUB_OUTPUT"
- name: Restore the pip cache
id: restore
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
# A cache is an optimisation; a cache service blip must not fail the job.
continue-on-error: true
with:
path: ${{ steps.probe.outputs.dir }}
key: ${{ steps.probe.outputs.key }}

View file

@ -0,0 +1,40 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Save half of the pip cache. See pip-cache-restore for why this is split out.
#
# Runs on the default branch ONLY. That is the whole point: an entry written on a
# pull_request ref is restorable only by re-runs of that same PR, so it buys no
# hit rate and evicts the copy on main that every PR can read.
name: Save the pip cache
description: Save pip's cache under the restore step's key, on the default branch only.
inputs:
dir:
description: pip's cache directory, from pip-cache-restore's `dir` output.
required: true
key:
description: The cache key, from pip-cache-restore's `key` output.
required: true
cache-hit:
description: >-
pip-cache-restore's `cache-hit`. Skipped when it is 'true', because the key
is already present and re-uploading it would be pure cost.
required: true
runs:
using: composite
steps:
- name: Save the pip cache
# always(), so a cache earned by a successful install is not thrown away
# because a LATER step in the job failed. The install itself is what fills
# this directory, and a failed test does not make its downloads wrong.
if: >-
always() && github.ref == 'refs/heads/main'
&& inputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
continue-on-error: true
with:
path: ${{ inputs.dir }}
key: ${{ inputs.key }}

View file

@ -138,14 +138,12 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# Without this, setup-python's default glob resolves to the repo's only
# requirements.txt, unsloth/kernels/moe/requirements.txt: five lines that have
# nothing to do with what this job installs. Every job in the repo therefore
# shared one cache slot keyed on an unrelated file, so a "hit" restored a few MB
# and the install still downloaded the whole tree. Key on what this job actually
# reads instead.
cache-dependency-path: |
- name: Restore the pip cache
id: pip-cache
uses: ./.github/actions/pip-cache-restore
with:
key-files: |
pyproject.toml
studio/backend/requirements/*.txt
@ -2271,6 +2269,14 @@ jobs:
echo " - unsloth_zoo @ ${UNSLOTH_ZOO_REF} pytest tests/ (5 GPU cases deselected)"
echo " - unsloth_zoo.compiler.test_apply_fused_lm_head"
- name: Save the pip cache
if: always()
uses: ./.github/actions/pip-cache-save
with:
dir: ${{ steps.pip-cache.outputs.dir }}
key: ${{ steps.pip-cache.outputs.key }}
cache-hit: ${{ steps.pip-cache.outputs.cache-hit }}
llama-cpp-smoke:
# Standalone llama.cpp build + smoke. Earlier this lived inside every
# consolidated matrix cell and re-cmake'd llama.cpp ~5 min per cell --
@ -2300,14 +2306,12 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# Without this, setup-python's default glob resolves to the repo's only
# requirements.txt, unsloth/kernels/moe/requirements.txt: five lines that have
# nothing to do with what this job installs. Every job in the repo therefore
# shared one cache slot keyed on an unrelated file, so a "hit" restored a few MB
# and the install still downloaded the whole tree. Key on what this job actually
# reads instead.
cache-dependency-path: |
- name: Restore the pip cache
id: pip-cache
uses: ./.github/actions/pip-cache-restore
with:
key-files: |
pyproject.toml
studio/backend/requirements/*.txt
@ -2505,3 +2509,12 @@ jobs:
f"and llama-quantize at {quantizer}."
)
PY
- name: Save the pip cache
if: always()
uses: ./.github/actions/pip-cache-save
with:
dir: ${{ steps.pip-cache.outputs.dir }}
key: ${{ steps.pip-cache.outputs.key }}
cache-hit: ${{ steps.pip-cache.outputs.cache-hit }}

View file

@ -192,7 +192,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# test_installer_profile_hardening.py imports unsloth_cli.commands.studio, which pulls
# the CLI's import chain; without these the job dies on ModuleNotFoundError before a
# single test runs. rich arrives through typer today, but unsloth_cli imports it

View file

@ -54,12 +54,11 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# This job installs three linters and nothing else. Without an explicit path it
# shared one cache key with every other Python 3.12 job in the repo, and whichever
# ran first won the slot: in practice this one, leaving a 9 MB cache that Core then
# restored before downloading 3.2 GB. Its pins are inline below, so key on this file.
cache-dependency-path: .github/workflows/lint-ci.yml
# No cache. This job installs three linters and nothing else, so the ~9 MB it
# would store is not worth a slot in a budget the model downloads are competing
# for. It used to share one key with every other Python 3.12 job in the repo,
# and whichever ran first won it: in practice this one, leaving a 9 MB entry
# that Core then restored before downloading 3.2 GB anyway.
# Pin ruff to match .pre-commit-config.yaml so a CI-only ruff
# bump cannot disagree with what pre-commit accepted.

View file

@ -40,7 +40,7 @@ on:
# other ten workflows in that change it had no main-ref trigger at all, so
# main's copy of the 4.6GB GGUF could only ever be written by the Monday
# cron. That is a poor match for a 7-day unused-cache retention on a repo
# that is over the 10GB budget: once LRU evicts main's copy, every PR
# sitting at 99.3% of its 50 GiB budget: once LRU evicts main's copy, every PR
# re-downloads until the next cron repairs it. Seed it on merge instead,
# behind the same paths filter as the pull_request trigger below so this
# only runs when the guarded sources actually change.
@ -162,7 +162,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Restore GGUF model file
id: cache-gguf
@ -188,12 +187,15 @@ jobs:
# merge ref -- per GitHub's docs they "can only be restored by re-runs
# of the pull request" -- while every PR *can* restore from the default
# branch. So a PR-scoped save helps almost nothing and competes for the
# 10GB per-repo budget, and when that budget is exceeded GitHub evicts
# per-repo cache budget, and when that budget is exceeded GitHub evicts
# by least-recently-used, which deletes main's copies that all PRs share.
# This repo was measured at 33.3GB across 30 caches, 3.3x over, with the
# same 4.6GB model held four times on four different PR refs and no copy
# on main at all. That is the thrash loop: PR misses -> downloads ->
# saves its own copy -> evicts main's -> next PR misses.
# This repo's budget is 50 GiB, not GitHub's 10GB default, and it was
# measured at 49.63 GiB across 258 entries -- 99.3% full, so eviction runs
# at the margin. 20.74 GiB of that (42%) is the SAME key held on several
# refs, and every one of those keys already has a copy on main, so the
# PR-scoped duplicates are redundant by construction. That is the thrash
# loop: PR misses -> downloads -> saves its own copy -> evicts main's ->
# next PR misses.
if: always() && github.ref == 'refs/heads/main' && steps.download-gguf.outcome == 'success'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
@ -383,7 +385,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Restore GGUF model file
id: cache-gguf
@ -565,7 +566,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Restore GGUF model file
id: cache-gguf
@ -724,7 +724,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# Cross-OS shared entry. The tree under `hf-cache` is byte-identical on
# Linux, macOS and Windows, so the key carries no `runner.os`, and

View file

@ -158,7 +158,14 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Restore the pip cache
id: pip-cache
uses: ./.github/actions/pip-cache-restore
with:
key-files: |
pyproject.toml
studio/backend/requirements/*.txt
# macOS install ladder, validated locally against a Linux
# mac-sim venv (platform spoofed + mlx_simulation shim + real
@ -534,3 +541,12 @@ jobs:
exit 1
fi
echo "OK: Unsloth prebuilt llama.cpp on Mac M1 + GGUF /completion works"
- name: Save the pip cache
if: always()
uses: ./.github/actions/pip-cache-save
with:
dir: ${{ steps.pip-cache.outputs.dir }}
key: ${{ steps.pip-cache.outputs.key }}
cache-hit: ${{ steps.pip-cache.outputs.cache-hit }}

View file

@ -106,7 +106,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install validator deps
run: |
@ -215,7 +214,7 @@ jobs:
path: notebooks
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: { python-version: '3.12', cache: 'pip' }
with: { python-version: '3.12' }
- name: Install
run: pip install -U pip
- name: Diff Colab oracle vs committed snapshots (--strict on cron)
@ -280,7 +279,22 @@ jobs:
path: notebooks
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: { python-version: '3.12', cache: 'pip' }
# This job pins its dependencies inline below rather than in a requirements
# file, so the workflow IS the dependency spec and hashing it is what makes the
# key describe the payload. Unscoped, setup-python hashes dependency files
# repo-wide, so one unrelated edit invalidates ~700MB per interpreter.
with:
python-version: '3.12'
- name: Restore the pip cache
id: pip-cache
# ./ resolves from GITHUB_WORKSPACE, and this job checks the repo out
# under `unsloth/`, so the unprefixed path is a directory that does not
# exist and the step fails with "Can't find 'action.yml'".
uses: ./unsloth/.github/actions/pip-cache-restore
with:
key-files: |
unsloth/.github/workflows/notebooks-ci.yml
- name: Install CPU torch + pinned unsloth + trl + converter deps
run: |
@ -354,6 +368,17 @@ jobs:
--converted-dir _converted \
--surface _api_surface.json
- name: Save the pip cache
if: always()
# ./ resolves from GITHUB_WORKSPACE, and this job checks the repo out
# under `unsloth/`, so the unprefixed path is a directory that does not
# exist and the step fails with "Can't find 'action.yml'".
uses: ./unsloth/.github/actions/pip-cache-save
with:
dir: ${{ steps.pip-cache.outputs.dir }}
key: ${{ steps.pip-cache.outputs.key }}
cache-hit: ${{ steps.pip-cache.outputs.cache-hit }}
smoke-install:
name: smoke install (Colab-shaped venv, opt-in)
if: ${{ github.event.inputs.include_smoke == 'true' || github.event_name == 'schedule' }}

View file

@ -184,7 +184,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@ -196,7 +195,7 @@ jobs:
with:
# Save only on main. This action defaults to saving on every ref, and
# a PR-scoped rust cache (550-840MB here) can only be restored by
# re-runs of that same PR while still counting against the 10GB
# re-runs of that same PR while still counting against the 50 GiB
# per-repo budget, evicting main's copy that every PR can restore.
save-if: ${{ github.ref == 'refs/heads/main' }}
workspaces: studio/src-tauri -> target
@ -827,7 +826,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install scan_packages.py runtime deps
# scan_packages.py imports requests + packaging at runtime to

View file

@ -79,7 +79,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# Cross-OS shared entry. The tree under `hf-cache` is byte-identical on
# Linux, macOS and Windows, so the key carries no `runner.os`, and
@ -112,12 +111,15 @@ jobs:
# merge ref -- per GitHub's docs they "can only be restored by re-runs
# of the pull request" -- while every PR *can* restore from the default
# branch. So a PR-scoped save helps almost nothing and competes for the
# 10GB per-repo budget, and when that budget is exceeded GitHub evicts
# per-repo cache budget, and when that budget is exceeded GitHub evicts
# by least-recently-used, which deletes main's copies that all PRs share.
# This repo was measured at 33.3GB across 30 caches, 3.3x over, with the
# same 4.6GB model held four times on four different PR refs and no copy
# on main at all. That is the thrash loop: PR misses -> downloads ->
# saves its own copy -> evicts main's -> next PR misses.
# This repo's budget is 50 GiB, not GitHub's 10GB default, and it was
# measured at 49.63 GiB across 258 entries -- 99.3% full, so eviction runs
# at the margin. 20.74 GiB of that (42%) is the SAME key held on several
# refs, and every one of those keys already has a copy on main, so the
# PR-scoped duplicates are redundant by construction. That is the thrash
# loop: PR misses -> downloads -> saves its own copy -> evicts main's ->
# next PR misses.
if: always() && github.ref == 'refs/heads/main' && steps.prime-hf.outcome == 'success' && hashFiles('hf-cache/**/*.gguf') != ''
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:

View file

@ -146,8 +146,12 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '${{ matrix.python }}'
cache: 'pip'
cache-dependency-path: |
- name: Restore the pip cache
id: pip-cache
uses: ./.github/actions/pip-cache-restore
with:
key-files: |
pyproject.toml
studio/backend/requirements/*.txt
@ -291,6 +295,14 @@ jobs:
tests/test_recommended_folders_permission.py \
tests/test_hf_cache_settings.py
- name: Save the pip cache
if: always()
uses: ./.github/actions/pip-cache-save
with:
dir: ${{ steps.pip-cache.outputs.dir }}
key: ${{ steps.pip-cache.outputs.key }}
cache-hit: ${{ steps.pip-cache.outputs.cache-hit }}
repo-cpu-tests:
# Auto-discover everything under tests/ that is not GPU-bound by
# design. New tests added in covered directories are picked up
@ -311,8 +323,12 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: |
- name: Restore the pip cache
id: pip-cache
uses: ./.github/actions/pip-cache-restore
with:
key-files: |
pyproject.toml
studio/backend/requirements/*.txt
@ -477,3 +493,11 @@ jobs:
[ "$found" -gt 0 ] || { echo "::error::no shell tests discovered under tests/sh"; exit 1; }
echo "ran $found shell installer test files"
- name: Save the pip cache
if: always()
uses: ./.github/actions/pip-cache-save
with:
dir: ${{ steps.pip-cache.outputs.dir }}
key: ${{ steps.pip-cache.outputs.key }}
cache-hit: ${{ steps.pip-cache.outputs.cache-hit }}

View file

@ -60,9 +60,20 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
# This job pins its dependencies inline below rather than in a requirements
# file, so the workflow IS the dependency spec and hashing it is what makes the
# key describe the payload. Unscoped, setup-python hashes dependency files
# repo-wide, so one unrelated edit invalidates ~700MB per interpreter.
with:
python-version: '3.12'
cache: 'pip'
- name: Restore the pip cache
id: pip-cache
uses: ./.github/actions/pip-cache-restore
with:
key-files: |
.github/workflows/studio-export-capability-ci.yml
- name: Upgrade pip
run: python -m pip install --upgrade pip
- name: Install CPU PyTorch
@ -79,3 +90,12 @@ jobs:
- name: Export capability + import-safety tests
working-directory: studio/backend
run: python -m pytest tests/test_export_capability.py -q
- name: Save the pip cache
if: always()
uses: ./.github/actions/pip-cache-save
with:
dir: ${{ steps.pip-cache.outputs.dir }}
key: ${{ steps.pip-cache.outputs.key }}
cache-hit: ${{ steps.pip-cache.outputs.cache-hit }}

View file

@ -152,7 +152,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# Cross-OS shared entry. The tree under `hf-cache` is byte-identical on
# Linux, macOS and Windows, so the key carries no `runner.os`, and
@ -224,12 +223,15 @@ jobs:
# merge ref -- per GitHub's docs they "can only be restored by re-runs
# of the pull request" -- while every PR *can* restore from the default
# branch. So a PR-scoped save helps almost nothing and competes for the
# 10GB per-repo budget, and when that budget is exceeded GitHub evicts
# per-repo cache budget, and when that budget is exceeded GitHub evicts
# by least-recently-used, which deletes main's copies that all PRs share.
# This repo was measured at 33.3GB across 30 caches, 3.3x over, with the
# same 4.6GB model held four times on four different PR refs and no copy
# on main at all. That is the thrash loop: PR misses -> downloads ->
# saves its own copy -> evicts main's -> next PR misses.
# This repo's budget is 50 GiB, not GitHub's 10GB default, and it was
# measured at 49.63 GiB across 258 entries -- 99.3% full, so eviction runs
# at the margin. 20.74 GiB of that (42%) is the SAME key held on several
# refs, and every one of those keys already has a copy on main, so the
# PR-scoped duplicates are redundant by construction. That is the thrash
# loop: PR misses -> downloads -> saves its own copy -> evicts main's ->
# next PR misses.
if: always() && github.ref == 'refs/heads/main' && steps.prime-hf.outcome == 'success' && hashFiles('hf-cache/**/*.gguf') != ''
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:

View file

@ -49,7 +49,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install minimal deps (no torch, no unsloth)
# The test stubs `loggers` and `structlog`, imports
# core.inference.llama_cpp directly, and drives a small

View file

@ -170,7 +170,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# Shared preamble, but it sits below phase 1's model priming because the
@ -251,12 +250,15 @@ jobs:
# merge ref -- per GitHub's docs they "can only be restored by re-runs
# of the pull request" -- while every PR *can* restore from the default
# branch. So a PR-scoped save helps almost nothing and competes for the
# 10GB per-repo budget, and when that budget is exceeded GitHub evicts
# per-repo cache budget, and when that budget is exceeded GitHub evicts
# by least-recently-used, which deletes main's copies that all PRs share.
# This repo was measured at 33.3GB across 30 caches, 3.3x over, with the
# same 4.6GB model held four times on four different PR refs and no copy
# on main at all. That is the thrash loop: PR misses -> downloads ->
# saves its own copy -> evicts main's -> next PR misses.
# This repo's budget is 50 GiB, not GitHub's 10GB default, and it was
# measured at 49.63 GiB across 258 entries -- 99.3% full, so eviction runs
# at the margin. 20.74 GiB of that (42%) is the SAME key held on several
# refs, and every one of those keys already has a copy on main, so the
# PR-scoped duplicates are redundant by construction. That is the thrash
# loop: PR misses -> downloads -> saves its own copy -> evicts main's ->
# next PR misses.
if: always() && github.ref == 'refs/heads/main' && steps.prime-hf.outcome != 'skipped' && hashFiles('hf-cache/**/*.gguf') != ''
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
timeout-minutes: 15

View file

@ -131,7 +131,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# Cross-OS shared entry. The tree under `hf-cache` is byte-identical on
# Linux, macOS and Windows, so the key carries no `runner.os`, and
@ -170,12 +169,15 @@ jobs:
# merge ref -- per GitHub's docs they "can only be restored by re-runs
# of the pull request" -- while every PR *can* restore from the default
# branch. So a PR-scoped save helps almost nothing and competes for the
# 10GB per-repo budget, and when that budget is exceeded GitHub evicts
# per-repo cache budget, and when that budget is exceeded GitHub evicts
# by least-recently-used, which deletes main's copies that all PRs share.
# This repo was measured at 33.3GB across 30 caches, 3.3x over, with the
# same 4.6GB model held four times on four different PR refs and no copy
# on main at all. That is the thrash loop: PR misses -> downloads ->
# saves its own copy -> evicts main's -> next PR misses.
# This repo's budget is 50 GiB, not GitHub's 10GB default, and it was
# measured at 49.63 GiB across 258 entries -- 99.3% full, so eviction runs
# at the margin. 20.74 GiB of that (42%) is the SAME key held on several
# refs, and every one of those keys already has a copy on main, so the
# PR-scoped duplicates are redundant by construction. That is the thrash
# loop: PR misses -> downloads -> saves its own copy -> evicts main's ->
# next PR misses.
if: always() && github.ref == 'refs/heads/main' && steps.prime-hf.outcome == 'success' && hashFiles('hf-cache/**/*.gguf') != ''
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:

View file

@ -93,7 +93,7 @@ jobs:
with:
# Save only on main. This action defaults to saving on every ref, and
# a PR-scoped rust cache (550-840MB here) can only be restored by
# re-runs of that same PR while still counting against the 10GB
# re-runs of that same PR while still counting against the 50 GiB
# per-repo budget, evicting main's copy that every PR can restore.
save-if: ${{ github.ref == 'refs/heads/main' }}
workspaces: studio/src-tauri -> target
@ -320,7 +320,7 @@ jobs:
with:
# Save only on main. This action defaults to saving on every ref, and
# a PR-scoped rust cache (550-840MB here) can only be restored by
# re-runs of that same PR while still counting against the 10GB
# re-runs of that same PR while still counting against the 50 GiB
# per-repo budget, evicting main's copy that every PR can restore.
save-if: ${{ github.ref == 'refs/heads/main' }}
workspaces: studio/src-tauri -> target
@ -376,7 +376,7 @@ jobs:
with:
# Save only on main. This action defaults to saving on every ref, and
# a PR-scoped rust cache (550-840MB here) can only be restored by
# re-runs of that same PR while still counting against the 10GB
# re-runs of that same PR while still counting against the 50 GiB
# per-repo budget, evicting main's copy that every PR can restore.
save-if: ${{ github.ref == 'refs/heads/main' }}
workspaces: studio/src-tauri -> target

View file

@ -120,7 +120,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# Static reads, so it runs first and fails fast; nothing else runs it.
- name: Playwright locator contract (static)
@ -158,12 +157,15 @@ jobs:
# merge ref -- per GitHub's docs they "can only be restored by re-runs
# of the pull request" -- while every PR *can* restore from the default
# branch. So a PR-scoped save helps almost nothing and competes for the
# 10GB per-repo budget, and when that budget is exceeded GitHub evicts
# per-repo cache budget, and when that budget is exceeded GitHub evicts
# by least-recently-used, which deletes main's copies that all PRs share.
# This repo was measured at 33.3GB across 30 caches, 3.3x over, with the
# same 4.6GB model held four times on four different PR refs and no copy
# on main at all. That is the thrash loop: PR misses -> downloads ->
# saves its own copy -> evicts main's -> next PR misses.
# This repo's budget is 50 GiB, not GitHub's 10GB default, and it was
# measured at 49.63 GiB across 258 entries -- 99.3% full, so eviction runs
# at the margin. 20.74 GiB of that (42%) is the SAME key held on several
# refs, and every one of those keys already has a copy on main, so the
# PR-scoped duplicates are redundant by construction. That is the thrash
# loop: PR misses -> downloads -> saves its own copy -> evicts main's ->
# next PR misses.
if: always() && github.ref == 'refs/heads/main' && steps.prime-hf.outcome == 'success' && hashFiles('hf-cache/**/*.gguf') != ''
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
@ -193,13 +195,14 @@ jobs:
# a cache holding the older engines.
- name: Restore the Playwright browser cache
id: pw-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
continue-on-error: true
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-cfw-v1
- name: Install Playwright browsers
id: pw-install
# --with-deps stays unconditional: the apt side installs system libraries OUTSIDE
# the cached directory, so a restored cache without it gives browsers that cannot
# start. Only the download is skipped, which is the part that costs.
@ -210,6 +213,34 @@ jobs:
python -m playwright install --with-deps chromium firefox webkit
fi
# Save on main only, like every model cache in this repo. read-write
# `actions/cache` saves from its post-step on EVERY ref, and a PR-scoped entry
# can only be restored by re-runs of that same PR while still competing for the
# 50 GiB repo budget, measured 99.3% full. At ~470MB per browser set that is how
# main's copy -- the one every PR can actually read -- gets evicted, so the next
# PR misses and writes its own. This key was measured holding 3 copies for 0.91
# GiB of pure duplicate. Same thrash loop the GGUF caches were fixed for.
# Gated on the install SUCCEEDING, not merely having run. `playwright install`
# downloads three engines from a CDN; a network failure part-way leaves some of
# them on disk, and under a bare always() that partial directory is what gets
# saved. The key is immutable (version-pinned), so every later run then restores
# it, reports cache-hit, skips the download and runs only install-deps -- with
# engines that are not there. Every UI job fails until someone deletes the entry
# by hand, and nothing in the logs points at the cache.
#
# always() stays, so an unrelated earlier failure does not throw away browsers
# that did download; the outcome check is what narrows it to this step.
- name: Save the Playwright browser cache
if: >-
always() && github.ref == 'refs/heads/main'
&& steps.pw-cache.outputs.cache-hit != 'true'
&& steps.pw-install.outcome == 'success'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
continue-on-error: true
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-cfw-v1
- name: Reset auth + boot Unsloth
if: matrix.shard == 'chat'
run: |
@ -581,7 +612,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install Unsloth (--local, --no-torch)
uses: ./.github/actions/install-unsloth-local
@ -605,13 +635,14 @@ jobs:
# a cache holding the older engines.
- name: Restore the Playwright browser cache
id: pw-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
continue-on-error: true
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-cfw-v1
- name: Install Playwright browsers
id: pw-install
# --with-deps stays unconditional: the apt side installs system libraries OUTSIDE
# the cached directory, so a restored cache without it gives browsers that cannot
# start. Only the download is skipped, which is the part that costs.
@ -622,6 +653,34 @@ jobs:
python -m playwright install --with-deps chromium firefox webkit
fi
# Save on main only, like every model cache in this repo. read-write
# `actions/cache` saves from its post-step on EVERY ref, and a PR-scoped entry
# can only be restored by re-runs of that same PR while still competing for the
# 50 GiB repo budget, measured 99.3% full. At ~470MB per browser set that is how
# main's copy -- the one every PR can actually read -- gets evicted, so the next
# PR misses and writes its own. This key was measured holding 3 copies for 0.91
# GiB of pure duplicate. Same thrash loop the GGUF caches were fixed for.
# Gated on the install SUCCEEDING, not merely having run. `playwright install`
# downloads three engines from a CDN; a network failure part-way leaves some of
# them on disk, and under a bare always() that partial directory is what gets
# saved. The key is immutable (version-pinned), so every later run then restores
# it, reports cache-hit, skips the download and runs only install-deps -- with
# engines that are not there. Every UI job fails until someone deletes the entry
# by hand, and nothing in the logs points at the cache.
#
# always() stays, so an unrelated earlier failure does not throw away browsers
# that did download; the outcome check is what narrows it to this step.
- name: Save the Playwright browser cache
if: >-
always() && github.ref == 'refs/heads/main'
&& steps.pw-cache.outputs.cache-hit != 'true'
&& steps.pw-install.outcome == 'success'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
continue-on-error: true
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-cfw-v1
- name: Cross-browser loaded-models indicator
# The three engines are independent runs of the same suite, and running
# them one after another was ~870s of this job's ~1000s -- the largest

View file

@ -102,12 +102,15 @@ jobs:
# merge ref -- per GitHub's docs they "can only be restored by re-runs
# of the pull request" -- while every PR *can* restore from the default
# branch. So a PR-scoped save helps almost nothing and competes for the
# 10GB per-repo budget, and when that budget is exceeded GitHub evicts
# per-repo cache budget, and when that budget is exceeded GitHub evicts
# by least-recently-used, which deletes main's copies that all PRs share.
# This repo was measured at 33.3GB across 30 caches, 3.3x over, with the
# same 4.6GB model held four times on four different PR refs and no copy
# on main at all. That is the thrash loop: PR misses -> downloads ->
# saves its own copy -> evicts main's -> next PR misses.
# This repo's budget is 50 GiB, not GitHub's 10GB default, and it was
# measured at 49.63 GiB across 258 entries -- 99.3% full, so eviction runs
# at the margin. 20.74 GiB of that (42%) is the SAME key held on several
# refs, and every one of those keys already has a copy on main, so the
# PR-scoped duplicates are redundant by construction. That is the thrash
# loop: PR misses -> downloads -> saves its own copy -> evicts main's ->
# next PR misses.
if: always() && github.ref == 'refs/heads/main' && steps.prime-hf.outcome == 'success' && hashFiles('hf-cache/**/*.gguf') != ''
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:

View file

@ -337,12 +337,15 @@ jobs:
# merge ref -- per GitHub's docs they "can only be restored by re-runs
# of the pull request" -- while every PR *can* restore from the default
# branch. So a PR-scoped save helps almost nothing and competes for the
# 10GB per-repo budget, and when that budget is exceeded GitHub evicts
# per-repo cache budget, and when that budget is exceeded GitHub evicts
# by least-recently-used, which deletes main's copies that all PRs share.
# This repo was measured at 33.3GB across 30 caches, 3.3x over, with the
# same 4.6GB model held four times on four different PR refs and no copy
# on main at all. That is the thrash loop: PR misses -> downloads ->
# saves its own copy -> evicts main's -> next PR misses.
# This repo's budget is 50 GiB, not GitHub's 10GB default, and it was
# measured at 49.63 GiB across 258 entries -- 99.3% full, so eviction runs
# at the margin. 20.74 GiB of that (42%) is the SAME key held on several
# refs, and every one of those keys already has a copy on main, so the
# PR-scoped duplicates are redundant by construction. That is the thrash
# loop: PR misses -> downloads -> saves its own copy -> evicts main's ->
# next PR misses.
if: always() && github.ref == 'refs/heads/main' && steps.prime-hf.outcome == 'success' && hashFiles('hf-cache/**/*.gguf') != ''
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:

View file

@ -119,12 +119,15 @@ jobs:
# merge ref -- per GitHub's docs they "can only be restored by re-runs
# of the pull request" -- while every PR *can* restore from the default
# branch. So a PR-scoped save helps almost nothing and competes for the
# 10GB per-repo budget, and when that budget is exceeded GitHub evicts
# per-repo cache budget, and when that budget is exceeded GitHub evicts
# by least-recently-used, which deletes main's copies that all PRs share.
# This repo was measured at 33.3GB across 30 caches, 3.3x over, with the
# same 4.6GB model held four times on four different PR refs and no copy
# on main at all. That is the thrash loop: PR misses -> downloads ->
# saves its own copy -> evicts main's -> next PR misses.
# This repo's budget is 50 GiB, not GitHub's 10GB default, and it was
# measured at 49.63 GiB across 258 entries -- 99.3% full, so eviction runs
# at the margin. 20.74 GiB of that (42%) is the SAME key held on several
# refs, and every one of those keys already has a copy on main, so the
# PR-scoped duplicates are redundant by construction. That is the thrash
# loop: PR misses -> downloads -> saves its own copy -> evicts main's ->
# next PR misses.
if: always() && github.ref == 'refs/heads/main' && steps.prime-hf.outcome == 'success' && hashFiles('hf-cache/**/*.gguf') != ''
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:

View file

@ -67,7 +67,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install pytest only
# The test fetches from raw.githubusercontent.com and greps
# source. No pip install of vllm / torch / transformers is
@ -94,7 +93,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install pytest only
run: |
python -m pip install --upgrade pip
@ -120,7 +118,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
# packaging as well as pytest: the backfill suite loads unsloth/import_fixes.py by
# file path, and that module imports packaging.version at the top. It pulls in
# nothing heavier, which is why it can run in this dependency-free job at all.
@ -149,7 +146,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install pytest only
run: |
python -m pip install --upgrade pip
@ -173,7 +169,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install pytest only
run: |
python -m pip install --upgrade pip
@ -197,7 +192,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install pytest only
run: |
python -m pip install --upgrade pip
@ -243,9 +237,23 @@ jobs:
sleep "$delay"
done
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
# This job pins its dependencies inline below rather than in a requirements
# file, so the workflow IS the dependency spec and hashing it is what makes the
# key describe the payload. Unscoped, setup-python hashes dependency files
# repo-wide, so one unrelated edit invalidates ~700MB per interpreter.
with:
python-version: '3.12'
cache: 'pip'
- name: Restore the pip cache
id: pip-cache
# ./ resolves from GITHUB_WORKSPACE, and this job checks the repo out
# under `unsloth/`, so the unprefixed path is a directory that does not
# exist and the step fails with "Can't find 'action.yml'".
uses: ./unsloth/.github/actions/pip-cache-restore
with:
key-files: |
unsloth/.github/workflows/version-compat-ci.yml
- name: Install CPU torch + supported pkg pins
run: |
python -m pip install --upgrade pip
@ -293,6 +301,17 @@ jobs:
tests/vllm_compat/test_extended_module_imports.py \
-v --tb=short
- name: Save the pip cache
if: always()
# ./ resolves from GITHUB_WORKSPACE, and this job checks the repo out
# under `unsloth/`, so the unprefixed path is a directory that does not
# exist and the step fails with "Can't find 'action.yml'".
uses: ./unsloth/.github/actions/pip-cache-save
with:
dir: ${{ steps.pip-cache.outputs.dir }}
key: ${{ steps.pip-cache.outputs.key }}
cache-hit: ${{ steps.pip-cache.outputs.cache-hit }}
# Fake-CUDA GRPO/SFT/DPO patch run against REAL TRL (latest + main). Unlike
# the static symbol/source greps above, this drives unsloth's actual
# source-transform patchers (models/rl.py + rl_replacements.py) on a CPU-only
@ -326,9 +345,23 @@ jobs:
sleep "$delay"
done
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
# This job pins its dependencies inline below rather than in a requirements
# file, so the workflow IS the dependency spec and hashing it is what makes the
# key describe the payload. Unscoped, setup-python hashes dependency files
# repo-wide, so one unrelated edit invalidates ~700MB per interpreter.
with:
python-version: '3.12'
cache: 'pip'
- name: Restore the pip cache
id: pip-cache
# ./ resolves from GITHUB_WORKSPACE, and this job checks the repo out
# under `unsloth/`, so the unprefixed path is a directory that does not
# exist and the step fails with "Can't find 'action.yml'".
uses: ./unsloth/.github/actions/pip-cache-restore
with:
key-files: |
unsloth/.github/workflows/version-compat-ci.yml
- name: Install CPU torch + ecosystem + TRL latest
run: |
python -m pip install --upgrade pip
@ -383,6 +416,17 @@ jobs:
tests/version_compat/test_trl_loss_normalization_contract.py \
-v --tb=short
- name: Save the pip cache
if: always()
# ./ resolves from GITHUB_WORKSPACE, and this job checks the repo out
# under `unsloth/`, so the unprefixed path is a directory that does not
# exist and the step fails with "Can't find 'action.yml'".
uses: ./unsloth/.github/actions/pip-cache-save
with:
dir: ${{ steps.pip-cache.outputs.dir }}
key: ${{ steps.pip-cache.outputs.key }}
cache-hit: ${{ steps.pip-cache.outputs.cache-hit }}
# Daily-only: same suites but with --strict on importable upstream
# tags. Schedule-only so PR jobs stay fast; cron tolerates a flake.
daily-fresh-fetch:
@ -397,7 +441,6 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install pytest
run: pip install 'pytest>=8'
- name: Run all version-compat suites in one process (no cache)

View file

@ -128,6 +128,16 @@ jobs:
- name: The host-offload opt-out is still macOS-only
run: python3 -m pytest tests/studio/test_mac_host_offload_optin.py -q
# Same reason again. This guard reads every workflow's cache steps, so the change
# that breaks it -- reintroducing an unscoped pip cache, saving on a PR ref, or
# pointing a cache-dependency-path at a path the job never checked out -- is by
# definition a workflow-only edit. Repo tests (CPU) does collect the file, but
# studio-backend-ci.yml's paths filter does not match a workflow-only PR, so that
# job never starts for the change this exists to reject. Collection is not coverage
# if nothing triggers the workflow that collects it.
- name: Cache discipline still holds across the workflows
run: python3 -m pytest tests/studio/test_cache_budget_discipline.py -q
# Same reason again, and this one guards THIS kind of file specifically. The test
# asserts that no macOS workflow has an unfiltered push trigger, and that each
# trigger lists the helpers its workflow executes. Every way it can regress is a

View file

@ -0,0 +1,593 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""No workflow may spend the shared Actions cache budget carelessly.
This repo's Actions cache budget is 50 GiB (not GitHub's 10 GB default), and GitHub evicts
least-recently-used once it is exceeded. Measured before this file existed, unslothai/unsloth
held **49.63 GiB across 258 entries -- 99.3% full**, so eviction runs at the margin and every
new entry displaces an existing one. What fills it is almost entirely redundancy:
20.74 GiB duplicate waste: the SAME key held on several refs (42% of the cache)
6.67 GiB 13 copies setup-python ... python-3.13.15-pip-85e247d7...
6.50 GiB 11 copies setup-python ... python-3.11.15-pip-85e247d7...
3.83 GiB 10 copies setup-python ... python-3.12.13-pip-85e247d7...
0.91 GiB 3 copies ms-playwright-Linux-1.62.0-cfw-v1
10.70 GiB 84 entries written and never read again, 10.60 GiB of it setup-python
24.01 GiB 198 entries on PR refs, restorable only by re-runs of that same PR
0.00 GiB entries unread for 7+ days -- nothing is idle, the cache is churning
Every one of those duplicated keys already has a copy on `main`, which every PR can restore
from. The PR-scoped copies are therefore redundant by construction: they buy no hit rate and
evict the copy that does.
That is a self-reinforcing loop, and the repo had already diagnosed it once for the GGUF
caches (see the save step in studio-inference-smoke.yml: "PR misses -> downloads -> saves its
own copy -> evicts main's -> next PR misses"). It reappeared through two doors this file now
closes.
Both failure modes are silent. Nothing goes red when a cache is evicted; CI just quietly
re-downloads a 4.6 GB model and everyone assumes that is what it costs.
Door 1 -- saving on a PR ref. `actions/cache` (the read-write form) saves from its post-step
on every ref. A PR-scoped entry can never be read by anyone except re-runs of that same PR,
yet it competes for the budget against main's copy, which every PR *can* read. Saves belong
on main only, via `actions/cache/restore` plus a `github.ref == 'refs/heads/main'` save.
Door 2 -- `cache: 'pip'` on a job that installs almost nothing. `actions/setup-python`
derives one pip key per interpreter from dependency files across the whole repo, so dozens of
unrelated jobs share it and race to save under it. The entries measured 666-715 MB, and the
four interpreter keys between them account for 19.44 GiB of the 20.74 GiB of duplicate waste.
A job that only pip-installs `huggingface_hub` or `pytest` was paying that for the 0-7s its
restore step took. Jobs that really do install torch/transformers keep the cache; the rest do
not.
"""
import re
from pathlib import Path
import pytest
import yaml
REPO = Path(__file__).resolve().parents[2]
WORKFLOWS = REPO / ".github" / "workflows"
# Jobs whose pip cache earns its place: they install a torch/transformers-class dependency
# set, where the download genuinely dominates. Anything not listed here must not ask for it.
#
# These nine no longer use setup-python's built-in `cache: 'pip'`. That form is read-write
# and saves from its post-step on whatever ref the job ran on, with no knob to gate it, so
# every PR wrote a ~700MB entry only its own re-runs could ever restore while evicting the
# copy on main that all PRs share. Measured at 19.45 GiB across 40 entries, 15.49 GiB of it
# on PR refs. They use the pip-cache-restore / pip-cache-save action pair instead, which
# splits the halves so the save can be gated on the default branch.
PIP_CACHE_JOBS = {
("consolidated-tests-ci.yml", "consolidated"),
("consolidated-tests-ci.yml", "llama-cpp-smoke"),
("mlx-ci.yml", "dispatch"),
("notebooks-ci.yml", "api-introspect"),
("studio-backend-ci.yml", "pytest"),
("studio-backend-ci.yml", "repo-cpu-tests"),
("studio-export-capability-ci.yml", "capability"),
("version-compat-ci.yml", "zoo-imports-under-spoof"),
("version-compat-ci.yml", "grpo-fake-run"),
}
HEAVY = re.compile(
r"torch|transformers|trl|peft|vllm|bitsandbytes|sentence-transformers|diffusers"
r"|accelerate|datasets|requirements/"
)
def _workflows():
for f in sorted(WORKFLOWS.glob("*.yml")):
try:
doc = yaml.safe_load(f.read_text(encoding = "utf-8"))
except yaml.YAMLError as exc: # a broken workflow is another test's problem
pytest.fail(f"{f.name} does not parse: {exc}")
if isinstance(doc, dict) and isinstance(doc.get("jobs"), dict):
yield f.name, doc
def _jobs():
for name, doc in _workflows():
for jid, job in doc["jobs"].items():
if isinstance(job, dict):
yield name, jid, job
def _or_alternatives(expr: str) -> list[str]:
"""``expr`` split on its TOP-LEVEL ``||``, ignoring ``||`` inside parens or quotes."""
parts, depth, quote, buf, i = [], 0, "", [], 0
while i < len(expr):
ch = expr[i]
if quote:
if ch == quote:
quote = ""
buf.append(ch)
elif ch in "'\"":
quote = ch
buf.append(ch)
elif ch == "(":
depth += 1
buf.append(ch)
elif ch == ")":
depth -= 1
buf.append(ch)
elif ch == "|" and depth == 0 and expr[i : i + 2] == "||":
parts.append("".join(buf))
buf = []
i += 2
continue
else:
buf.append(ch)
i += 1
parts.append("".join(buf))
return parts
# A POSITIVE equality against main, in either quote style. `!=` must not match: an
# expression restricting a save to everything EXCEPT main is the exact inversion of the
# rule, and a substring search for "refs/heads/main" accepts it.
_MAIN_ONLY = re.compile(r"github\.ref\s*==\s*['\"]refs/heads/main['\"]")
def _restricted_to_main(expr: str) -> bool:
"""Whether ``expr`` can only be true on ``refs/heads/main``.
Every alternative of a top-level `||` has to carry the main check, because `||` is how
a condition GAINS refs: `github.ref == 'refs/heads/main' || github.event_name ==
'pull_request'` mentions main and runs on every PR. Requiring the check in each
alternative is conservative -- it rejects some conditions that happen to be safe -- and
that is the right direction for a guard whose failure mode is a silently refilled cache.
Structural rather than a substring test because three shapes all contain the literal
"refs/heads/main" while permitting PR saves: a `!=` comparison, an `||` that admits
another event, and the check appearing only inside one branch of one.
"""
alternatives = _or_alternatives(expr)
return bool(expr.strip()) and all(_MAIN_ONLY.search(a) for a in alternatives)
@pytest.mark.parametrize(
"expr,restricted",
[
("always() && github.ref == 'refs/heads/main'", True),
('always() && github.ref == "refs/heads/main"', True),
("github.ref == 'refs/heads/main' && steps.x.outcome == 'success'", True),
# The three shapes a substring test accepts and should not.
("github.ref != 'refs/heads/main'", False),
("github.ref == 'refs/heads/main' || github.event_name == 'pull_request'", False),
("(github.ref == 'refs/heads/main' && always()) || github.event_name == 'push'", False),
# Both alternatives restricted is still restricted.
(
"(github.ref == 'refs/heads/main' && always()) || "
"(github.ref == 'refs/heads/main' && failure())",
True,
),
# A `||` inside a string or parenthesised sub-expression is not a top-level split.
("github.ref == 'refs/heads/main' && contains(x, 'a||b')", True),
("", False),
],
)
def test_the_main_only_expression_check_reads_the_expression(expr, restricted):
"""The guard below is only as good as this predicate, so the predicate is tested too."""
assert _restricted_to_main(expr) is restricted, expr
def _composite_actions():
"""(name, steps) for every composite action in the repo.
Scanned because the pip cache save now lives in one. A guard that reads only workflow
steps would have gone blind to it the moment the logic was factored out, which is the
failure mode where a rule quietly stops applying to the thing it was written for.
"""
for f in sorted((REPO / ".github" / "actions").rglob("action.yml")):
doc = yaml.safe_load(f.read_text(encoding = "utf-8"))
if isinstance(doc, dict):
yield f.parent.name, ((doc.get("runs") or {}).get("steps") or [])
def test_no_workflow_saves_a_cache_on_a_pull_request_ref():
offenders = []
for name, steps in _composite_actions():
for step in steps:
uses = str(step.get("uses", ""))
if "actions/cache" not in uses or "/restore@" in uses:
continue
if "refs/heads/main" not in str(step.get("if", "")):
offenders.append(f"action {name}: {step.get('name') or uses}")
for name, jid, job in _jobs():
for step in job.get("steps") or []:
uses = str(step.get("uses", ""))
# setup-python's `cache:` is a save too, and an invisible one: the action
# registers a post-step (`post: dist/cache-save/index.js` in its own
# action.yml) that runs after the job on whatever ref it ran on, with no
# condition to gate it. A scan that only looked for `actions/cache` steps
# read as green while nine jobs wrote PR-scoped entries every run. Nothing
# is exempt now that all nine are converted.
if "setup-python" in uses and (step.get("with") or {}).get("cache"):
offenders.append(f"{name}:{jid}: setup-python implicit post-step save")
continue
if "actions/cache" not in uses:
continue
saves = "/restore@" not in uses # read-write and /save@ both write
if not saves:
continue
if not _restricted_to_main(str(step.get("if", ""))):
offenders.append(f"{name}:{jid}: {step.get('name') or uses}")
assert not offenders, (
"these steps save a cache on whatever ref they run on, so every PR writes its own "
"copy and evicts the copy on main that all PRs share:\n " + "\n ".join(offenders)
)
def test_no_job_uses_setup_pythons_built_in_pip_cache():
"""The built-in cache cannot be gated, so it is not used here at all any more.
`actions/setup-python`'s `cache: 'pip'` is the read-write form: it restores in the step
and saves from its own post-step, on whatever ref the job ran on, and exposes no
condition to stop that. A PR-ref entry is restorable only by re-runs of that same PR, so
it buys no hit rate while competing for the shared 50 GiB budget against main's copy,
which every PR can read. Use pip-cache-restore plus pip-cache-save instead.
"""
offenders = [
f"{name}:{jid}"
for name, jid, job in _jobs()
for step in job.get("steps") or []
if "setup-python" in str(step.get("uses", "")) and (step.get("with") or {}).get("cache")
]
assert not offenders, (
f"these jobs use setup-python's built-in cache, which saves on every ref with no "
f"way to gate it: {offenders}. Swap to the pip-cache-restore / pip-cache-save pair."
)
def _pip_cache_users():
"""Every job that touches either half of the pip-cache action pair, discovered."""
return {
(name, jid)
for name, jid, job in _jobs()
for step in job.get("steps") or []
if "pip-cache-restore" in str(step.get("uses", ""))
or "pip-cache-save" in str(step.get("uses", ""))
}
def test_only_the_allowlisted_jobs_use_the_pip_cache_actions():
"""The allowlist has to be enforced against what the workflows DO, not iterated over.
Every other check in this file is parametrized over PIP_CACHE_JOBS, which means a new
job that adds the restore/save pair is simply never visited: it gets a ~700MB entry
with no scoping check, no wiring check and no justification, and this file stays green.
That hole opened when the built-in `cache: 'pip'` went away. The previous guard
discovered claimants by scanning for setup-python's `cache:` key, so replacing that
mechanism removed the discovery along with it, leaving nine hardcoded names and nothing
watching for a tenth.
"""
extra = _pip_cache_users() - PIP_CACHE_JOBS
assert not extra, (
f"these jobs use the pip cache without being listed in PIP_CACHE_JOBS: "
f"{sorted(extra)}. Every entry competes for the shared 50 GiB budget, so a job "
f"earns one by installing a torch/transformers-class dependency set where the "
f"download dominates. Add it to the allowlist with that justification, or drop the "
f"cache."
)
def test_every_pip_cache_user_actually_installs_something_heavy():
"""The allowlist records a judgement; this checks the judgement still matches the job.
A job whose heavy install is later moved elsewhere keeps its cache entry, and nothing
else in this file would notice: the name stays in the list and every parametrized check
still passes.
"""
thin = []
for name, jid in sorted(_pip_cache_users()):
job = dict(_workflows())[name]["jobs"][jid]
body = "\n".join(
str(step.get("run", "")) + str(step.get("with", "")) for step in job.get("steps") or []
)
if not HEAVY.search(body):
thin.append(f"{name}:{jid}")
assert not thin, (
f"these jobs hold a pip cache but no longer install anything that justifies it: " f"{thin}"
)
def _pip_cache_steps(name, jid):
"""(restore step, save step) for a job, either of which may be None."""
job = dict(_workflows())[name]["jobs"][jid]
restore = save = None
for step in job.get("steps") or []:
uses = str(step.get("uses", ""))
if "pip-cache-restore" in uses:
restore = step
elif "pip-cache-save" in uses:
save = step
return restore, save
@pytest.mark.parametrize("name,jid", sorted(PIP_CACHE_JOBS))
def test_every_pip_cache_scopes_its_key_to_what_it_installs(name, jid):
"""Without scoping, the key is a hash of dependency files repo-wide.
That is the second multiplier behind the 19.45 GiB: 16 distinct keys appeared in a
week, because any requirements edit anywhere invalidates every interpreter's entry at
once and orphans the old ones. Scoping the key to the files a job actually installs
from -- or, for the jobs that pin their dependencies inline, to the workflow file that
IS the dependency spec -- keeps an unrelated edit from costing ~700MB per interpreter.
"""
restore, _ = _pip_cache_steps(name, jid)
assert restore is not None, f"{name}:{jid} no longer restores a pip cache"
files = [
l.strip()
for l in str((restore.get("with") or {}).get("key-files") or "").splitlines()
if l.strip()
]
assert files, f"{name}:{jid} passes no key-files, so the key describes nothing"
@pytest.mark.parametrize("name,jid", sorted(PIP_CACHE_JOBS))
def test_every_restored_pip_cache_is_also_saved_and_wired_to_its_restore(name, jid):
"""A restore with no save fills nothing; a save reading the wrong ids saves nothing.
Both halves are silent when wrong. The save takes the directory, the key and the
hit flag from the restore step's outputs, so a renamed or missing id yields empty
inputs and an entry that is never written, with a green job either way.
"""
restore, save = _pip_cache_steps(name, jid)
assert restore is not None and save is not None, (
f"{name}:{jid} has restore={restore is not None}, save={save is not None}; the "
f"pair has to stay together or the cache is never populated"
)
ident = restore.get("id")
assert ident, f"{name}:{jid}'s restore step has no id, so the save cannot read its outputs"
with_ = save.get("with") or {}
for field in ("dir", "key", "cache-hit"):
assert f"steps.{ident}.outputs.{field}" in str(
with_.get(field, "")
), f"{name}:{jid}'s save does not take {field} from steps.{ident}.outputs"
def test_the_pip_cache_save_action_is_gated_on_the_default_branch():
"""The one place the gate lives, now that nine call sites share it."""
doc = yaml.safe_load(
(REPO / ".github" / "actions" / "pip-cache-save" / "action.yml").read_text(encoding = "utf-8")
)
steps = (doc.get("runs") or {}).get("steps") or []
saves = [s for s in steps if "actions/cache" in str(s.get("uses", ""))]
assert saves, "pip-cache-save no longer saves anything"
for s in saves:
cond = str(s.get("if", ""))
assert "refs/heads/main" in cond, (
"the pip cache save is no longer gated on the default branch, so all nine call "
"sites went back to writing PR-scoped entries at once"
)
def _workflows_by_name():
return dict(_workflows())
@pytest.mark.parametrize("name,jid", sorted(PIP_CACHE_JOBS))
def test_every_allowed_pip_cache_job_still_exists_and_still_earns_it(name, jid):
"""The list must not outlive the jobs, or it silently permits nothing."""
doc = dict(_workflows()).get(name)
assert doc is not None, f"{name} no longer exists; drop it from PIP_CACHE_JOBS"
job = doc["jobs"].get(jid)
assert job is not None, f"{name} no longer has job {jid}; drop it from PIP_CACHE_JOBS"
body = "\n".join(str(s.get("run", "")) for s in job.get("steps") or [])
assert HEAVY.search(body), (
f"{name}:{jid} is allowed a pip cache but no longer installs anything heavy; it "
f"should give the budget back"
)
def test_the_cold_install_lanes_never_restore_a_cache():
"""These workflows exist to prove a cold install works. A warm one proves nothing.
They would still pass with a cache in front of them, which is exactly why this is
asserted rather than left to review.
"""
cold = [
"clean-machine-install-ci.yml",
"desktop-app-clean-machine-ci.yml",
"interrupted-install-ci.yml",
]
offenders = []
for name, jid, job in _jobs():
if name not in cold:
continue
for step in job.get("steps") or []:
uses = str(step.get("uses", ""))
if "actions/cache" in uses:
offenders.append(f"{name}:{jid}: {step.get('name') or uses}")
if "setup-python" in uses and (step.get("with") or {}).get("cache"):
offenders.append(f"{name}:{jid}: setup-python cache on a cold-install lane")
assert not offenders, "a cold-install lane must not be warmed by a cache:\n " + "\n ".join(
offenders
)
def test_every_setup_python_step_still_pins_an_interpreter():
"""Guards the edit that produced this file.
Removing `cache: 'pip'` from an inline-flow mapping (`with: { python-version: '3.12',
cache: 'pip' }`) by deleting the line takes the interpreter pin with it, and the job then
silently runs on whatever Python the image happens to ship.
"""
offenders = [
f"{name}:{jid}"
for name, jid, job in _jobs()
for step in job.get("steps") or []
if "setup-python" in str(step.get("uses", ""))
and not (step.get("with") or {}).get("python-version")
]
assert not offenders, f"setup-python without an explicit python-version: {offenders}"
def test_a_cache_save_of_downloaded_artifacts_waits_for_the_download_to_succeed():
"""A partial download saved under an immutable key poisons every later run.
`playwright install` fetches three engines from a CDN. If it fails part-way, the
directory still exists with some of them in it, and a save gated only on `always()`
stores that. The key is pinned to the resolved Playwright version, so it does not roll
over: every subsequent run restores the partial tree, sees `cache-hit == 'true'`, runs
only `install-deps`, and drives a browser that was never downloaded. The UI jobs fail
until somebody deletes the entry by hand, and nothing in the log says cache.
So a save step whose payload is produced by an earlier step must check that step's
outcome. `always()` on its own is the bug, not the fix.
"""
offenders = []
for name, jid, job in _jobs():
steps = job.get("steps") or []
producers = {
s.get("id")
for s in steps
if s.get("id") and re.search(r"install|download|build|prime", str(s.get("run", "")))
}
for step in steps:
uses = str(step.get("uses", ""))
if "actions/cache" not in uses or "/restore@" in uses:
continue
cond = str(step.get("if", ""))
if "always()" not in cond:
continue # not force-run, so a failed producer already skips it
if not any(f"steps.{pid}.outcome" in cond for pid in producers if pid):
offenders.append(f"{name}:{jid}: {step.get('name') or uses}")
assert not offenders, (
"these cache saves run under always() without checking that the step which "
"produced the payload succeeded, so a partial download can be stored under an "
"immutable key and served to every later run:\n " + "\n ".join(offenders)
)
def test_every_cache_key_path_resolves_where_the_job_checked_out():
"""A key-files glob that matches nothing collapses every job onto one key.
Three jobs check the repo out under `unsloth/` because they need a second repo beside
it (notebooks-ci api-introspect, version-compat-ci zoo-imports-under-spoof and
grpo-fake-run), so a path written as if the checkout were at the workspace root
resolves to nothing. Under setup-python's built-in cache that was fatal outright
("No file in ... matched to ..."); hashFiles is quieter and simply returns empty, which
is why pip-cache-restore fails loudly on an empty hash and why this stays asserted.
Each entry is resolved against the checkout it belongs to and then globbed, rather than
prefix-matched. A prefix check calls `unsloth/.github/workflows/typo.yml` correct
because it starts with `unsloth/`, and a job checked out at the workspace root was
skipped entirely, so a misspelling there was never examined at all.
"""
offenders = []
for name, jid, job in _jobs():
steps = job.get("steps") or []
# Where THIS repo lands, which is not the same question as "is there a `path:`".
# notebooks-ci api-introspect checks out two repositories side by side, and a path
# under the OTHER one cannot be resolved against this tree at all, so it is skipped
# rather than reported. A checkout with no `repository:` is this repo by definition.
own_prefixes, foreign_prefixes = [], []
for s in steps:
if "actions/checkout" not in str(s.get("uses", "")):
continue
with_ = s.get("with") or {}
prefix = str(with_.get("path") or "").strip("/")
repo = str(with_.get("repository") or "")
(foreign_prefixes if repo and not repo.endswith("/unsloth") else own_prefixes).append(
prefix
)
if not own_prefixes:
own_prefixes = [""]
for step in steps:
with_ = step.get("with") or {}
paths = with_.get("cache-dependency-path") or (
with_.get("key-files") if "pip-cache-restore" in str(step.get("uses", "")) else None
)
for line in str(paths or "").splitlines():
line = line.strip()
if not line:
continue
if any(p and line.startswith(p + "/") for p in foreign_prefixes):
continue
# The prefix has to match a checkout of this repo, AND what remains has to
# resolve to a file that exists. Checking only the prefix accepted
# `unsloth/.github/workflows/typo.yml`, which fails the job just as hard.
relative = None
for prefix in sorted(own_prefixes, key = len, reverse = True):
if not prefix:
relative = line
break
if line.startswith(prefix + "/"):
relative = line[len(prefix) + 1 :]
break
if relative is None:
offenders.append(
f"{name}:{jid}: {line!r} is workspace-root-relative, but this job "
f"checks the repo out under {own_prefixes}"
)
continue
if not list(REPO.glob(relative)):
offenders.append(
f"{name}:{jid}: {line!r} matches no file in the repo "
f"(resolved to {relative!r})"
)
assert not offenders, (
"these cache key paths are workspace-root-relative in a job that checks the repo "
"out into a subdirectory, so they match no file:\n " + "\n ".join(offenders)
)
def test_no_setup_python_step_declares_a_cache_path_without_a_cache():
"""Dead config reads as a caching decision that is not in force.
Removing `cache: 'pip'` and leaving `cache-dependency-path` behind is inert -- the
action only reads the path inside its `if (cache && isCacheFeatureAvailable())` branch
-- but the next reader sees a scoped cache key and believes the job is cached.
"""
offenders = [
f"{name}:{jid}"
for name, jid, job in _jobs()
for step in job.get("steps") or []
if "setup-python" in str(step.get("uses", ""))
and (step.get("with") or {}).get("cache-dependency-path")
and not (step.get("with") or {}).get("cache")
]
assert not offenders, (
f"these steps declare cache-dependency-path but no cache, so the key is never "
f"used and the config only misleads: {offenders}"
)
def test_local_action_references_use_the_nested_checkout_path():
"""`uses: ./...` resolves from GITHUB_WORKSPACE, not from the workflow file.
GitHub's own docs put it plainly: if the action checks the repository out to a
different location than the workflow, the relative path for a local action has to be
updated. Three jobs here check out under `unsloth/` because they need a second repo
beside it, so an unprefixed `./.github/actions/...` points at a directory that does not
exist and the step fails with "Can't find 'action.yml', 'action.yaml' or 'Dockerfile'".
Same root cause as the cache-key path check above, one level out: the key paths were
fixed for these jobs and the action paths were not.
"""
offenders = []
for name, jid, job in _jobs():
steps = job.get("steps") or []
checkout_dirs = [
str((s.get("with") or {}).get("path")).rstrip("/")
for s in steps
if "actions/checkout" in str(s.get("uses", "")) and (s.get("with") or {}).get("path")
]
if not checkout_dirs:
continue
for step in steps:
uses = str(step.get("uses", ""))
if uses.startswith("./") and not any(uses.startswith(f"./{d}/") for d in checkout_dirs):
offenders.append(f"{name}:{jid}: {uses} (checkouts: {checkout_dirs})")
assert not offenders, (
"these local action references are workspace-root-relative in a job that checks "
"the repo out into a subdirectory, so the runner cannot find the action:\n "
+ "\n ".join(offenders)
)