From 3f7f521c061d8c48b69c3c05da72bc1140da5444 Mon Sep 17 00:00:00 2001 From: PathGao Date: Mon, 10 Aug 2026 20:45:45 +0800 Subject: [PATCH 1/2] ci: write the cargo cache on master only, and stop writing it four more times #572 added the cargo cache and named two things it could not settle. Both turned out to matter, and the measurement is now available: the repository holds 13.22 GB of Actions cache against a 10 GB limit -- 8.76 GB in ~113 npm caches spread over 50 refs, and 4.45 GB in the four cargo caches a single run of #572 wrote. So the limit binds today, and over it GitHub evicts least-recently-used. Cargo caches are the largest entries in the repository, which makes them the ones that vanish: without a bound, two pull requests in flight overflow the budget and the cache becomes a slower way of not caching. `save-if` bounds it. Pull requests restore and save nothing, so the total is one set rather than one set per branch in flight. That only works if something writes on master, and #572 recorded that nothing did: test_build.yml ran on `pull_request` alone, so every cache it wrote was scoped to a pull request branch and no other branch could read it. Every new pull request paid a full cold compile regardless. It now also runs on push to master, carrying the same paths-ignore, and cancel-in-progress is restricted to pull requests there for the reason test.yml already gives -- cancelling a master run because a second merge landed would discard the compile that was going to fill the cache. build.yml loses its cache entirely. Four more entries is another ~4.5 GB of a budget that is already over, spent on the least valuable minutes here: it is workflow_dispatch-only, runs about weekly, and nobody waits on a release build the way they wait on a pull request check. First place to add one back if the limit stops binding. 965 tests pass; svelte-check clean; all three workflows re-parsed as YAML. The one thing not knowable before merging: the first master run is a cold compile that writes the caches every later pull request reads. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 30 +++++++++++----------- .github/workflows/test.yml | 15 ++++++++--- .github/workflows/test_build.yml | 43 +++++++++++++++++++++++++++----- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a73c78e8..c371679b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -177,23 +177,23 @@ jobs: with: targets: ${{ matrix.rust_target }} - # Release builds compile the same dependency graph the test workflows - # do, from scratch, on four runners. The measurement that motivated - # caching is in test.yml: 7m57s of a 12m14s job was the release compile - # alone. + # No cargo cache here, deliberately, and it is a budget decision rather + # than a judgement that caching does not work. # - # `workspaces` names the cargo workspace root; without it the action - # looks for Cargo.lock at the repository root and caches nothing. + # A cargo cache is 1.0-1.2 GB per entry. This workflow has four matrix + # entries, so caching it would claim another ~4.5 GB of a 10 GB + # per-repository limit that already holds ~110 npm caches and the three + # entries test_build.yml writes on master. Over that limit GitHub evicts + # least-recently-used, and these would be the least recently used of all: + # this workflow is `workflow_dispatch` only and runs about once a week. # - # `key` is not optional here. The automatic key covers the job id, the - # rustc release/host and a hash of the Cargo files -- and the two - # windows-latest entries share all three, differing only in `arch`. The - # ARM64 job would otherwise restore the x64 job's target directory. - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: src-tauri - key: ${{ matrix.os }}-${{ matrix.arch }} + # What it would buy is also the least valuable minutes in the repository. + # A release build is not on anyone's critical path -- nobody waits on it + # the way they wait on a pull request check -- and its four jobs already + # finish in 12-16 minutes. Spending a third of the cache budget to make a + # weekly job faster, at the cost of the caches every pull request reads, + # is the wrong trade. If the limit ever stops binding, this is the first + # place to add one back. - name: Install Linux dependencies if: matrix.platform == 'ubuntu-24.04' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62791051..3d8c894f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,14 +55,21 @@ jobs: # looks for Cargo.lock at the repository root, finds none and caches # nothing. # - # This job is also what makes the cache reach anything: a pull request - # can only restore caches written on its own branch or on the base - # branch, and of the three workflows only this one runs on push to - # master. Its master runs are what seed a freshly opened branch. + # A pull request can only restore caches written on its own branch or on + # the base branch, so master runs are what seed a freshly opened branch. + # This job and test_build.yml's matrix both run on push to master for + # that reason, and `save-if` keeps them the only writers: a pull request + # restores and saves nothing. + # + # That bound is not cosmetic. One run's cargo caches measured 4.45 GB + # against a 10 GB per-repository limit shared with ~110 npm caches, and + # GitHub evicts least-recently-used -- so unbounded per-branch copies + # would push out the very entries they are meant to reuse. - name: cache rust dependencies uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri + save-if: ${{ github.ref == 'refs/heads/master' }} - name: install dependencies (ubuntu only) run: | diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml index 04a37669..cefe047d 100644 --- a/.github/workflows/test_build.yml +++ b/.github/workflows/test_build.yml @@ -27,15 +27,39 @@ on: - '**.md' - 'pics/**' - '.github/ISSUE_TEMPLATE/**' + # This trigger exists to fill the cargo cache, not to test master again. + # + # A GitHub Actions cache can only be restored by the branch that wrote it or + # by a branch descended from one that did, and until now nothing wrote a + # cargo cache on master: this workflow only ran on `pull_request`, so every + # cache was scoped to a pull request branch and no other branch could ever + # read it. Every new pull request paid a full cold compile no matter how many + # had been cached before it -- which is most of the benefit of caching, lost + # to where the cache was written rather than to anything about caching. + # + # One run per merge seeds all three platforms for every pull request opened + # afterwards. Only the first is a cold compile; later master runs restore + # master's own cache and recompile little. + push: + branches: + - master + paths-ignore: + - '**.md' + - 'pics/**' + - '.github/ISSUE_TEMPLATE/**' workflow_dispatch: # Every push to a pull request queued another full matrix while the previous # one was still waiting for a runner, and both then competed for the same # scarce macOS capacity. Only the newest revision of a branch is worth # building. +# +# Not on master, for the same reason test.yml does not: the push trigger above +# exists to write the cache, and cancelling one master run because a second +# merge landed would throw away the compile that was going to fill it. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build-test: @@ -89,16 +113,23 @@ jobs: # `key` makes the cache say which matrix entry wrote it rather than # leaning on that coincidence. # - # Note what this cannot do on its own: this workflow has no push - # trigger, so its caches are only ever written on pull request branches - # and a branch can only restore its own or the base branch's. The first - # run of a new pull request will still be a cold compile; later pushes - # to the same branch are the ones that hit. + # `save-if` is what keeps this affordable. Without it every pull request + # writes its own copy, and a copy here is 1.0-1.2 GB per platform: four + # cargo caches from a single run measured 4.45 GB, against a 10 GB + # per-repository limit that also holds ~110 npm caches. Two pull requests + # in flight would overflow it, and GitHub evicts least-recently-used -- + # so the largest entries, these, are the ones that vanish, and the cache + # would be a slower way of not caching. + # + # Writing only on master pins the total at one set. Pull requests restore + # from master and save nothing, which is also what makes the push trigger + # above worth its runner time: it is the only writer. - name: cache rust dependencies uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri key: ${{ matrix.os-name }} + save-if: ${{ github.ref == 'refs/heads/master' }} - name: install dependencies (ubuntu only) if: matrix.platform == 'ubuntu-22.04' From 60d56e43e5e048fb9e3d8cc4f81011daf6817754 Mon Sep 17 00:00:00 2001 From: PathGao Date: Mon, 10 Aug 2026 20:58:32 +0800 Subject: [PATCH 2/2] ci: drop the npm cache, which was spending the budget the cargo cache needs The 10 GB per-repository cache limit is being exceeded, and `save-if` in the previous commit bounds the cargo half of it. This is the other half, and it was the larger one: 113 npm cache entries, 8.76 GB, across 50 refs, most of them pull requests closed weeks earlier. Nothing can bound them. GitHub scopes caches by ref, so setup-node writes one per branch per platform, and there is no `save-if` equivalent -- deleting them by hand, as was done alongside this branch, buys a few weeks before they grow back. What they bought, measured on runs that hit them: `npm ci` in 6s on Linux, 9s on macOS, 15-17s on Windows. `cache: npm` caches `~/.npm`, the download directory, not `node_modules`; `npm ci` still runs and still links all 190 packages, so the saving is the registry fetch alone and the runners sit close to the registry. Seconds per job, in exchange for the budget that makes an eight-minute saving possible. Two assertions in scripts/ciCacheBudget.test.ts hold both halves: every rust-cache use restricts its save to master, and no workflow re-enables the npm cache. Both were mutation-checked -- removing `save-if` fails the first and not the second, re-adding `cache: npm` fails the second and not the first. Neither is a law of nature and the comments say so; the point is that the next change to either has to re-do the measurement rather than discover the ceiling again. 965 tests pass; svelte-check clean. Co-Authored-By: Claude Opus 5 --- .github/workflows/test.yml | 18 +++++++++- .github/workflows/test_build.yml | 3 +- scripts/ciCacheBudget.test.ts | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 scripts/ciCacheBudget.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d8c894f..75ae4a86 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,11 +36,27 @@ jobs: steps: - uses: actions/checkout@v7 + # No `cache: npm`, deliberately — it was the single largest consumer of a + # cache budget the cargo caches above have to fit inside. + # + # Measured: 113 npm cache entries, 8.76 GB, spread over 50 refs, against + # a 10 GB per-repository limit. GitHub scopes caches by ref, so one is + # written per branch per platform and nothing can bound it the way + # `save-if` bounds the cargo caches. Most of those 113 belonged to pull + # requests closed weeks earlier; caches are only evicted after seven days + # unused, or by least-recently-used pressure that takes the biggest + # entries — the cargo caches — first. + # + # What it bought, measured on runs that hit it: `npm ci` in 6s on Linux, + # 9s on macOS, 15-17s on Windows. It caches `~/.npm`, the download + # directory, not `node_modules` — `npm ci` still runs and still links all + # 190 packages, so the saving is only the registry fetch, and the runners + # are close to the registry. Seconds per job, for the budget that makes + # an eight-minute saving possible. - name: setup node uses: actions/setup-node@v7 with: node-version: lts/* - cache: 'npm' - name: install rust stable uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml index cefe047d..553cd503 100644 --- a/.github/workflows/test_build.yml +++ b/.github/workflows/test_build.yml @@ -88,11 +88,12 @@ jobs: steps: - uses: actions/checkout@v7 + # No `cache: npm` — see the note in test.yml. It cost 8.76 GB of a 10 GB + # budget across 113 unbounded per-branch entries and saved seconds. - name: setup node uses: actions/setup-node@v7 with: node-version: lts/* - cache: npm - name: install rust stable uses: dtolnay/rust-toolchain@stable diff --git a/scripts/ciCacheBudget.test.ts b/scripts/ciCacheBudget.test.ts new file mode 100644 index 00000000..49615255 --- /dev/null +++ b/scripts/ciCacheBudget.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { readSource } from './sourceTree.js'; + +const workflows = ['build.yml', 'test.yml', 'test_build.yml', 'publish-packages.yml'].map( + (name) => ({ name, text: readSource(`.github/workflows/${name}`) }), +); + +// GitHub gives a repository 10 GB of Actions cache and evicts +// least-recently-used over it. Both assertions below exist because that budget +// was measured being exceeded — 13.22 GB across 117 entries — and because the +// entries that overflow it are evicted in the order that hurts most: the +// largest first, which is exactly the cargo caches the budget is being spent +// on. Neither of these is a law of nature; both are trades with numbers behind +// them, recorded in the workflow comments. Change them by re-doing the +// measurement, not by deleting the assertion. + +test('a cargo cache is written on master and nowhere else', () => { + // Without `save-if` every pull request writes its own copy, and a copy is + // 1.0-1.2 GB per platform: one run of #572 wrote 4.45 GB. Two pull requests + // in flight overflow the budget on their own, and what gets evicted is the + // cargo caches — so the cache becomes a slower way of not caching. + // + // This is also why test_build.yml runs on push to master. The two are one + // decision: `save-if` makes master the only writer, so master has to run. + for (const { name, text } of workflows) { + const uses = [...text.matchAll(/uses: Swatinem\/rust-cache@v2\n([\s\S]{0,400}?)(?=\n\s*- name:|\n\n)/g)]; + for (const [, block] of uses) { + assert.match( + block, + /save-if: \$\{\{ github\.ref == 'refs\/heads\/master' \}\}/, + `${name} caches cargo output without restricting the save to master`, + ); + } + } +}); + +test('setup-node does not cache npm', () => { + // It was the largest consumer of the same budget: 113 entries, 8.76 GB, + // across 50 refs, most of them pull requests closed weeks earlier. Caches + // are scoped by ref, so one is written per branch per platform and nothing + // can bound it the way `save-if` bounds the cargo caches. + // + // What it bought, on runs that hit it: `npm ci` in 6s on Linux, 9s on + // macOS, 15-17s on Windows. It caches `~/.npm`, not `node_modules`, so + // `npm ci` still runs and still links every package — the saving is the + // registry fetch alone. + for (const { name, text } of workflows) { + assert.doesNotMatch( + text, + /^\s*cache: '?npm'?\s*$/m, + `${name} re-enables the npm cache; re-read the budget note in test.yml first`, + ); + } +});