Cargo-Rail: One Workspace Model, Not Ten Cargo Plugins
loadingalias
· post · 16 min
Cargo-Rail replaces ten Rust workspace tools with one shared model. It still rebuilds that model from outside Cargo, and that cost shapes everything.
Every Cargo plugin starts by relearning the same workspace.
It needs the packages, targets, features, dependency edges, source ownership, toolchains, and release relationships. Cargo already computes much of this, but doesn’t expose it all directly. Each tool pieces together its own partial copy from cargo metadata, manifest parsing, Git, and whatever it can observe from outside the build. Your CI, releases, and dependency checks all depend on those separate copies agreeing.
I call that the reconstruction tax: repeated work, duplicated config, tools that disagree with each other, and compute that should have gone to the compiler in the first place. After all, Rust compilation needs all the resources it can get.
I built Cargo-Rail to pay that tax once per command instead of ten times. Cargo-Rail 0.30.1 and Cargo-Rail Action v10.1.1 are far beyond the tool I introduced last year. Cargo-Rail repairs the dependency graph, audits the real public surface, reuses compiler results, plans affected work, carries change intent through releases, and keeps split repos synced with a canonical monorepo.
Cargo still builds. Nextest still runs tests, Just still owns recipes, and GitHub Actions still schedules jobs. Cargo-Rail decides what those tools should see and ties that decision to the workspace it inspected.
The annoying part? Cargo-Rail still pays the tax. It can share one model across every workflow, but it has to build that model from outside Cargo. A surprising amount of the code exists only to work around what Cargo doesn’t expose directly. It bothers me more and more every day.
Ten tools, ten copies of the same workspace
In my own repos, Cargo-Rail has replaced more than ten plugins and a decent pile of shell scripts. This was the goal of Cargo-Rail.
unify absorbed work that used to be spread across cargo-hakari, cargo-udeps, cargo-shear, cargo-machete, feature auditors, workspace-inheritance checks, and MSRV plugins/scripts. change and release replaced release-plz, cargo-release, git-cliff, and the glue that worked out publish order. That gave me a reliable way to write and review release notes with the change. split and sync replaced Copybara and the scripts around it - which was a massive Java stack. plan removed another layer of path filters and package-selection code. cache replaced my local and remote sccache setup; one verified cache now serves my laptop, my remote SSH machines, and CI.
today: each tool rebuilds its own slice
hakari, udeps, shear, machete ──▶ dependency graph
release-plz, git-cliff ──▶ crates, versions, history
path filters, CI scripts ──▶ package ownership
Copybara, sync scripts ──▶ crate paths, history
sccache ──▶ rustc invocations
cargo-rail: one reconstruction per command
metadata + manifests + git + rustc observations
│
▼
WorkspaceContext ──▶ unify · surface · cache
──▶ plan · release · split/sync
either way, the model is rebuilt outside CargoThis might seem like a lot for a single tool. It is. I know it is. I just couldn’t find a better way. Sharing the workspace model connects these workflows. Removing an unnecessary dependency gives Cargo less to build, the planner less to schedule, and the cache less to store. A changeset records release intent once, so the release doesn’t have to reconstruct it from commit messages months later. A split crate keeps its relationship with the monorepo instead of becoming another repository that people sync by hand. If you’ve ever tried to maintain an OSS repo from a monorepo… you’ll know how frustrating the overhead is.
Repair the dependency graph before optimizing the build
A cache can only reuse work you chose to do. The fastest compiler invocation is still the one Cargo never needed.
cargo rail unify treats version drift, unused edges, hidden feature coupling, workspace inheritance, MSRV policy, and workspace-hack maintenance as one graph-coherence problem. It derives one repair plan from the captured Cargo graph, shows the exact manifest changes, and revalidates the workspace before it writes anything. The unify workflow is everything I think should be native in Cargo today.
cargo rail unify --check --show-diff --explain
cargo rail unify apply --backupThe check leaves manifests untouched. When nothing needs repair, it says so, and --explain still lists every declaration it kept on purpose and why.
Unify has caught a bug in most of the workspaces I’ve pointed it at, including large, well-maintained ones: a crate compiles only because another member enables a dependency feature it forgot to declare. Feature unification hides this by design: Cargo builds one feature set per dependency across everything in the build, so the workspace build is green while the crate built on its own is broken. Unify compares the features Cargo’s resolution turns on for each dependency with the ones each member declares. It flags features a member only gets because a sibling asked for them.
cargo metadata can’t tell Cargo-Rail whether a dep is actually used, so Unify compiles evidence views in a sandbox and reads what the compiler saw. Until 0.30.0, parallel views each rebuilt the shared dep graph in their own sandbox; the 0.30.0 changelog records the switch to one shared sandbox and how much CPU it saved. That’s work Cargo-Rail has to do to recover an answer Cargo already has. This is not the last of it, either.
Cargo metadata can describe this checkout. It can’t prove what every downstream user, plugin, generated target, or unpublished consumer needs. Cargo-Rail preserves the external requirements you configure and widens when the evidence is incomplete. A lean graph built on a false closed-world assumption is a broken graph with better marketing.
Make pub describe an interface
cargo rail surface asks a different question: which public declarations can the configured products actually reach?
Surface merges compiler facts across libraries, binaries, tests, doctests, build scripts, proc macros, feature profiles, and selected targets. It reports dead public declarations and visibility wider than the observed consumers need. You can preview and apply proven visibility reductions; dead code stays report-only.
cargo rail surface --prepare
cargo rail surface --check --explain
cargo rail surface --fix --dry-run --explainI run Surface as a pre-release audit. It finds the pub someone added to get one old consumer compiling, the pub(crate) only a child module still needs, and the exported item no configured product can reach. Visibility starts describing the boundary we actually mean to support.
Getting those facts out of Cargo was harder than producing them. Cargo hashes the workspace wrapper’s path into each unit’s -C metadata. Surface used to give every view a fresh wrapper path, so every view recompiled every workspace member in its graph. As you can imagine, this was a horrible DX. In 0.30.0, typed views share one sandbox and one stable wrapper path, so Cargo keeps those units fresh. None of that made Surface smarter; it did, however, help to stop fighting Cargo’s freshness model.
Surface is deliberately conservative. It needs the authenticated compiler driver from the native archive or an adapter pack, plus the toolchain’s rustc-dev component. If a crate may have consumers outside the captured workspace, you have to tell it. Cargo-Rail won’t assume the rest of the world doesn’t exist.
Astral’s Hawk is a workspace-aware lint for the same unnecessary-pub problem, built for workspaces like Ruff and uv. Both tools run as Cargo’s workspace wrapper on rustc_private. As Hawk’s docs explain, rustc’s dead_code and unreachable_pub lints see one crate at a time. Getting the cross-crate answer requires a tool like this, which makes me think the analysis belongs in the toolchain itself.
The defaults differ. Hawk treats the workspace as the whole world. Surface defaults to consumer_scope = "open" and only draws closed-world conclusions for non-publishable internal crates you opt in, because a published lib’s real callers aren’t in your checkout. Hawk describes itself as experimental, and I haven’t done a careful head-to-head, so I won’t pretend either one is better. I don’t know. I do know that Hawk was built by Charlie Marsh using Codex… so, it’s safe to say, it’s probably pretty solid.
Surface is slow, and how slow depends entirely on the workspace: it compiles every product, feature profile, doctest set, and target view you ask it to cover. It’s a pre-release audit, not something to run on every build. The slowness sucks. I hate waiting for it, but I love the result.
Keep compiler work after cargo clean
Cargo owns freshness and incremental compilation inside target/. Cargo-Rail adds verified result reuse outside it.
The local content-addressed store survives cargo clean and target-directory removal. You can share the same results through S3, Cloudflare R2, or Azure Blob Storage, and eligible results can move between checkout roots with explicit path remapping. Local dev, remote workers, and CI reuse the same verified work instead of maintaining separate cache islands. As of 0.30.0, workspace-member Clippy results replay too, and every stable, beta, and nightly compiler at or above the adapter’s minimum can reuse results.
Before a hit, Cargo-Rail revalidates the compiler action, selected Rust and linker inputs, env, dep artifacts, outputs, and stored bytes. If evidence is missing, it runs the compiler.
cargo rail cache setup --check
cargo rail cache setup
cargo rail cache ready
cargo rail cache statuscache ready proves one cold miss and one verified warm restore before you trust it.
The cache is where the tax is highest. Cargo’s integration point for compiler reuse is the rustc-wrapper setting: one global slot that hands you a rustc command line and a few environment variables. Cargo-Rail has to reconstruct what that invocation means, including which workspace it belongs to, which inputs it reads, and which outputs it owns.
As of 0.30.1, src/compiler/ is about 73,000 lines of Rust, inline tests included. Alongside the cache itself, it contains the machinery for reconstructing the context Cargo had when it launched the compiler. That reconstruction has to stay correct as Cargo and rustc change. In A Second Vision for Cargo, I argued that a cache should reuse a unit only when evidence Cargo recorded itself permits it. A better interface could let us retire some of that machinery and give the next tool less of the same work to do.
Cargo compiles registry dependencies inside their unpacked source. Under an explicit CARGO_TARGET_DIR, the wrapper couldn’t find an enrolled workspace, so every one of those crates bypassed until 0.30.0. Procedural macros can read files no build script declares, which Cargo’s own freshness misses. To reuse their consumers safely, the compiler driver now watches what each loaded macro reads through the C lib and, on Linux, through the kernel. That’s a lot of machinery to answer the question “what did this crate depend on?”
When comparing Cargo-Rail with sccache, I care most about which compiler work each can reuse. sccache’s own Rust notes say crates that invoke the system linker (bin, dylib, cdylib, proc-macro) can’t be cached, and that procedural macros reading files may not cache properly. Cargo-Rail reuses linked outputs when it has complete linker evidence, and it observes what macros read before reusing their consumers. Both tools leave incremental units to Cargo. Also, like sccache, Cargo-Rail works better without incremental compilation.
I’m not publishing a head-to-head number: the released benchmark compares the two on one local chunk, and it deliberately stops short of claiming either restores more. Measure the builds you actually run and let me know what you find.
Cargo-Rail has to own Cargo’s global rustc-wrapper setting. Windows setup works, but native result reuse still bypasses there. Some host qualification remains incomplete, and cold capture has a cost. When the cache can’t establish that reuse is valid, it falls back to Cargo.
Plan the work before paying for it
Most CI optimization starts with path globs. A glob can tell you where a file lives. It can’t tell you which packages, tests, docs, binaries, images, or platform rows a change can affect.
cargo rail plan combines Git changes, semantic manifest changes, Cargo ownership, reverse dependency impact, compiler-observed inputs, and repo policy. The result is a set of named work items with exact package, target, and variant selectors. Incomplete evidence widens the work item that owns it instead of quietly skipping work.
Cargo-Rail Action v10.1.1 installs the matching Cargo-Rail release, runs the planner once, and validates the saved plan and current checkout before it emits arguments to Cargo or nextest. Planning can happen in one job and execution in another without turning explanation text into authority.
Across three of my workspaces, affected planning has removed enough local and CI work that I notice it in both dev wait time and my monthly bill. I don’t have a reproducible percentage I can defend across other repos. The saving comes from skipping compilation, transfer, and scheduling for work the captured graph says can’t be affected.
One plan can scope local checks, CI jobs, target matrices, and the compiler work those jobs feed into the cache. The alternatives usually rebuild that decision at every boundary.
0.30.0 added cargo rail plan evidence, which records the files each compiled unit read by running a recorder as RUSTC_WRAPPER during an ordinary build. That lets the planner skip cargo.build for a README-only change, but it means matching Cargo’s view of a unit to the rustc invocation Cargo actually ran. They don’t always agree. For a harness = false bench, Cargo reports a test-mode binary while rustc receives neither --test nor --crate-type, and Cargo metadata doesn’t expose harness at all. I’m working on a fix for that now.
Even Cargo’s config identity is a reconstruction. In 0.30.0, a doubled separator in a Windows PATH entry changed it, and a saved plan the Action had already verified failed verification when a shell launched Cargo-Rail directly. 0.30.1 fixed that.
0.30.0 also added cargo rail plan --cases, which compares reviewed path cases with the planner’s decisions before you migrate CI. If you can’t predict what the planner will select, you shouldn’t trust it in CI yet. Frankly, as a pre-v1 tool, you should have a stable fallback in place as Iggy’s CI pipe does.
Write release intent while it’s still known
Release notes assembled on release day are usually nonsense. The author already knew what changed, who it affects, whether it breaks anything, and how to migrate. That knowledge should be reviewed with the code. This was something that bothered me endlessly. It’s also something that agents are great at automating.
cargo rail change records that intent in a small .changes/ file. It names the affected crates and version bumps, then carries authored Markdown for the release note. Separate files avoid the shared-changelog conflict and keep the user-facing explanation inside the pull request, where reviewers can still improve it. This has improved my own releases significantly… and it’s removed the mental overhead of going back to fix the jumbled text pre/post release.
cargo rail change add my-crate --bump patch --message "Fixed connection retries"
cargo rail change check --merge-base
cargo rail release check --all --publicationcargo rail release consumes those reviewed changesets, computes the dependent release closure, updates versions and lockfiles, and writes changelogs. It validates the exact release commit, creates tags and configured native assets, publishes in dependency order, and records enough state to resume an interrupted transaction. Hosted execution and protected-branch release PRs continue the same intent; the Action doesn’t carry a second release implementation.
The design starts from the failure case. A closed terminal, lost runner, uncertain upload response, or reviewed merge shouldn’t force a human to guess which effects already happened. Cargo-Rail reconciles retained evidence and stops when that evidence is missing or contradictory. Registry publication stays denied until the repo explicitly authorizes it.
Release is the hardest path Cargo-Rail ships today. Cargo-Rail coordinates versions, tags, changelogs, assets, and recovery around cargo publish. As of 0.30.1, Cargo-Rail carries about 13,500 lines in src/release/ to hold that transaction together. Better access to Cargo’s workspace model could simplify the planning. Release policy and recovery are responsibilities I chose to take on.
Releasing Cargo-Rail 0.30.0 with Cargo-Rail took three workflows, a re-dispatched CI run across every platform, and a manual rerun after a packaging job hit GitHub’s API rate limit. The release shipped, but I wouldn’t ask anyone else to run it that way. Rebuilding the release path from its two entry points (one local command, one short workflow) is the next major piece of work.
Rust “changesets” are great, but the release path isn’t ideal. It needs simplification.
Keep the monorepo and the crate repository
The usual monorepo argument assumes development and distribution have to share a repo boundary. They don’t really have to do that anymore.
cargo rail split extracts selected crates with the Git history and Cargo manifest context their destination needs. cargo rail sync maps later commits in both directions. When both sides change the same code, it uses Git’s three-way merge, stops before publishing unresolved state, writes a conflict receipt, and resumes with cargo rail sync --resume after a human resolves it.
That lets me keep one canonical dev monorepo while giving an OSS crate its own repo. Internal work moves out, and external contributions move back. The connection is maintained as a transaction instead of a recurring copy job.
This isn’t perfect. It needs more real world testing. It’s a start, though.
Every Cargo plugin pays the same tax
Even one command may need several answers from Cargo. ResolutionViews calls cargo metadata again for every distinct feature or target view, because one metadata result can’t answer every question about one command.
Cargo-Rail can share one model internally, but it still reconstructs that model from outside Cargo. It still loads metadata, manifests, source state, target views, and compiler observations, then derives the narrowest decision the evidence allows. Cargo owns the final build and must win whenever the two interpretations disagree.
None of this is unique to Cargo-Rail, either. rust-analyzer, nextest, cargo-hakari, sccache, and every single release bot rebuild their own slice of the same facts. Cargo-Rail does it once per command instead of once per tool, which is a real improvement. It isn’t a fix. It’s a patch where nothing else existed.
This is why I wrote A Second Vision for Cargo in response to Ed Page’s A Vision for Cargo. Ed and I want many of the same outcomes: better dependency management, caching, plumbing, build extensibility, and a healthier architecture for Cargo. My argument is about where to begin. Cargo needs one command-scoped chain from captured inputs through resolution, planning, execution, and results, so external tools can consume those facts instead of rebuilding Cargo in parallel. That interface would need careful design, with compatibility guarantees Cargo’s maintainers can sustain.
Cargo-Rail makes that reconstruction reusable, but the workarounds also show what Cargo could eventually own. When evidence is missing, planning widens, cache reuse bypasses, and mutation stops. If Cargo ever exposes the facts it already holds, I’d happily delete most of the code this post describes. In fact, I basically pray to wake up and find Cargo has made Cargo-Rail moot. It will have made all of our lives better.
Adoption is useful evidence, including the friction
Cargo-Rail dogfoods Cargo-Rail. Its current CI uses affected planning, remote compiler reuse, cache reporting, and dependency policy. Its pre-release path runs Surface and then Cargo-Rail’s own release engine. rscrypto’s CI shares compiler results across a large native and cross-target matrix.
rscrypto is also where I hit my own limits. Its release path used to run Surface before preparation. On October 1, I paused it because Surface’s target preflight can’t yet pass for a crate that declares sixteen target views, from x86-64 Windows to bare-metal RISC-V. Surface is still there for manual runs, but it isn’t a release gate for rscrypto right now.
Apache Iggy merged dependency-DAG test scoping in April, and its upgrade to Cargo-Rail 0.30.1 is open under ASF review. Prosody merged its v10 migration on October 2; its quality workflow routes build, test, documentation, coverage, and infrastructure work through the Action. Eryx runs cargo rail unify --check on 0.30.1.
A GitHub code search on September 17 found current or historical workflow references in at least thirteen public repos outside my own, including Iggy, the Prosody family, Eryx, Tooned, Updog, ForgeGuard, Neusym, and Supergreen.
Not every integration stuck. The reconstruction tax again, paid by users this time.
The point is, for Cargo-Rail to really find the ideal shape… it needs feedback. I need outside GH issues; I need contributions.
What happens next
I’m focusing on DX, code quality, and test coverage. The CLI, config, and machine contracts need to get smaller and more predictable before I pretend there’s a meaningful stable/nightly split. I hope the APIs will start solidifying soon; they aren’t solid today. cargo rail config migrate, new in 0.30.0, already prunes a config file down to the policy you actually chose, and I want more of the product to move in that direction.
Dependency removal has started. In 0.30.0, the S3 and Azure providers became optional Cargo features: cargo install cargo-rail --locked no longer builds the AWS and Azure SDKs, which accounted for 88 of 327 dependency packages, and the binary shrank from 45 MB to 31 MB. The release path gets rebuilt next. The cache work continues after that: decompose the native cache, close compiler-runtime evidence gaps, bring Windows reuse and workers to parity, extend the benchmark to remote and distributed modes, and qualify the release hosts we claim.
Cargo-Rail needs more contact with real Rust repositories. I want bug reports, incorrect plans, cache bypasses, confusing configuration, hostile workflows, missing targets, bad defaults, and cases where the tool makes the developer experience worse. Real feedback, where someone tells me exactly why it didn’t earn its place, is worth more right now than another feature idea.
I’m genuinely starting to explore what Cargo would look like if the lessons from Cargo-Rail, the myriad of Cargo plugins, and the mountain of scripts we all keep were applied. I’m starting to think about Cargo’s ‘uv’ moment… where we get a build system and package manager that’s significantly better. I also sympathize with engineers adopting agentic workflows and running into familiar build bottlenecks. Rust compilation is expensive, and Cargo-Rail has convinced me that better dependency management, planning, and reuse can reduce how much of it we need.
I will keep the community updated.
Install the complete native archive if you want cache reuse or Surface. cargo install cargo-rail --locked still supplies the general CLI; add --features s3,azure if you need a remote cache from a source build. Then start with the read-only paths: run cargo rail unify --check --show-diff --explain, inspect cargo rail plan --explain, or audit a release candidate with cargo rail surface --check --explain. If one result is useful, replace the plugin, script, or manual step that used to make the same decision.