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..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 @@ -55,14 +71,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..553cd503 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: @@ -64,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 @@ -89,16 +114,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' 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`, + ); + } +});