Viscosity
Review
Part XI · Viscosity in Rust · Day 49

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.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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

Step throughOne push, five jobs
A push starts five parallel CI jobs; older runs on the same branch are cancelledgit pushengine-tsengine-rustdripbookwebsiteeach job: a fresh runner, ubuntu-latestred steps: reproduced locally, not GitHub runs
A push (or a pull request) starts the workflow. Every job starts from a fresh runner: a clean checkout, and nothing from your machine.
1/6

.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:

Try itA cache keyed by the lockfile
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);
        }
    }
}
Open in Rust Playground ↗
Definition

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.

Under the hood

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

ExerciseMissing and stale fixtures

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!()
}
Open in Rust Playground ↗

What you learned

  • Continuous integration runs every gate on every push, on a fresh runner: here five parallel jobs, older runs cancelled.
  • npm ci refuses a lockfile out of sync with package.json; npm install would 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 and cargo package.
  • Committed fixtures need regenerating after the reference changes.

Check your understanding

0/6
  1. 1.

    A lockfile is out of sync with package.json. What happens on the CI runner?

  2. 2.

    You push twice to the same branch a minute apart. What does ci.yml’s concurrency setting do?

  3. 3.

    Which of these does the committed workflow NOT run?

  4. 4.

    Why does the Rust parity half of CI not need the TypeScript engine installed?

  5. 5.

    With engine/ts/node_modules removed, which tests fail in cargo test -p viscosity-cli?

  6. 6.

    What does Swatinem/rust-cache save between runs?

References

  1. [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. [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. [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. [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. [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. [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)