Updated July 2026 for cargo-rail v0.15. The biggest change since the original post: release automation grew into a full changesets-style workflow - reviewed release intent in .changes/ files, graph-aware changelogs, release PRs, and --bump auto. There's a dedicated deep-dive post here: [https://dev.to/loadingalias/changesets-for-rust-how-cargo-rail-plans-releases-from-the-workspace-graph-2b17].
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 Cargo-native control plane for serious Rust workspaces: graph-aware CI, dependency unification, changesets-style releases, and crate split/sync — 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, stores reviewed release intent as change files, plans multi-crate releases from the workspace graph, and lets you split crates with full history.
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 release multiple crates and are tired of gluing together bump/changelog/tag/publish scripts.
- 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 infers everything from commits at the end, 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 most share two properties I didn't want: they infer everything from commit messages at release time, and they pull in very large dependency graphs for comparatively straightforward tasks.
Commit inference breaks down in workspaces. Path globs misattribute cross-cutting commits, changelogs read like git log, and nobody reviews release notes until release day. The JS ecosystem solved this years ago with changesets — reviewed release intent stored in the repo, next to the code that caused it. Rust didn't have that. Now it does.
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.
- Change / Release — store reviewed release intent as change files, then plan dependency-order releases with graph-aware changelogs.
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. - Changesets-style release intent instead of pure commit-message archaeology.
- 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 (first apply creates an undo backup)
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 through a configurable host 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 plan --merge-base -f github # GitHub Actions outputs
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. impact is diagnostic; scope is the execution handoff.
GitHub Action
cargo-rail-action wraps planner output for CI:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: loadingalias/cargo-rail-action@v4
id: rail
- name: Run selected CI profile
if: steps.rail.outputs.build == 'true' || steps.rail.outputs.test == 'true' || steps.rail.outputs.infra == 'true'
run: cargo rail run --since "${{ steps.rail.outputs.base-ref }}" --profile ci
- name: Run docs pipeline
if: steps.rail.outputs.docs == 'true'
run: cargo rail run --since "${{ steps.rail.outputs.base-ref }}" --surface docs
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: Change & Release — Changesets for Cargo Workspaces
Commands: cargo rail change, cargo rail release
This is the part that changed the most since I first published this post. Release grew from "version, tag, publish" into a full changesets-style workflow.
The core idea: release intent is written and reviewed when the change happens, not inferred from commit messages on release day.
# In the PR that makes the change:
cargo rail change add my-crate --bump minor -m "Added graph-aware release planning."
cargo rail change status
# On release day:
cargo rail release run --all --bump auto --pr --check # preview the plan
cargo rail release run --all --bump auto --pr --yes # open a release PR
# After the release PR merges, from updated main:
cargo rail release finalize --all --yes # tag, push, forge release, publish
Change files are plain Markdown with TOML frontmatter, living in .changes/ by default:
---
"my-crate" = "minor"
---
Added graph-aware release planning.
They get reviewed in the same PR as the code, and consumed (deleted) by the release commit. Change file bodies render as highlights at the top of the changelog section — human-written summaries first, commit archaeology second.
--bump auto reads change files first, then falls back to conventional commits, then cargo-semver-checks signals, then dependency updates. Crates with nothing release-worthy are skipped, with the reason recorded.
The changelog engine is graph-aware: commits are attributed to crates through the workspace dependency graph — the same file-to-crate mapping the CI planner uses — not path globs. A conventional-commit scope naming a workspace crate narrows attribution. Crates released only because a dependency bumped get a synthesized dependencies entry. No git-cliff, no template engine, no extra deps.
Release computes publish order from the workspace dependency graph, handles cascading dependent bumps, supports lockstep version groups, and refuses partial releases that would leave dependents out of sync. Everything is dry-runnable, and every apply writes a receipt first.
Configuration lives in rail.toml:
[release]
tag_prefix = "v"
tag_format = "{crate}-{prefix}{version}"
require_clean = true
push = true
create_github_release = true
require_release_notes = true
change_dir = ".changes"
[release.changelog]
path = "CHANGELOG.md"
For an owned GitHub release, set both push = true and create_github_release = true; cargo-rail pushes the release commit and tags atomically, publishes crates in dependency order, then flips the draft GitHub Release public.
I release cargo-rail using cargo-rail — every version since the beginning.
I wrote a full deep-dive on the release system, including the changesets design and graph-aware changelog attribution: [LINK TO NEW RELEASE POST].
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.15.0 lockfile, cargo tree -e normal -p cargo-rail --locked resolves to 56 unique non-root crates.
That number hasn't grown since v0.13 — even as release automation gained changesets, graph-aware changelogs, and release PRs. The changelog engine is a custom winnow-based generator, not a git-cliff dependency.
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 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
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 change + cargo rail release run --bump auto
|
| 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.
- Releases went from scripts to a reviewed, planned, dependency-ordered flow.
- 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.
- If you want release automation to infer everything from commits with no reviewed release intent, existing tools already do that well.
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.
Graph-Attributed Changelogs: Commits map to crates through the dependency graph, not path globs. Scope is an explicit human signal; files are the fallback truth.
Reviewed Release Intent: Change files are the primary bump/changelog signal; commit inference is the fallback, not the foundation.
Mutation Plans and Receipts: Every mutating release action is a typed, risk-annotated plan you can inspect with --check, and every apply writes a receipt before it runs.
Planner Explainability: cargo rail plan --explain, cargo rail graph, cargo rail hash, cargo rail diff-hash, 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
# Preview a release plan without touching anything
cargo rail release run --all --bump auto --check
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
Migrating from release-plz, cargo-release, or git-cliff?
cargo rail release init # generates per-crate release + changelog config
cargo rail release run --all --bump auto --check
Full migration guide: docs/migrate-git-cliff.md
Roadmap
Short version: keep cargo-rail small, deterministic, and focused on Rust workspaces.
Near-term:
- Stronger presets/examples for large public workspaces.
- Adoption tracks: pick up just release, just CI planning, or just unify — independently.
- Continued hardening of the release PR + finalize flow.
Longer-term:
- Easier integration with existing tooling (
nextest,cargo-deny, custom pipelines). - Keep planner contracts stable and continue tightening 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
- Migration Guide: migrate-hakari
- Migration Guide: migrate-git-cliff
- Change Detection Guide: change-detection
Built by @loadingalias
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.