Release engineering
This document captures how the release pipeline works, what each artifact contains, and how to cut a new release.
Overview
The pipeline has two halves:
- release-plz (
.github/workflows/release-plz.yml, configrelease-plz.toml) maintains a rolling release PR on every push tomain: it bumps[workspace.package].version(all crates inherit it — never hand-bump), rewrites the internal=X.Y.Zdependency pins, and updatesCHANGELOG.mdviacliff.toml(Common Changelog style). Merging that PR cuts the release: the same workflow then pushes the barevX.Y.Ztag. - dist / cargo-dist (
.github/workflows/release.yml, configdist-workspace.toml) fires on that tag: builds the three target tarballs plus the shell installer, uploads checksums and GitHub build-provenance attestations, creates the GitHub Release (body from the version’sCHANGELOG.mdsection), and runs our custom jobs.
push to main ─→ release-plz-pr (maintains rolling release PR)
merge release PR ─→ release-plz-release (pushes tag vX.Y.Z)
tag vX.Y.Z ─→ release.yml (dist):
plan ─→ build-local-artifacts (3 runners) ─→ build-global-artifacts
─→ custom-release-checks (release-checks.yml — artifact gate)
─→ host (uploads artifacts, creates GitHub Release)
─→ custom-homebrew-tap-publish (needs host + checks — pushes tap formula)
─→ announce ─→ custom-smoke-test (smoke-test.yml — post-announce, informational)
release.yml is generated by dist generate from dist-workspace.toml — never hand-edit it;
edit the config and regenerate (dist generate --check guards drift). The custom jobs live in
hand-maintained workflow_call workflows:
release-checks.yml— dynamic-dependency allowlist (ldd/otool -L), GLIBC ≤ 2.35 floor on the binary and the embedded ONNX Runtime (extracted at runtime by a real tiny index run), and a--versionsmoke. Gates the tap publish. (dist 0.32 offers no pre-hosthook, so the GitHub Release itself is created regardless — a failed check means: fix, delete the release/tag, re-tag.)homebrew-tap-publish.yml— rendershomebrew/localdb.rb.erbviahomebrew/render.rbfrom dist’s finaldist-manifest.json(URLs/sha256s verbatim) and pushesFormula/localdb.rbtodokterbob/homebrew-localdb. Single writer of the tap. Needs theHOMEBREW_TAP_TOKENsecret (fine-grained PAT,contents: writeon the tap).smoke-test.yml—smoke_test.sh(install → init → index → search) on ubuntu-22.04 (the glibc-2.35 floor, ≈ Mint 21.x), ubuntu-latest and macos-latest. Post-announce, informational.
Version and changelog policy
[workspace.package].versionis release-plz-owned. All 10 crates useversion.workspace = true; internal path deps carry aversion = "X.Y.Z"requirement (cargo packagedemands one), which release-plz keeps in sync on each bump.- Package names are namespaced (
localdb-extract,localdb-embed, …) while each crate’s[lib] namestays short, so imports are unprefixed (use extract::…) but nothing in the workspace can ever be resolved against an unrelated crates.io package — the short names (extract,fetch,embed, …) are all taken by strangers there, and release-plz’sgit_onlychange detection runscargo package, which resolves internal deps through an overlay registry that only shadows crates.io at the same name and version. Thelocaldb-*names are currently unclaimed on crates.io; the plan is to claim them once the project matures. Until then a squatter publishing a higher-versionedlocaldb-*crate could confuse that overlay resolution — if that ever becomes a live concern before the names are claimed, tightening the internal requirements to exact=X.Y.Zpins closes it. release-plz.tomluses the single-tag scheme: every package sharesgit_tag_name = "v{{ version }}"(that template is howgit_onlymode finds each package’s last released version), but onlylocaldbcreates the tag.CHANGELOG.mdfollows Common Changelog, rendered bycliff.toml(shared by release-plz and thegit-cliffCLI). Commits are grouped by first-word heuristics (Add/Fix/Remove/…); docs/chore/ci/test commits are skipped. The release PR is hand-editable — curate the generated section before merging.- Version bumps are patch-level by default (the repo does not use conventional commits, so release-plz cannot infer minor/major). Edit the version in the release PR for a bigger bump.
Release targets
| Platform | Target triple | Runner | Notes |
|---|---|---|---|
| macOS Apple Silicon | aarch64-apple-darwin |
macos-14 |
CoreML built in |
| Linux x86_64 | x86_64-unknown-linux-gnu |
ubuntu-22.04 |
glibc-2.35 floor |
| Linux arm64 | aarch64-unknown-linux-gnu |
ubuntu-22.04-arm |
native build, no cross |
Runner pinning lives in [dist.github-custom-runners] in dist-workspace.toml. The ubuntu-22.04
pins are the glibc-floor mechanism (issue #133): our own Rust code inherits the build machine’s
glibc floor. Pinning also keeps dist off its default zigbuild cross path for aarch64.
Artifacts are built with the dist cargo profile ([profile.dist]: release + strip = true).
Embedding backends per artifact
| Artifact | Backend |
|---|---|
aarch64-apple-darwin |
CoreML (ANE/GPU) built in — auto-selected at runtime; falls back to ONNX |
x86_64-unknown-linux-gnu |
ONNX CPU only |
aarch64-unknown-linux-gnu |
ONNX CPU only |
How CoreML gets into the macOS binary — cli/Cargo.toml declares a
[target.'cfg(target_os = "macos")'.dependencies] block that depends on embed with
features = ["local-coreml"]. Cargo unions this with the base local-onnx feature, so on macOS
embed builds with both. No --features flag is needed anywhere.
Models are downloaded from HuggingFace at runtime on first use (~706 MB for the default model) and
cached under paths.models. Nothing is bundled in the binary.
Native deps and static-linking guarantees
ONNX Runtime is embedded, never system-provided (issue #133): embed/build.rs downloads and
sha256-verifies Microsoft’s official build at compile time and embeds it; the binary extracts it to
the user cache dir on first use (embed::ort_runtime). The Homebrew formula deliberately has no
depends_on "onnxruntime" — brew’s onnxruntime bumps every few weeks while ort v2 pins 1.24.x,
and Ubuntu 24.04 has no apt package, so a system-lib approach would fork the build per channel and
accept untested version skew. Embedded keeps one artifact and one tested combo everywhere.
release-checks.yml enforces the guarantees on the built tarballs:
- Linux (both arches, natively):
lddallowlist (platform baseline only),objdump -TGLIBC ≤ 2.35 on the binary and the runtime-extractedlibonnxruntime. - macOS:
otool -Lallowlist — only/usr/lib/,/System/Library/,@rpath,@loader_path(CoreML/Foundation frameworks live under/System/Library/and pass). --versionmust report a commit SHA (vergen;unknownwould mean a git-less pipeline build).
Install channels
- Homebrew tap:
brew install dokterbob/localdb/localdb— prebuilt-binary formula with shell completions (generate_completions_from_executable, backed bylocaldb completions <shell>) and an opt-inbrew services start localdbdaemon (service do). A from-source formula is off the table whileembed/build.rsdownloads ONNX Runtime at build time (Homebrew builds are network-free), and homebrew-core is out of reach at current notability. - Shell installer:
curl ... | shone-liner generated by dist, linked from each GitHub Release. - Tarballs: three per release, each containing
localdb,README.md,LICENSE, plussha256.sumchecksums and GitHub build-provenance attestations (gh attestation verify <file> --repo dokterbob/localdb).
MSRV
The workspace MSRV is Rust 1.88 on every platform, declared as rust-version in the root
Cargo.toml (floor set by image 0.25 via the pdf_oxide PDF parser). CI and the release pipeline
use current stable.
How to cut a release
- Merge the rolling release PR that release-plz maintains (curate its changelog section and, if the default patch bump is wrong, edit the version in the PR first).
- That’s it. The merge triggers the tag push; the tag triggers dist. Monitor the
Releaserun in the Actions tab: 3 tarballs + shell installer + checksums + attestations on the GitHub Release, tap formula updated, smoke test green.
Do not hand-bump the version or hand-push vX.Y.Z tags outside this flow (a hand-pushed tag
does work — dist only needs the tag — but the changelog and version pins won’t have been updated).
Operational notes
- release-plz’s
git_onlychange detection runscargo package --workspace(with verification builds) in a temp worktree on everyrelease-plzrun — this is a full workspace build; the workflow warms it withSwatinem/rust-cacheand a fixedCARGO_TARGET_DIR. RELEASE_PLZ_TOKENmust be a PAT: tags pushed with the defaultGITHUB_TOKENdo not triggerrelease.yml.- Legacy
v0.1.0preNtags predate this pipeline; bothcliff.toml’stag_patternand release-plz’s tag template ignore them.
Footguns (each of these has bitten once — the behaviors are permanent)
- release-plz tags any untagged manifest version on the next push to main. That is the release
mechanism itself (release-PR merge = new version on main = tag), but it has two sharp edges.
First, adopting release-plz in a repo whose current version was never tagged releases that version
immediately — this is exactly how v0.1.0 shipped. Second, deleting a
vX.Y.Ztag whileCargo.tomlstill saysX.Y.Zre-tags and re-releases it on the next push. To retire a botched release, land the version bump past it (merge the next release PR) before deleting its tag. - Hand-edits to a release PR survive only until the next push to main. release-plz force-push-refreshes its PR on every main push; a release PR containing commits from anyone else is closed and recreated fresh instead (the edits stay in the closed PR). Curate the changelog as the last step before merging, and merge promptly.
- Release PRs track packaged files only. Merges touching nothing inside a crate (docs, CI, workflows, qlty/cliff config, this file) neither open nor update a release PR — “release-plz ran but nothing happened” is the expected outcome for such pushes.
CARGO_TARGET_DIRin release-plz.yml must end in/target. release-plz’sgit_onlychange detection lists extracted comparison packages straight from disk only when they sit under a literaltarget/package/path; any other basename makes it fall back tocargo package --listinside the extracted standalone package, where path deps are stripped and our unpublished crates fail to resolve against crates.io. Reported upstream: release-plz/release-plz#2995.- Prereleases update the Homebrew tap while
publish-prereleases = true(set indist-workspace.toml). To cut one: edit the release PR’s version toX.Y.Z-rc.N(workspace version + the internal dep requirements) — the resultingvX.Y.Z-rc.Ntag makes dist mark the GitHub Release as a prerelease automatically. Flippublish-prereleasesoff once the tap has real users, or an rc formula will displace the stable one forbrew install. After an rc, the next release PR proposes a bump computed from the rc — edit it to the intended final version.
Known gaps / future work
- CUDA: today the ONNX sessions register no execution providers (CPU-only). A
local-cudafeature and CUDA release artifact are tracked separately. - launchd / systemd units outside brew:
brew servicescovers Homebrew installs; bare-tarball installs still have no unit files. Seespecs/06-roadmap.md §4. - Windows: no target yet.