Viscosity
Review
Part XII · Shipping · Day 50

198. Release engineering

Every version number in the repository says 0.0.1, and nothing has been published. A release ships three things to two registries. Here is what each needs, what is already checked on every push, what isn’t, and the one step that only a person should take.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Find every version number a Viscosity release would have to change, and apply Cargo’s rules for 0.y.z and 0.0.z versions.
  • Say what Cargo.toml metadata a crate needs to be published, and what packaging the workspace’s crates first turned up.
  • Describe the two npm packages, how each is built and which licences each ships.
  • Write a release checklist from the scripts that really exist.

Version 0.0.1, everywhere

Ask the Rust CLI and the TypeScript CLI their version and both say viscosity 0.0.1. So does every package in the repository that could ever be published. None of them is. The repository has no git tags and no changelog either: as far as any registry knows, Viscosity doesn’t exist.

Chapter 119 packed the TypeScript CLI for npm and found three bugs on the way. Track B adds two more things to ship: the Rust crates, which would go to crates.io, and @viscosity/wasm, the Rust engine as an npm package (chapter 190). This chapter is about the work between “it passes its tests” and “a stranger can install it”, and about which parts of that work exist.

Intuition

A release is a ferry leaving. Up to the moment it sails you can fix anything on board. Once it has sailed, whatever was on it is on the other shore for good, and the only remedy is the next ferry. So the crew walks a checklist on the quay, the same one every time.

Definition

A release process is the repeatable set of steps that turns one commit into published, versioned packages: choose the version number, record what changed (a changelog), build each package, check it as a user would get it, then publish it to its registry. Publishing is the step that can’t be undone: a crates.io version “can never be overwritten, and the code cannot be deleted”[5], and an npm name and version can never be used again, even after npm unpublish[6].

Step throughThree things to ship
The Rust crates, @viscosity/cli and @viscosity/wasm, checked stage by stage for a releaseversionbuildlicencescheckedpublishedRust cratescrates.io✓····version: Cargo.toml 0.0.1@viscosity/clinpm, TS engine✓····version: package.json 0.0.1@viscosity/wasmnpm, Rust engine✓····version: package.json 0.0.1
Three numbers, all 0.0.1: one in the workspace Cargo.toml, which six crates inherit, and one in each npm package. They are set by hand; scripts/release.sh refuses to run if they differ, and CI checks that the packed CLI’s --version matches its own package.json.
1/5

Where the numbers live

A release would change three version numbers by hand:

  • engine/rust/Cargo.toml, [workspace.package] version. All six crates say version.workspace = true, so they inherit it[4], and the Rust CLI’s --version is compiled from it (chapter 197).
  • engine/ts/packages/cli/package.json, for @viscosity/cli.
  • engine/rust/packages/wasm/package.json, for @viscosity/wasm.

CI checks one thing: that the packed TypeScript CLI’s --version matches its own package.json. The release script (below) checks the rest, and stops unless both package.json files say what the crates say. The two npm packages are the same compiler, promised byte-identical, so one number for both says so plainly.

What the next number should be follows Semantic Versioning: a major change for an incompatible API change, minor for a compatible feature, patch for a compatible fix, and “anything MAY change” while the major number is 0[1]. Cargo makes that last part precise. A requirement accepts versions whose left-most non-zero part is the same, so "0.1" accepts 0.1.9 but not 0.2.0. For a 0.0.z version the left-most non-zero part is z itself: "0.0.3" means >=0.0.3, <0.0.4, so a caret requirement treats every patch release as incompatible[3]. So for a 0.y.z crate, a change in y counts as major and a change in z as minor, and every 0.0.z release is a major change[2].

That is why Viscosity’s first version is 0.0.1, not 0.1.0. The number says “experimental”: someone who depends on "0.0.1" never gets 0.0.2 without asking for it. Moving to 0.1.0 would be the first promise that fixes arrive compatibly.

What is the API, for Viscosity? The functions, yes, but also every explain line (the parity fixtures already treat it as exact), the CLI’s flags, messages and exit statuses, and the compiled code itself, which users run.

The crates

The workspace Cargo.toml holds the shared metadata: version, edition, authors = ["Tariq Shams"], license = "MIT", the repository URL, homepage = "https://viscosity.dev", a readme, keywords and a rust-version. Each crate adds its own one-line description, its categories and a documentation link (docs.rs for the libraries). That covers what the Cargo book asks a crate to fill in before publishing: a licence, a description, a homepage, a repository and a readme[5].

cargo package (which cargo publish --dry-run also runs) is the real test, and packaging found two problems, both fixed now. When this chapter was first written, on viscosity-core it stopped at once:

