196. Keeping it healthy
Every test in this Part ran on one machine that had everything installed. Continuous integration runs them on a machine that has nothing, on every push. Here is the workflow as committed, the two ways a fresh machine bites, and an honest note on what has actually run.
- Say what continuous integration adds to tests that already pass locally.
- Read the five-job workflow, its triggers, concurrency and caches.
- Name the gates in the Rust job, and one check the workflow doesn’t run.
- Explain how a lockfile or a missing install breaks a fresh runner, and check a fixture set yourself.
It worked on my machine
I checked out engine/ts as it was just before commit cff36bb and
ran the command a CI server runs to install it (npm 10.9.9, trimmed):
$ npm ci
npm error code EUSAGE
npm error `npm ci` can only install
packages when your package.json and
package-lock.json or npm-shrinkwrap.json
are in sync. Please update your lock
file with `npm install` before
continuing.
npm error Missing: @viscosity/cli@0.1.0
from lock file
Five workspace packages were missing from the lockfile. On the machine where all of this was written, nobody had
noticed: node_modules was already there, and npm install updates
the lockfile when it disagrees. npm ci is built for automated
installs and does the opposite: if the lock and package.json don’t
match, it “will exit with an error, instead of updating the package
lock”[1]. The fix regenerated the lockfile and
made esbuild, which bundles the CLI, an explicit dev dependency.
A recipe tested only in your own kitchen hides the things you never wrote down: the spice you always have, the oven that runs hot. Give it to a new cook in an empty kitchen every day, and the missing lines show up the first morning.
The workflow
.github/workflows/ci.yml runs on every push and pull_request.
Its concurrency group is the workflow and the branch, with
cancel-in-progress: true: a new push cancels the run it supersedes
[2]. Its permissions let the jobs only read
the repository.
Five jobs run side by side on ubuntu-latest, each with a timeout:
job what it runs
engine-ts npm ci, tsc --noEmit, vitest,
npm pack + install the CLI
engine-rust npm ci (TS reference), fmt,
cargo test, clippy -D warnings,
wasm32 build, cargo package
drip vitest; cargo test --release,
clippy (book/companion)
book check_chapters, vitest, build,
link check, Playwright smoke
website npm ci, build, link check
Two caches save work between runs. actions/setup-node caches npm’s
downloads under a key built from each package-lock.json
[3], so a changed lockfile means a fresh
download. Swatinem/rust-cache caches ~/.cargo and the compiled
dependencies in target/, keyed by the rustc version among other
things, and deliberately not the workspace’s own crates
[4]: oxc comes from the cache, and
Viscosity’s own crates are rebuilt on every run.
A key computed from a file’s contents means the same lockfile always finds the same cache, and any change to it misses. A toy version:
use std::collections::hash_map::{
DefaultHasher,
};
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
// A cache key from a lockfile's text,
// as setup-node hashes the lockfile
fn key(lock: &str) -> u64 {
let mut h = DefaultHasher::new();
lock.hash(&mut h);
h.finish()
}
fn main() {
let old = "acorn 8.19.0\n\
vitest 3.2.4";
let new = "acorn 8.19.0\n\
esbuild 0.28.2\nvitest 3.2.4";
let runs = [
("push 1", old),
("push 2", old),
("push 3, new lock", new),
("push 4", new),
];
let mut saved = HashSet::new();
for (run, lock) in runs {
let k = key(lock);
if saved.contains(&k) {
println!("{run}: hit");
} else {
println!("{run}: miss");
saved.insert(k);
}
}
}
Continuous integration runs every check on every change, on a fresh machine that has only what the repository says to install. A change is healthy when every gate passes there. It catches what passes on your machine only because of something your machine has.
The gates
The engine-rust job is mostly one command, cargo test --workspace,
and everything in this Part hangs off it. When I ran it locally on the
engine at commit 288e497 (rustc 1.97), it passed 75 tests in 14 test
binaries:
binary tests what it holds
parity 10 190 pairs, 14 frames,
4 profiles, the CLI
differential 2 node and python3
early_errors 6 108 invalid programs
multi_file 8 several files, jobs
profile_cli 2 odd profiles, cleanup
os_args 2 non-UTF-8 file names
fuzz 1 200 programs, seed 1
unit + core 44 SSA, CFG, plugins...
Each is a gate: if one fails, the job is red. Parity holds the
engines together (chapter 188), the differential holds the compiled
programs to the originals, multi_file holds the parallel CLI of
chapter 195 to its order, and fuzz.rs runs the fuzzer of chapter
193.
Then cargo clippy --workspace --all-targets -- -D warnings, which
turns every lint warning into an error. It passed when I ran it.
Clippy’s README now prefers CARGO_BUILD_WARNINGS=deny (Cargo 1.97
and later), which “has the benefit of not invalidating build
caches”[5], a good fit for a cached CI job.
When this chapter was first written, one check was missing:
cargo fmt --check, which exits with 1 when rustfmt would change a
file[6]. The code was formatted (rustfmt
1.9.0, with the repository’s rustfmt.toml), but nothing kept it so.
The job now runs cargo fmt --all --check before the tests, and
cargo package --workspace after them (chapter 198).
Fixtures that keep up
The parity fixtures in tests/expected/ are the TypeScript engine’s
output, committed. That is why the Rust half of parity needs no
TypeScript at all. The price is a rule: after changing the reference,
run node scripts/gen_expected.mjs, then cargo test, and commit
both. Forget the first step and the gate says so:
fixtures_match_the_programs_exactly compares the fixture folder with
the program list and fails with “run node scripts/gen_expected.mjs;
missing […], stale […]”. Your exercise is that check.
Has it run?
I can’t see GitHub from here, so this chapter describes the workflow
as committed, not runs I watched. The commit says each job’s commands
were run locally first. But a local run has your machine’s state, and
I could simulate one difference. The engine-rust job, as first
committed, installed node but never ran npm ci in engine/ts. Two
tests added after the workflow load the TypeScript engine from there:
fuzz.rs, and the TypeScript half of multi_file.rs. With
engine/ts/node_modules removed, cargo test -p viscosity-cli gave me
(wrapped):
test a_fixed_seed_fuzz_run_finds_nothing
... FAILED
Error [ERR_MODULE_NOT_FOUND]: Cannot find
package 'acorn' imported from
.../frontend-js/src/index.ts
test the_typescript_cli_prints_the_same
... FAILED
So the first real run of that job would most likely have been red, for
the same reason as the lockfile: something the repository didn’t
install. The fix was one step, npm ci in engine/ts before
cargo test, and the workflow has it now.
Notice what made both failures possible: a check that only ever ran where its inputs were already present. CI turns “it passed” into “it passed from scratch”. That is the same move the differential tests made for compiled programs, and the fuzzer for inputs nobody wrote: replace an assumption with a machine that doesn’t share it.
Your turn
Write check(progs, have), the fixture-set gate of parity.rs. Each program (name, ext) needs four files: name.ext, name.explain.txt, name.assume.ext and name.assume.explain.txt. Return the files that are needed but not in have (missing), and the files in have that no program needs (stale), each sorted.
fn check(
progs: &[(&str, &str)],
have: &[&str],
) -> (Vec<String>, Vec<String>) {
todo!()
}What you learned
- Continuous integration runs every gate on every push, on a fresh runner: here five parallel jobs, older runs cancelled.
npm cirefuses a lockfile out of sync withpackage.json;npm installwould have rewritten it.- Caches are keyed by lockfiles and the rustc version; workspace crates are rebuilt each time.
- The Rust gates:
cargo fmt --check, parity, differential, early errors, multi-file, fuzz, clippy-D warnings, a wasm build andcargo package. - Committed fixtures need regenerating after the reference changes.
Check your understanding
0/6- 1.
A lockfile is out of sync with package.json. What happens on the CI runner?
- 2.
You push twice to the same branch a minute apart. What does ci.yml’s
concurrencysetting do? - 3.
Which of these does the committed workflow NOT run?
- 4.
Why does the Rust parity half of CI not need the TypeScript engine installed?
- 5.
With engine/ts/node_modules removed, which tests fail in
cargo test -p viscosity-cli? - 6.
What does
Swatinem/rust-cachesave between runs?
References
- [1] npm, Inc.. npm-ci: Clean install a project (npm CLI documentation). docs.npmjs.com; source docs/lib/content/commands/npm-ci.md in github.com/npm/cli. docs.npmjs.com/cli/commands/npm-ci (checked 2026-10-31)
- [2] GitHub, Inc.. Control the concurrency of workflows and jobs (GitHub Actions documentation). docs.github.com; source in github.com/github/docs (content/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency.md and data/reusables/actions). docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency (checked 2026-10-31)
- [3] GitHub, Inc.. actions/setup-node: README (caching global packages data). github.com/actions/setup-node. github.com/actions/setup-node (checked 2026-10-31)
- [4] Swatinem (GitHub account) and contributors. Swatinem/rust-cache: README (Rust Cache Action). github.com/Swatinem/rust-cache. github.com/Swatinem/rust-cache (checked 2026-10-31)
- [5] The Rust Project Developers. Clippy: README (usage in CI). github.com/rust-lang/rust-clippy. github.com/rust-lang/rust-clippy (checked 2026-10-31)
- [6] The Rust Project Developers. rustfmt: README (verifying code is formatted). github.com/rust-lang/rustfmt. github.com/rust-lang/rustfmt (checked 2026-10-31)