Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
33 changes: 28 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
46 changes: 39 additions & 7 deletions .github/workflows/test_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down
56 changes: 56 additions & 0 deletions scripts/ciCacheBudget.test.ts
Original file line number Diff line number Diff line change
@@ -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`,
);
}
});