error: failed to verify manifest at
  `crates/core/Cargo.toml`

Caused by:
  all dependencies must have a version
  requirement specified when packaging.
  dependency `viscosity-vir` does not
  specify a version

(cargo 1.97, wrapped.) The workspace referred to its own crates by path only, as in viscosity-vir = { path = "crates/vir" }, and crates.io doesn’t accept dependencies on code outside crates.io. The fix the Cargo book gives is to add a version next to the path, which is used when the crate is published[3], and every internal entry now carries version = "0.0.1". The crates would go up in order, viscosity-vir first, since each needs the one below it on crates.io already. core’s dev-dependency on frontend-js stays path-only: cargo leaves it out of the packaged manifest, which breaks the cycle.

The second problem: viscosity-vir packaged, but its file list, from cargo package --list, held Cargo.toml, Cargo.lock and src/, and no licence text, because crates/vir had no LICENSE file. The MIT licence asks for its notice to be “included in all copies or substantial portions of the Software”. Each crate has a LICENSE now, and cargo package --workspace packages and verifies all six.

The two npm packages

@viscosity/cli is built by packages/cli/build.mjs, which npm runs as prepack. It uses esbuild to bundle the CLI, the engine, acorn, acorn-walk and sucrase into one dist/viscosity.mjs for Node 18 or later, so the package has no runtime dependencies at all. Then it copies the repository’s LICENSE and writes THIRD_PARTY_LICENSES.txt with the full licence text of each of the three bundled packages, read from node_modules. A dry run of npm pack lists five files: those two, the bundle, a five-line bin/viscosity.mjs, and package.json.

@viscosity/wasm is built by its own build.mjs (chapter 189). Its licence file is bigger, because the .wasm statically links 71 third-party crates. The script reads each crate’s licence files from cargo’s sources and stops if one has none. oxc’s crates are published without a licence file, so it uses committed copies of oxc’s own.

Both packages are scoped, under @viscosity. Scoped packages are private by default on npm, so a first publish would need --access public, and publishing under an organization’s scope needs that organization[6]. Both package.json files now set "publishConfig": { "access": "public" }, and the engine’s other workspace packages say "private": true, so npm refuses to publish them by accident.

What CI already checks

.github/workflows/ci.yml runs on every push and pull request, in five jobs (chapter 196). Two matter most for a release. The TypeScript job type-checks, runs every vitest test, then packs the CLI, installs the tarball in an empty folder and runs viscosity --version and viscosity explain from there. The Rust job installs the TypeScript engine (the parity reference), checks formatting, runs cargo test --workspace (parity, differential, multi-file, and a fixed-seed run of the fuzzer) and clippy with warnings as errors, builds the wasm crate in debug, and runs cargo package --workspace (without the verifying build, to save time).

What CI doesn’t run: wasm-bindgen and the @viscosity/wasm smoke test, a long fuzzing campaign, or the benchmarks.

A checklist from real scripts

Every command below exists in the repository today:

  1. Choose the version by Cargo’s rules, and set it in all three files.

  2. In engine/ts: npm ci, npm run typecheck, npm test.

  3. In engine/rust: cargo fmt --all --check, the clippy command, cargo test --workspace, cargo package --workspace.

  4. A long fuzzing campaign: cargo build -p viscosity-cli, then node scripts/fuzz.mjs --count 20000 --seed 7.

  5. node packages/wasm/build.mjs, then node packages/wasm/smoke.mjs, which packs @viscosity/wasm, installs it, and compares every corpus program it compiles with the TypeScript CLI’s output.

  6. npm pack in engine/ts/packages/cli, and install the tarball in an empty folder, as CI does.

  7. If the release quotes numbers, re-run packages/bench/src/matrix.ts from engine/ts.

  8. In book: node scripts/check_chapters.mjs.

  9. scripts/release.sh --dry-run: the tests again, then cargo publish --dry-run and npm publish --dry-run. It needs no tokens.

One step has no script yet: writing a changelog. The commit history is the only record of changes so far.

Under the hood

The last step is scripts/release.sh without --dry-run. It stops unless NPM_TOKEN and CARGO_REGISTRY_TOKEN are set and the git tree is clean, runs the tests, then publishes the crates one at a time, viscosity-vir first, skipping any version crates.io already has and waiting for each to reach the index. The dry run can’t check one crate at a time: cargo package -p viscosity-core fails to verify, because viscosity-vir isn’t on crates.io yet. --workspace verifies each crate against the ones packed before it. Publishing is the only step that can’t be undone, and it claims names: crates.io names are first come, first served[5], and chapter 119 found that the plain viscosity name on npm belongs to someone else. So the tokens are Tariq’s to add, after he creates the npm organization viscosity, and running the script is his decision.

