Changesets for Rust: How cargo-rail Plans Releases from the Workspace Graph

rust dev.to

Every Rust release tool I've used works the same way: on release day, it reads your commit messages, guesses what changed, guesses how much to bump, and generates a changelog that reads like git log with emoji. We hate emoji.

The JS ecosystem moved past this years ago with changesets: release intent is written when the change happens, reviewed in the same PR as the code, and consumed at release time. It's one of the few pieces of JS tooling I was jealous of... and I'm not jealous of much in JS.

So, I've built it for Cargo workspaces. As of v0.15, cargo-rail ships a changesets-style release workflow — plus something the JS version doesn't have: changelog attribution through the workspace dependency graph instead of path globs.

TL;DR: cargo rail change add stores reviewed release intent as MD files in .changes/. cargo rail release run --bump auto folds that intent - plus conventional commits, cargo-semver-checks signals, and dependency updates - into a dep-ordered release plan: bumps, per-crate changelogs, release PR, tags, forge releases, and publish. Everything is dry-runnable. Commits are attributed to crates through the resolved workspace graph, not path filters.

cargo install cargo-rail
cargo rail release init
cargo rail release run --all --bump auto --check   # read-only preview

Quick links: GitHub · Crates.io · Migrating from git-cliff/release-plz


The Problem w/ Commit Inference

Conventional commits are a fine signal. They're a terrible foundation — especially in a Rust workspace:

  1. Path globs misattribute changes. A commit touching crates/core/src/lib.rs and crates/cli/src/main.rs belongs to two crates. Glob-based changelog tools either duplicate it everywhere, drop it, or route it by whichever pattern matched first. Cross-cutting refactors and infra commits get it wrong constantly.
  2. Commit messages are written for reviewers, not users. fix: handle edge case in parser is a fine commit. It's a useless changelog entry. Nobody rewrites commit messages three weeks later on release day... even if we tell ourselves we should. Hell, we don't even use AI tooling to do it. It just gets forgotten about.
  3. The bump decision happens at the worst possible time. Whether a change is breaking is best judged by the person who made it, at the moment they made it — not inferred from a ! someone forgot to type.
  4. Nothing is reviewed. The changelog and version bump materialize on release day. If they're wrong, you find out after publishing.

I wanted release notes and bump decisions to go through code review like everything else. I wanted a single, unified, non-fragmented release workflow for my Rust workspaces that just worked... and as usual, without the supply-chain nightmare.


Change Files: Reviewed Release Intent

cargo rail change add creates a change file — MD w/ TOML frontmatter — in .changes/:

cargo rail change add my-crate --bump minor -m "Added graph-aware release planning."
Enter fullscreen mode Exit fullscreen mode
---
"my-crate" = "minor"
---

Added graph-aware release planning.
Enter fullscreen mode Exit fullscreen mode

One file can name multiple crates w/ different bump levels:

---
"rail-core" = "minor"
"rail-cli" = "patch"
---

Core gained auto bump planning; the CLI picked up the new flag.
Enter fullscreen mode Exit fullscreen mode

The workflow:

  • You (or a contributor) add the change file in the PR that makes the change. Omit -m and it opens $EDITOR.
  • Reviewers see the release note and the bump level next to the diff. We can now catch a wrong bump level in review, not after we've published.
  • cargo rail change status shows pending intent, folded per crate (max bump wins).
  • At release time, change files are consumed: their bodies render as highlights at the top of the changelog section - human-written prose first, commit history second - and the files are deleted in the release commit.

Consumption is all-or-nothing: cargo-rail refuses a release plan that would delete a change file naming a crate outside the plan. No pending intent gets silently lost.

Want to enforce it? require_change_files = true (or a per-crate list) in your rail.toml config makes release check fail when code changes lack a change file - the same gate changesets-bot plays in JS land... but built into cargo-rail's release flow.


--bump auto: A Precedence Chain, Not a Guess

Change files don't replace commit inference — they outrank it. For each crate, --bump auto resolves the bump from:

  1. Change files — explicit, reviewed intent.
  2. Conventional commits — breaking → major, feat → minor, fix/perf → patch.
  3. cargo-semver-checks — if enabled (semver_check = "warn" or "deny"), API-level breakage detection. External tool, never vendored.
  4. Dependency updates — a crate whose workspace dependency bumped gets a patch.
  5. Nothing? The crate is skipped, with the reason recorded in the plan.

Pre-1.0 semantics are configurable: pre_1_breaking_bump = "minor" (default) or "major". And --bump auto refuses to run in a shallow clone, because missing tags mean wrong answers.


Graph-Aware Changelogs

This is the the novel bit of cargo-rail's release workflows... the part I haven't seen anywhere else, in any ecosystem.

Every other workspace changelog tool attributes commits to crates with path globs — you maintain per-crate include/exclude patterns and hope. That model silently drops tags, misroutes cross-cutting commits, and can't express intent. Not to mention, it's added work. Even with the clankers... you're still left to remember to manage it.

cargo-rail already has a better source of truth: the same file-to-crate mapping the CI planner uses to decide which tests to run. The changelog engine reuses it:

  • One git log --name-only per release range (not per crate).
  • Every changed file maps to its owning crate through the workspace graph — an O(1) lookup against Cargo's resolved metadata.
  • A conventional-commit scope naming a workspace crate narrows attribution to that crate. Scope is an explicit human signal; files are the fallback truth.
  • Crates released only because a dependency bumped get a synthesized entry: - updated <dep> to <version>.

The rendering layer is a custom generator — winnow-based conventional-commit parsing (with typo suggestions: hore → "did you mean 'chore'?"), O(1) type-to-section classification, per-crate CHANGELOG.md. Configurable sections, entry formats, commit/PR links (GitHub auto-inferred; GitLab and self-hosted via URL templates), and filters. One rule is non-negotiable: breaking changes are exempt from skip_types - you can't configure a changelog that silently drops a breaking change.

No git-cliff. No template engine. No new dependencies — cargo-rail is still 13 direct deps, 56 crates resolved on the current lockfile; it has now quietly replaced release_plz for me, too.


Workspace-Shaped Releases

Releasing one crate is easy. Releasing a workspace is a graph problem:

  • Publish order comes from the dependency graph. Dependencies publish before dependents, with a configurable delay for crates.io propagation.
  • Cascading bumps: when rail-core bumps, dependents' manifests and [workspace.dependencies] are rewritten, and their changelogs record the dependency update.
  • Closure safety: releasing a crate while leaving its dependents on a stale version is rejected by default. --include-dependents expands your selection to the full transitive closure instead.
  • Version groups: [release.version_groups] declares lockstep sets - the group releases together at the max bump any member earned, like a fixed group in JS changesets.
[release.version_groups]
rail = ["rail-core", "rail-cli", "rail-macros"]
Enter fullscreen mode Exit fullscreen mode

Two Release Shapes

Direct — for solo repos and small teams:

cargo rail release run --all --bump auto --check   # preview
cargo rail release run --all --bump auto           # bump, changelog, commit, tag, push, publish
Enter fullscreen mode Exit fullscreen mode

Release PR — for teams that want the release itself reviewed:

cargo rail release run --all --bump auto --pr --yes   # branch + bumps + changelogs -> opens PR
# ... review, merge ...
cargo rail release finalize --all --yes               # tag, push, forge release, publish
Enter fullscreen mode Exit fullscreen mode

The --pr flow only touches manifests, changelogs, and the lockfile - no tags, no publish. finalize runs from updated main, verifies the changelog sections actually exist for the versions in the manifests, then does the irreversible parts.


Safe by Default

Release automation is the one place where "oops" means a yanked crate... which I can't stand. So every mutating step is explicit and inspectable with the obvious goal of stopping that before it happens:

  • --check prints the full plan and exits — including a typed mutation plan: BUMP_VERSION, UPDATE_CHANGELOG, CREATE_TAG, PUSH_RELEASE_REFS, PUBLISH_CRATE, each annotated with its risk class (CRATES_IO_PUBLISH, REMOTE_PUSH, ...).
  • Every apply writes a receipt before running, so you can audit exactly what was planned.
  • Preflight checks before anything irreversible: remote exists, push dry-run succeeds, no colliding remote tags, gh/glab present and authenticated.
  • Pushes are atomic — release commit and tags land together or not at all.
  • GitHub releases are created as drafts pinned to the exact pushed commit, and flipped public only after crates publish successfully. No "release exists but crates.io publish failed" limbo.
  • Tags can be GPG/SSH-signed (sign_tags = true). You are signing your tags, right?

Example --check output:

📦 Release Plan

1. my-crate
   Version: 0.1.0 → 0.2.0
   Bump: 0.1.0 -> 0.2.0 (auto: change files -> minor)
   Tag: v0.2.0
   Publish: ✓
   Causes: f956ff8 (minor)

Summary: 1 crate(s), 1 to publish, 1 tag(s), 0 skipped
Enter fullscreen mode Exit fullscreen mode

And yes — cargo-rail releases itself with this exact flow. Every version since v0.1.0.


Config

[release]
tag_prefix = "v"
tag_format = "{crate}-{prefix}{version}"   # auto-collapses to "{prefix}{version}" for single-crate repos
require_clean = true
push = true
create_github_release = true
change_dir = ".changes"
unconventional_commits = "warn"            # allow | warn | deny
semver_check = "warn"                      # off | warn | deny (external cargo-semver-checks)
require_change_files = false               # true, or ["crate-a", "crate-b"]

[release.changelog]
path = "CHANGELOG.md"
emoji = true

[release.changelog.filters]
skip_types = ["chore", "ci"]               # breaking changes are exempt, always
Enter fullscreen mode Exit fullscreen mode

Every changelog key is overridable per crate under [crates.NAME.changelog]. cargo rail release init generates sane per-crate config for your whole workspace in one shot... but I quite like customizing mine to the workspace. It's especially nice when working within a larger, zero-to-one codebase.


Migrating

From To
cargo-release cargo rail release run (graph-ordered publish built in)
git-cliff [release.changelog] config + graph attribution instead of path globs
release-plz cargo rail release run --all --bump auto --pr + finalize
Manual .changes-like conventions cargo rail change add / change status

Guide: docs/migrate-git-cliff.md


Not for You If

  • You're happy w/ pure commit inference and single-crate releases — cargo-release and release-plz are great tools and simpler for that.
  • You need Gitea forge releases (unsupported today; GitHub via gh, GitLab via glab).
  • You want a hosted release bot. cargo-rail is a CLI you run locally or in CI, on purpose.

Try It (Read-Only; Safe)

cargo install cargo-rail          # or: cargo binstall cargo-rail
cargo rail release init --check   # preview generated config
cargo rail release init
cargo rail release run --all --bump auto --check
Enter fullscreen mode Exit fullscreen mode

That last command touches nothing - it just shows you what a release of your workspace would look like right now: bumps, causes, changelog sections, publish order, skipped crates and why.

If your team has ever spent a release day reverse-engineering three weeks of commits, I'd REALLY like to hear how this holds up on your workspace. Issues and critiques welcome: github.com/loadingalias/cargo-rail

Star on GitHub

Built by @loadingalias. Previous post: cargo-rail: Making Rust Monorepos Boring Again

Source: dev.to

arrow_back Back to Tutorials