I wrote up the two days I lost releasing a seventeen-crate workspace to crates.io as a war story, wrong turns and all. This is the other half: the field guide, so you don’t have to lose the same two days.
release-plz is a genuinely good tool, and none of what follows is a bug. It’s three behaviours that are entirely within its design and will still ambush you the moment you point it at a Cargo workspace rather than a single crate. Mildest first, because the third is the one that actually ate my release.
First, what release-plz is doing
In one line: it’s release-please for cargo. It keeps a Release MR open, bumps your versions and per-crate changelogs from your Conventional Commits, and when that MR merges it publishes every crate to crates.io and tags the release. On a workspace where N crates all share one version, “the release” is N publishes and N tag operations. Hold on to that N. It’s hiding behind all three traps.
Trap 1: the default tag template is built for one crate, not a workspace
You will reach for one tag per version, and for me it was more than tidiness. I
wanted to ship the whole framework as a single release: one v0.5.1 covering all
seventeen crates, because that was the compatibility promise I wanted to make.
Use the crates that share a version and they’re guaranteed to work together. A
single tag felt like the natural way to say “this is one coherent release of the
whole thing” (and it didn’t hurt that the repo already had a v0.5.0 tag from
before release-plz, so one unified tag also looked like continuity). So you either
set this, or, worse, you leave git_tag_name unset assuming the default does
something workspace-aware:
git_tag_name = "v{{ version }}"
Here’s the catch. release-plz’s default git_tag_name is v{{ version }}, and
release-plz tags per crate. So the first crate publishes and creates the tag
v0.5.1. The second crate publishes and tries to create v0.5.1 again:
ERROR failed to create git tag 'v0.5.1'
"message": "Tag v0.5.1 already exists"
By the time you read that error, the first crate (and on a retry, the next, and the next) is already live on crates.io, and crates.io publishes are forever. Leaving the line out doesn’t save you, because the default is the same single-crate-shaped template. This is the trap I walked straight into on the release commit.
Trap 2: “one release for the whole workspace” isn’t a setting, it’s a category error
The natural next thought is “fine, I’ll keep one tag but configure release-plz to roll the crates into a single release.” There’s no knob for that, and chasing one is a waste of an afternoon. release-plz’s model is per-crate all the way down: per-crate tags, per-crate GitLab/GitHub releases, per-crate changelogs. “One unified release for the whole workspace” isn’t an option it withholds, it’s a shape it doesn’t have.
So you stop fighting it and set the per-crate templates explicitly:
git_tag_name = "{{ package }}-v{{ version }}"
git_release_name = "{{ package }} v{{ version }}"
Now each crate gets its own tag (rtb-assets-v0.5.1, rtb-config-v0.5.1, and so
on) and its own release. It’s more objects per version than you wanted, but it’s
the grain the tool works in, and once you accept that the collisions stop.
This is where I had to pull apart two things I’d quietly merged in my head: the
version and the tag. The compatibility promise I cared about, that crates sharing
a version work together, is carried by the version, and release-plz keeps every
crate on the one workspace version no matter how it tags them. The tag is just a
label pointing at a commit. I’d wanted a single tag to mean “one coherent
framework release”, but the coherence was always in the shared version number, not
in the tag. Once that landed, seventeen tags stopped feeling like seventeen
releases of seventeen different things and started looking like what they are:
seventeen labels on one versioned release. The version is not the tag. If you still want
one human-facing narrative for the whole thing, keep a hand-written root
CHANGELOG.md alongside the generated per-crate ones, rather than trying to make
release-plz aggregate.
Trap 3: a release reads its config from the release commit, not HEAD
This is the small one, and the one that cost me the most, because it makes the fix for Trap 1 look like it isn’t working.
When release-plz runs a release, it does not read release-plz.toml from your
working tree. It reads it from the release commit, the commit that first
introduced the version it’s releasing. So picture the obvious recovery: you hit
the tag collision, you realise your template is wrong, you fix it in a follow-up
commit and push to main. Your fix is real. It’s committed. It’s on the default
branch. And it is completely ignored, because the version hasn’t changed, so the
release commit release-plz reads from is still the old one with the old template.
I didn’t take this on faith. With the corrected per-crate template sitting on
HEAD, the CI release job still tried to create the unified tag, pinned to the
old commit:
ERROR failed to create git tag 'v0.5.1' with ref 'f6de975...'
"message": "Tag v0.5.1 already exists"
That ref is the release commit, not the HEAD that held my fix. And the cruel
part: release-plz release --dry-run on your laptop reads your working-directory
config, so it renders the shiny new per-crate tags and tells you you’re sorted. CI
runs the real thing against the release commit and does something else entirely.
Same config file, two different answers depending on who’s asking, which is why
the war story has the title it does.
The operational rule that falls out of this: any release-plz config change that affects how a release behaves has to ride along with a version bump, or it does not apply. A “fix-up” commit on its own is a no-op.
If you set one thing
If you run release-plz on a multi-crate workspace and you change a single line from the defaults, make it the tag template:
git_tag_name = "{{ package }}-v{{ version }}"
And set it before your first release, not during it, so it’s already in the commit that introduces the version, because that’s the only commit a release will ever read it from. Everything else here follows from two facts: the grain is per-crate, and CI reads history while your laptop reads your working tree. Trust the history.
None of this is release-plz misbehaving. Every bit of it is documented and deliberate. It just isn’t where you’ll think to look until it has published six crates you can’t take back, which is roughly how I came to know it so well.