Your turn

Write Cargo’s version arithmetic: the next version for a change, and whether a caret requirement accepts a version. The tests include Viscosity’s own 0.0.1.

ExerciseCargo’s version arithmetic

Write next(v, c): the version after v for a change c. From 1.0.0 on, Break bumps the major number, Feat the minor and Fix the patch, and the parts to the right reset to 0. In 0.y.z, Break bumps y and resets z, and Feat and Fix bump z. In 0.0.z, every change bumps z. Then write accepts(req, v): does Cargo’s default (caret) requirement req accept v? It must have the same left-most non-zero part (for 0.0.z, the same everything) and not be older than req.

#[derive(Clone, Copy)]
enum Change {
    Break, // breaks the public API
    Feat,  // adds, breaks nothing
    Fix,   // fixes a bug
}
use Change::*;

// "1.4.2" -> (1, 4, 2)
fn parts(v: &str) -> (u64, u64, u64) {
    let n: Vec<u64> = v
        .split('.')
        .map(|p| p.parse().unwrap())
        .collect();
    (n[0], n[1], n[2])
}

// The version after `v` for change `c`.
fn next(v: &str, c: Change) -> String {
    todo!()
}

// Does the requirement `req` (Cargo's
// default, a caret) accept version `v`?
fn accepts(req: &str, v: &str) -> bool {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • A release process is the same steps every time: version, changelog, build, check as a user would, publish. Publishing is permanent on both crates.io and npm.
  • Viscosity’s version lives in three files, set by hand and checked by the release script. It is 0.0.1: under Cargo’s caret rule every 0.0.z release is incompatible with the next. (At 0.1.0, a breaking change would be 0.2.0.)
  • Packaging the crates found two problems, now fixed: path dependencies without versions, and no licence text in a crate.
  • @viscosity/cli is one esbuild bundle with no runtime dependencies; @viscosity/wasm ships notices for 71 crates. Both set publishConfig.access to public.
  • CI packs and installs the CLI and packages the crates on every push; the wasm smoke test, long fuzzing and benchmarks are run by hand. scripts/release.sh publishes; running it is Tariq’s call.

Check your understanding

0/6
  1. 1.

    Suppose Viscosity were at 0.1.0 and a release changed the wording of an explain line. Under Cargo’s rules, what should the version be?

  2. 2.

    What does cargo package -p viscosity-core do in the repository today?

  3. 3.

    Why does @viscosity/cli ship a THIRD_PARTY_LICENSES.txt?

  4. 4.

    Which of these does CI run on every push?

  5. 5.

    A user depends on viscosity-core = "0.1". Which release will cargo update give them?

  6. 6.

    scripts/release.sh can publish everything. Why does it only do so with tokens Tariq adds himself?

References

  1. [1] Tom Preston-Werner (original author) and the semver contributors. Semantic Versioning 2.0.0. semver.org (source in the semver/semver repository on GitHub), CC BY 3.0. semver.org/spec/v2.0.0.html (checked 2026-10-31)
  2. [2] The Cargo Team. SemVer Compatibility (The Cargo Book). The Cargo Book; source doc/book/src/reference/semver.md in github.com/rust-lang/cargo. doc.rust-lang.org/cargo/reference/semver.html (checked 2026-10-31)
  3. [3] The Cargo Team. Specifying Dependencies (The Cargo Book). The Cargo Book; source in doc/book/src/reference/specifying-dependencies.md of github.com/rust-lang/cargo. doc.rust-lang.org/cargo/reference/specifying-dependencies.html (checked 2026-10-31)
  4. [4] The Cargo Team. Workspaces (The Cargo Book). The Cargo Book; source in doc/book/src/reference/workspaces.md of github.com/rust-lang/cargo. doc.rust-lang.org/cargo/reference/workspaces.html (checked 2026-10-31)
  5. [5] The Cargo Team. Publishing on crates.io (The Cargo Book). The Cargo Book; source doc/book/src/reference/publishing.md in github.com/rust-lang/cargo. doc.rust-lang.org/cargo/reference/publishing.html (checked 2026-10-31)
  6. [6] npm, Inc. (GitHub) and the npm CLI contributors. npm-publish and Scope (npm CLI documentation). docs.npmjs.com (source in the npm/cli repository on GitHub). docs.npmjs.com/cli/commands/npm-publish (checked 2026-10-31)