Rust monorepos are painful. Your dependency graph drifts, CI runs too much, and extracting crates for OSS means Copybara (Java) or git subtree hell.
After 18 months of fighting these problems, I built cargo-rail — 13 direct dependencies, four workflows, zero workspace-hack crates.
My justfile had grown to over 1k lines. I had thirty shell scripts just to run tests. I wanted to release a few crates to the OSS community but git subtree was a project unto itself, and Copybara meant pulling in Java tooling. I searched for a solution to modern Rust monorepo challenges and couldn't find one that fit, so I built cargo-rail.
cargo-rail is a single Cargo subcommand that unifies dependencies, plans and executes graph-aware CI, splits/syncs repositories, and automates releases for Rust workspaces — with a small dependency footprint.
TL;DR: cargo-rail makes Rust monorepos lean, boring, and safe. It keeps your dependency graph clean, finds hidden workspace-feature bugs, runs checks only where changes matter, lets you split crates with full history, and automates releases.
Quick start:
cargo install cargo-rail cargo rail init cargo rail unify --check
Quick links:
Who This Is For
This article is for you if:
- You maintain a Rust workspace with multiple crates (a monorepo or a "fat" workspace).
- Your CI is slow or expensive and you'd like to only run what actually changed.
- You care about dependency hygiene and supply-chain surface area.
- You want to stay on Cargo (not Bazel/Buck2/Moon) but still have a first-class monorepo story.
The Four Problems
Rust monorepos are hard because:
- Build graphs get bloated and misaligned.
- CI slows down without graph-aware planning.
- Extracting OSS crates cleanly (with history) is painful.
- Release tooling is often heavy and expands your supply chain.
Here's how cargo-rail approaches each of these.
1. The Build Graph
Rust is notorious for compilation times and large dependency graphs. I needed a way to keep my graph honest: unify versions against resolved metadata, prune dead features, drop unused deps, and compute MSRV from what I actually depend on.
I also wanted to avoid the "workspace-hack crate" model from cargo-hakari. I didn't want another synthetic crate, and I didn't want another maintenance lane in CI.
While building this, I found a class of hidden bugs: undeclared features borrowed across workspace members. Cargo's workspace feature unification can mask these until isolated builds or publish-time. cargo-rail now detects and can auto-fix them.
2. Change Detection
I'm a solo startup founder working on low-level systems code. I run lots of variants:
- Unit tests, integration tests, and doc-tests.
- Property tests and concurrency tests via
loomandshuttle. - Memory safety checks with
Miri. - Mutation tests, fuzzing, and benchmarks via
Criterion. - Multiple sanitizer setups across targets.
Every just check was running cargo fmt, cargo check, cargo clippy, cargo doc, cargo audit, and cargo deny for far more code than needed.
Without robust change planning, development velocity drops hard. I needed to run only what changed, deterministically, without a maze of custom scripts.
3. Distribution
The first crates I built were strong OSS candidates. I wanted to split them from a private/canonical monorepo into clean public repos with full history.
That workflow is deceptively hard. I could script git subtree forever and hope I didn't cut history wrong, or I could pull in Copybara. Neither was attractive.
4. Release, Publish, and Maintain
Once I looked seriously at split/sync, release automation was the obvious next bottleneck.
There are excellent tools in this space. But many pull in very large dependency graphs for comparatively straightforward tasks. I wanted a workflow with a smaller attack surface and fewer moving parts.
The Solution
How It Works
cargo-rail is built on two things every Rust team already has: git and Cargo.
It uses system git, Cargo metadata, and a deterministic planner contract to keep local and CI behavior aligned. No daemon, no background scheduler, no parallel ecosystem of scripts.
At a high level, cargo-rail covers four workflows:
- Unify — keep your dependency graph clean, deduplicated, and honest about MSRV.
- Plan / Run — build a deterministic change plan, then execute only selected surfaces.
- Split / Sync — extract crates with full git history and keep them in sync with the monorepo.
- Release — plan and publish dependency-order releases with a minimal toolchain.
cargo install cargo-rail
# or via cargo-binstall for prebuilt binaries
cargo binstall cargo-rail
Why It's Different
- Single Cargo subcommand, no daemon, no external scheduler.
- Built on Cargo's resolved graph, not just manifest syntax or path filters.
- Uses your system
gitdirectly; nolibgit2orgitoxide. - Planner-first model (
plan+run) keeps local and CI logic aligned. - Direct dependency footprint is small (
Cargo.tomlcurrently has 13 direct deps).
The rest of this post walks through the four workflows in detail.
Workflow A: Dependency Unification — Optimize Your Dependency Graph
Commands: cargo rail init, cargo rail unify
cargo rail init # generates .config/rail.toml
cargo rail unify --check # preview changes (CI-safe, exits 1 if changes needed)
cargo rail unify # apply changes (auto-backups by default)
cargo rail config sync # refresh/add new config fields after upgrades
What unify does for each configured target triple:
- Unifies versions based on what Cargo actually resolved (no syntax parsing).
- Detects/fixes undeclared features that are only satisfied by workspace-wide feature unification.
- Computes MSRV via configurable policy (
deps,workspace, ormax). - Prunes dead features that are never enabled (empty no-op features).
-
Detects/removes unused deps using graph + compiler diagnostics (
unused_crate_dependencies). -
Pins transitives at the workspace root (configurable) as a
cargo-hakarireplacement.
Wire cargo rail config sync into your post-upgrade flow. It preserves existing settings/comments while adding missing defaults.
This solved my first problem. Build graphs in local and CI runs became leaner and easier to reason about.
Workflow B: Change Detection — Test Only What Changed
Commands: cargo rail plan, cargo rail run
Originally I used path filters and shell scripts. It worked until it didn't.
Now it's planner-first:
cargo rail plan --merge-base --explain # show what changed and why
cargo rail plan --merge-base -f json # machine contract for CI
cargo rail run --merge-base --profile ci # execute planner-selected surfaces
cargo rail run --all --surface test # override and run full test surface
The planner classifies changes into surfaces like build, test, bench, docs, and infra. Config lives in [change-detection] and [run] inside rail.toml.
GitHub Action
cargo-rail-action wraps planner output for CI:
jobs:
plan:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.rail.outputs.build }}
test: ${{ steps.rail.outputs.test }}
docs: ${{ steps.rail.outputs.docs }}
infra: ${{ steps.rail.outputs.infra }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: loadingalias/cargo-rail-action@v3
id: rail
with:
since: ${{ github.event.pull_request.base.sha || github.event.before }}
ci:
needs: [plan]
if: needs.plan.outputs.build == 'true' || needs.plan.outputs.test == 'true' || needs.plan.outputs.infra == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- run: cargo rail run --merge-base --profile ci
This is where I saw the biggest day-to-day velocity jump: fewer wasted runs, fewer custom scripts, clearer reasoning.
Try it now:
cargo install cargo-rail && cargo rail init && cargo rail plan --merge-base --explain
Workflow C: Split & Sync — Split Crates and Sync Repos with Full History
Commands: cargo rail split, cargo rail sync
This is high-risk transformation work. If you get it wrong, you lose history or break developer flow.
cargo rail split init my-crate # add split config to rail.toml for one or many crates
cargo rail split run my-crate --check # preview split
cargo rail split run my-crate # execute split with git history
cargo rail sync my-crate # bidirectional sync w/ conflict strategy
cargo rail sync my-crate --to-remote # monorepo -> split repo
cargo rail sync my-crate --from-remote # split repo -> monorepo
Design decision: monorepo to split is treated as canonical-forward; split to monorepo is review-oriented. Conflict strategies include manual (default), ours, theirs, and union.
This gives you:
- Clean extraction of crates with full git history.
- A way to open-source selected crates while keeping private work private.
- A path to collaborate in split repos and merge changes back safely.
Three effective modes:
- Single: One crate -> new repo
- Combined + standalone: Multiple crates -> one repo, separate crates
- Combined + workspace: Multiple crates -> extracted workspace layout
Workflow D: Release — Automate Safe, Minimal Releases
Commands: cargo rail release
cargo rail release init crate # define release config
cargo rail release check crate # fast validation
cargo rail release check crate --extended # publish dry-run + MSRV validation
cargo rail release run crate --check # preview release plan
cargo rail release run crate --bump minor # execute: version, changelog, tag, publish
Release computes publish order from the workspace dependency graph. Configuration lives in rail.toml:
[release]
tag_prefix = "v"
tag_format = "{crate}-{prefix}{version}"
require_clean = true
changelog_path = "CHANGELOG.md"
Version bump, changelog, tag, publish, and optional GitHub release can all run through one command surface.
I've also released cargo-rail using cargo-rail.
Minimal Attack Surface
I care about supply-chain security. Monorepo orchestration should be aggressively simple.
cargo-rail currently has 13 direct dependencies in Cargo.toml. On the current v0.10.12 lockfile, cargo tree -e normal -p cargo-rail resolves to 56 non-root crates.
That is still materially smaller than the "several separate tools with hundreds of deps" stack many teams end up with.
Real-World Validation
I've tested across public Rust workspaces with real history and real CI constraints.
Here are current cargo rail unify results from validation forks:
| Repository | Crates | Deps Unified | Undeclared Features | MSRV |
|---|---|---|---|---|
| tokio | 10 | 13 | 7 | 1.85.0 |
| helix | 14 | 28 | 19 | 1.87.0 |
| meilisearch | 23 | 70 | 215 | 1.88.0 |
| helix-db | 6 | 21 | 17 | 1.88.0 |
Totals across 53 crates:
- 132 dependencies unified
- 258 undeclared features fixed
- 2 dead features pruned
Validation artifacts and reproducibility material live in: github.com/loadingalias/cargo-rail-testing
What I Replaced
In my own workspace, cargo-rail let me remove:
| Before | After |
|---|---|
cargo-hakari |
cargo rail unify with pin_transitives = true
|
cargo-features-manager |
cargo rail unify with prune_dead_features = true
|
cargo-udeps, cargo-machete, cargo-shear
|
cargo rail unify with detect_unused = true
|
cargo-msrv |
cargo rail unify with msrv = true
|
dorny/paths-filter + 1k LoC shell |
cargo rail plan + cargo rail run
|
release-plz, cargo-release, git-cliff
|
cargo rail release |
| Google Copybara / git-subtree |
cargo rail split + cargo rail sync
|
Results:
- Change planning/execution replaced most custom CI shell glue.
- CI costs dropped materially on my own workloads.
- 258 hidden feature issues were surfaced and fixed across validation repos.
- Workflow is leaner and easier to reason about.
When Not to Use cargo-rail
- If you're already on Bazel, Buck2, or Moon and you like that model, cargo-rail is not the right tool.
- If you have a single crate with simple CI, cargo-rail is more machinery than you need.
- If you want a general-purpose build system for a polyglot monorepo, cargo-rail is Rust-only and Cargo-native.
cargo-rail fits when you have a Rust workspace and want to keep using Cargo while solving dependency hygiene, deterministic planning, split/sync, and release.
Design Decisions
Multi-Target Resolution: cargo-rail runs cargo metadata --filter-platform per target and intersects results with guardrails. If it marks something unused, it is unused across configured targets.
Resolution-Based, Not Syntax-Based: cargo-rail uses resolved Cargo metadata, not raw manifest guesswork.
System Git: Uses your system git directly. No libgit2, no gitoxide.
Lossless TOML: Uses toml_edit to preserve comments/formatting in manifests.
Planner Explainability: cargo rail plan --explain, cargo rail graph, and run receipts in target/cargo-rail/receipts/ make decisions auditable.
Try It
You don't need to restructure anything. Start small.
If you only do one thing after reading this, run:
cargo install cargo-rail
cargo rail init
cargo rail config validate
cargo rail unify --check # preview unification for your workspace
This runs in dry-run mode, creates no changes, and gives you a concrete "before vs after" for dependency hygiene.
If it looks reasonable:
cargo rail unify
If you want to roll back:
cargo rail unify undo
Then, when you're comfortable:
# See what changed and why
cargo rail plan --merge-base --explain
# Execute planner-selected test surface only
cargo rail run --merge-base --surface test
Migrating from cargo-hakari?
git checkout -b migrate-to-rail
# Remove workspace-hack crate and hakari.toml
cargo rail init
# Edit rail.toml: set pin_transitives = true
cargo rail unify --check
cargo rail unify
cargo check --workspace && cargo test --workspace
Full migration guide: docs/migrate-hakari.md
Roadmap
Short version: keep cargo-rail small, deterministic, and focused on Rust workspaces.
Near-term:
- Stronger presets/examples for large public workspaces.
- Better custom-surface CI patterns and reporting.
- Sharper split/sync and release safety rails.
Longer-term:
- Easier integration with existing tooling (
nextest,cargo-deny, custom pipelines). - Keep planner contracts stable and improve hash/diff-hash/graph explainability workflows.
I'll keep the roadmap in the repo up to date; feedback and critiques are welcome.
Get Involved
-
Try it:
cargo rail unify --checkon your workspace. -
Use it: Wire
cargo-rail-actioninto CI. - Star it: github.com/loadingalias/cargo-rail helps other teams find it.
- Critique it: If a design decision is wrong, open an issue.
If you maintain a public Rust workspace and want a low-risk evaluation, I'm happy to open a small PR wiring in cargo-rail so you can see real impact before committing.
What's your biggest Rust monorepo pain point?
Links:
- GitHub: cargo-rail
- Crates.io: cargo-rail
- GitHub Action: cargo-rail-action
- Validation Forks: cargo-rail-testing
- Migration Guide: migrate-hakari
- Change Detection Guide: change-detection
Built by @loadingalias
Top comments (0)