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.
- 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.
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.
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].
Where the numbers live
A release would change three version numbers by hand:
engine/rust/Cargo.toml,[workspace.package] version. All six crates sayversion.workspace = true, so they inherit it[4], and the Rust CLI’s--versionis 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:
-
Choose the version by Cargo’s rules, and set it in all three files.
-
In
engine/ts:npm ci,npm run typecheck,npm test. -
In
engine/rust:cargo fmt --all --check, the clippy command,cargo test --workspace,cargo package --workspace. -
A long fuzzing campaign:
cargo build -p viscosity-cli, thennode scripts/fuzz.mjs --count 20000 --seed 7. -
node packages/wasm/build.mjs, thennode packages/wasm/smoke.mjs, which packs@viscosity/wasm, installs it, and compares every corpus program it compiles with the TypeScript CLI’s output. -
npm packinengine/ts/packages/cli, and install the tarball in an empty folder, as CI does. -
If the release quotes numbers, re-run
packages/bench/src/matrix.tsfromengine/ts. -
In
book:node scripts/check_chapters.mjs. -
scripts/release.sh --dry-run: the tests again, thencargo publish --dry-runandnpm 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.
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.
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!()
}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/cliis one esbuild bundle with no runtime dependencies;@viscosity/wasmships notices for 71 crates. Both setpublishConfig.accessto 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.shpublishes; running it is Tariq’s call.
Check your understanding
0/6- 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.
What does
cargo package -p viscosity-coredo in the repository today? - 3.
Why does
@viscosity/cliship a THIRD_PARTY_LICENSES.txt? - 4.
Which of these does CI run on every push?
- 5.
A user depends on
viscosity-core = "0.1". Which release willcargo updategive them? - 6.
scripts/release.shcan publish everything. Why does it only do so with tokens Tariq adds himself?
References
- [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] 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] 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] 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] 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] 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)