mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-23 07:44:06 +00:00
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:
parent
aa32c1861c
commit
54b6ca4c3f
24 changed files with 1043 additions and 106 deletions
83
.github/actions/pip-cache-restore/action.yml
vendored
Normal file
83
.github/actions/pip-cache-restore/action.yml
vendored
Normal 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 }}
|
||||
40
.github/actions/pip-cache-save/action.yml
vendored
Normal file
40
.github/actions/pip-cache-save/action.yml
vendored
Normal 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 }}
|
||||
45
.github/workflows/consolidated-tests-ci.yml
vendored
45
.github/workflows/consolidated-tests-ci.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
.github/workflows/lint-ci.yml
vendored
11
.github/workflows/lint-ci.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
19
.github/workflows/local-agent-guides-ci.yml
vendored
19
.github/workflows/local-agent-guides-ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
18
.github/workflows/mlx-ci.yml
vendored
18
.github/workflows/mlx-ci.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
31
.github/workflows/notebooks-ci.yml
vendored
31
.github/workflows/notebooks-ci.yml
vendored
|
|
@ -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' }}
|
||||
|
|
|
|||
4
.github/workflows/security-audit.yml
vendored
4
.github/workflows/security-audit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
14
.github/workflows/studio-api-smoke.yml
vendored
14
.github/workflows/studio-api-smoke.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
32
.github/workflows/studio-backend-ci.yml
vendored
32
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
14
.github/workflows/studio-inference-smoke.yml
vendored
14
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
.github/workflows/studio-mac-inference-smoke.yml
vendored
14
.github/workflows/studio-mac-inference-smoke.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
14
.github/workflows/studio-mac-ui-smoke.yml
vendored
14
.github/workflows/studio-mac-ui-smoke.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
6
.github/workflows/studio-tauri-smoke.yml
vendored
6
.github/workflows/studio-tauri-smoke.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
77
.github/workflows/studio-ui-smoke.yml
vendored
77
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
13
.github/workflows/studio-windows-api-smoke.yml
vendored
13
.github/workflows/studio-windows-api-smoke.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
13
.github/workflows/studio-windows-ui-smoke.yml
vendored
13
.github/workflows/studio-windows-ui-smoke.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
61
.github/workflows/version-compat-ci.yml
vendored
61
.github/workflows/version-compat-ci.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
10
.github/workflows/workflow-trigger-lint.yml
vendored
10
.github/workflows/workflow-trigger-lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
593
tests/studio/test_cache_budget_discipline.py
Normal file
593
tests/studio/test_cache_budget_discipline.py
Normal 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)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue