Every Rust tutorial ends the same way: cargo build --release, a victory screenshot of a binary running in a terminal, the end. The Book, the YouTube series, the "build X in Rust" posts — all of them stop at the moment the code works on the author's laptop.
That is roughly the halfway point.
The other half — turning a binary that works for you into something a stranger can brew install on a Mac, winget install on Windows, or apt install on Ubuntu, that updates itself, carries a version number that means something, and ships without you babysitting it — is a discipline of its own. It is closer to product management than to programming, and almost nobody writes it down. So here it is: the shipping half, from a real multi-crate app distributed across five platforms and four package managers.
Consider this the map. Each leg below — the architecture, the one-tag workflow, every package manager, the build cache, the cross-compile traps — is a deep-dive of its own, and each gets its own post in this series. Here we walk the whole territory at altitude, so the pieces connect before you zoom into any one of them. If you only read one thing about shipping Rust, read this; if you're about to do it, follow the per-topic posts as they land.
Architecture first: one big app is many small crates
The instinct for a large Rust app is one giant crate. Resist it. The pattern that scales is a fleet: a family of small, single-purpose library crates, each a deep expert in one thing, plus a thin application crate on top that wires them together.
Two rules make this work:
One concern per crate, with an honest name. A crate name is read bare — in cargo add, in a dependency list, on crates.io search — stripped of all repo context. mft-core tells you what it is; utils does not. If your short prefix is distinctive (memf-, winevt-) it can stand alone; if it is a generic word (browser-), keep the full descriptive prefix so the name claims a namespace by itself.
Depend down, never sideways or up. Draw the layers and let dependencies only point downward. The app at the top imports everything; the leaf at the bottom imports nothing. When that arrow ever points the wrong way, you have found a design bug, not an inconvenience.
Arrows mean "depends on" — they only ever point down.
Two policies pay for themselves later:
-
Batteries-included binaries. A user in the field cannot
cargo build --features gpu,cloudon their machine. Compile every capability in. If a heavy dependency trips a license or dependency gate, fix the gate, not the feature set — amputating capability to slim a build ships a tool that silently can't do the thing. -
Lean core, full binary. When one crate is both a heavy end-user tool and something other libraries link for one primitive, split it: a lean
<x>-corewith just the primitives (no GPU, no cloud), and the full<x>binary that depends on it. Libraries link the lean core; the app stays batteries-included. Onedefaultcan't be both lean-for-libraries and full-for-the-binary — the split is the answer, not feature juggling.
None of this is visible in a tutorial because a tutorial has one crate and no consumers. At fleet scale it is the whole game.
One tag ships everything
Here is the goal, and it is worth being strict about: a single signed git tag produces every artifact, on every platform, in every channel.
git tag -s v0.3.0 -m "v0.3.0"
git push origin v0.3.0
That tag triggers one release.yml workflow that fans out to every platform and channel:
Nothing else produces a release. If a repo's "tag" shows only GitHub's auto-generated source tarball, the workflow did not run — that auto-tarball is not the release. The discipline is: one tag in, a full release out, and you verify it actually produced binaries before telling anyone it shipped. A green push is not a green deploy.
The build matrix is the spine:
matrix:
include:
- { target: aarch64-apple-darwin, os: macos-latest }
- { target: x86_64-apple-darwin, os: macos-15 }
- { target: x86_64-unknown-linux-musl, os: ubuntu-latest }
- { target: aarch64-unknown-linux-musl, os: ubuntu-latest }
- { target: x86_64-pc-windows-msvc, os: windows-latest }
The channels, and the part each one will bite you on
GitHub Releases
The easy one, and the only one that needs zero external secrets — the built-in GITHUB_TOKEN is enough to attach binaries + checksums. If all you ever do is publish executables, you can stop here. Everything below is for reaching people who never visit your Releases page.
crates.io
cargo publish, in dependency order, bottom of the graph up. Two rules:
-
A version publishes once. Bump the version before every release tag; a tag whose publish job re-pushes an existing version fails "already exists." Re-tagging the same
vX.Y.Zis only safe if the publish job never ran. -
Prefer the published registry crate over a
pathdependency the moment it is on crates.io. Path deps are for unpublished, in-flight work. Once your crate is published, switch dependents toversion = "0.2"so the build is reproducible and decoupled from your local checkout layout.
For an application crate that depends on dozens of internal path crates, crates.io publishing simply does not apply — an app ships as a binary, not a crate. Don't fight it; skip the publish job for the app and let the binary channels carry it.
Homebrew
One shared tap for the whole fleet (brew install yourorg/tap/yourtool). The trap that costs an afternoon: each project must dispatch its own event type (update-yourtool) to a matching handler workflow in the tap. Share a generic update-formula event between two projects and the second project's release fires the first project's updater. And the bot that dispatches needs write access on the tap — repository_dispatch from a read-only collaborator returns 403, silently.
winget
The single most surprising one: the winget-releaser action cannot create a new package — it only bumps an existing one. So your first version is a manual submission (hand-author three YAML manifests, open a PR to microsoft/winget-pkgs), and every release after that auto-PRs. Make the winget job continue-on-error: true until that first manual PR merges, or it fails every release for no reason. Two more: keep the MSI's UpgradeCode stable across versions (winget keys upgrades off it), and pull the ProductCode fresh each release (it changes every build).
apt, via Cloudsmith
.deb packages built with cargo-deb, pushed to a Cloudsmith repo, installed by users with:
curl -1sLf https://dl.cloudsmith.io/public/yourorg/yourrepo/setup.deb.sh | sudo bash
The gotcha is a 404: the Cloudsmith repo must exist before the first push. Create it in the dashboard first; the workflow won't create it for you.
The gotchas that are actually the product-management lessons
These are the ones that don't appear in any "build a CLI in Rust" post, because they only exist once real people on real platforms install your thing.
E0463: can't find crate for core on cross-compiles. This means a rust-toolchain.toml pin is overriding the toolchain your CI action installed, so the cross-target landed on the wrong toolchain. Pin the action to the same version as the toml. It fails in ~20 seconds, before any real compilation, and a native cargo publish --dry-run will never catch it.
cargo-deb errors "must have a copyright or authors property." Add authors = [...] to [package]. One line, and it is the entire difference between a green and a red release.
cargo-wix in a workspace where the binary is a member crate. The trailing positional argument is parsed as the path to Cargo.toml, not the wxs input — so cargo wix ... wix/main.wxs errors "does not appear to be a Cargo.toml file." Use --package <crate> --include wix/main.wxs with repo-root-relative Source= paths in the wxs. (And WiX is Windows-only; package the MSI on a Windows runner.)
Heavy C/C++ dependencies don't cross-compile to musl for free. A bundled C++ engine (an embedded database, say) needs a C++ cross-compiler — and apt's musl-tools ships musl-gcc but no musl-g++. Either switch the Linux targets to -gnu, or use cargo-zigbuild so zig c++ provides the musl toolchain.
Secrets are organization-level, and a repo-level secret silently shadows them. Put your publish tokens (crates.io, the tap PAT, the winget PAT, the Cloudsmith key) as organization secrets so every repo inherits them and rotation is one update. Then delete the repo-level copies — a same-named repo secret overrides the org one, and the repo keeps using its stale local value while you wonder why the org rotation did nothing.
Commit Cargo.lock in every app/binary repo. Otherwise CI resolves a fresh dependency graph that can differ from what you tested — and a release red-herring becomes a multi-hour misdiagnosis of a perfectly good crate.
And the one about build times, because it will dominate your CI bill
A real app with a bundled C++ dependency can take 80+ minutes to compile on Windows MSVC — three times the macOS clang time, because cl.exe is slow on template-heavy C++ built as a few huge translation units. The fix is not a faster machine (those translation units don't parallelize); it is caching the build.
Swatinem/rust-cache caches target/, which includes the compiled C++ objects, so a warm build skips the recompile entirely — measured here as 84 minutes down to 14. Two things to know: it keys on the whole Cargo.lock (so any dependency bump invalidates everything, including the unchanged C++), and it keys by runner architecture, not the Rust --target — so two matrix targets on the same runner collide on one cache key unless you add key: ${{ matrix.target }}. The lock-key invalidation is the one real limitation — a dependency bump means a cold rebuild — and the obvious next move (a compiler-level cache like sccache) turned out to break this particular C++ build more than it helped, so rust-cache alone is the pragmatic answer here. The 10 GB GitHub Actions cache cap, by the way, is no longer hard — it is now a raisable, pay-as-you-go limit.
The deep-dives ahead
This was the map. Each leg is a full post of its own, landing in this series:
- The fleet architecture — splitting one big app into many small crates, the depend-down discipline, and batteries-included vs. feature flags.
-
One tag, every artifact — the
release.ymlanatomy, the build matrix, and verifying a release actually shipped. - Publishing to crates.io — the lean-core/full-binary split, path-to-registry migration, and the publish-once rule.
- Homebrew without the footguns — the shared tap, per-project dispatch handlers, and bot write access.
- winget from zero — the manual first submission, then auto-updates, and keeping the MSI codes stable.
-
apt via Cloudsmith —
.debpackaging and a one-line public install. -
The Windows MSI —
cargo-wixin a virtual workspace, packaged on a Windows runner. - Making CI builds fast — rust-cache, the cache-key collisions, the 84-to-14-minute story, and the caching layers that don't pay off.
- The cross-compile traps — E0463, protoc, and musl-vs-gnu for C++ dependencies.
I'll link each here as it publishes.
Shipping is a product discipline
Here is the thing the tutorials are implicitly teaching by omission: that the code is the product. It isn't. The product is the thing a stranger installs without reading your README, that works the first time, that updates itself, and that you can release again next week without remembering 14 manual steps.
That is a product-management problem wearing an engineering costume. It has a roadmap (which channels, in what order), a definition of done (binaries verified live, not "the push was green"), a security posture (signed tags, org-scoped secrets, no insecure defaults), and a maintenance cost you pay every release. Treating it as an afterthought is why so much good Rust code never gets installed by anyone but its author.
The good news: it is almost entirely a one-time setup. Build the fleet architecture, write the one tag-driven workflow, eat the gotchas above once, and from then on shipping to five platforms and four package managers costs you exactly one signed tag. That is the half nobody teaches — and it is the half that turns code into a tool people use.