Featured image of post Three traps release-plz sets for a Rust workspace

Three traps release-plz sets for a Rust workspace

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.

Built with Hugo
Theme Stack designed by Jimmy