Skip to content
rusthacks.dev

Release Plz and Cargo Release Compared for Crate Releases

cargo-release is a command you run; release-plz is a bot that opens a release pull request and publishes on merge. Compare control, review, workspaces, semver checks and which to pick.

5 min read
Summarize in AI
Flat illustration for the post “Release Plz and Cargo Release Compared for Crate Releases”, composed around deploy.

Two tools dominate Rust release automation and they disagree about who is driving. cargo release is a command you run, in your terminal, when you decide it is time. release-plz is a bot: it watches the main branch, prepares the next release as a pull request, and acts when you merge. Both end with a tag on git and a version on crates.io, and the choice affects how a team works more than what lands in the registry. This post runs each through the same scenario, a library with a breaking change and a fix since the last tag, and says which suits which maintainer. Tested with cargo-release 0.25, release-plz 0.3 and Cargo 1.99.

cargo release: a script you trigger

Shell
cargo install cargo-release --locked
cargo release minor

Without --execute, the command is a dry run. It prints each action it would take, the version it would write, the commit message, the tag name and the publish, and changes nothing. That default is the best design choice in the tool, since a release is the kind of command you want to read before it runs. When the plan looks right:

Shell
cargo release minor --execute

The sequence it runs is the one you would type by hand: verify the working tree is clean and on the right branch, bump version in Cargo.toml (and in other workspace crates that depend on it), update Cargo.lock, make a commit, create an annotated tag, run cargo publish, and push the commit and tag. You choose the bump, patch, minor, major, or an exact version, and it does not inspect your code to check you chose well. Configuration lives in release.toml or the manifest:

release.tomlTOML
allow-branch = ["main"]
sign-tag = true
pre-release-commit-message = "release {{crate_name}} {{version}}"
tag-name = "v{{version}}"
pre-release-replacements = [
  { file = "CHANGELOG.md", search = "Unreleased", replace = "{{version}}" },
  { file = "CHANGELOG.md", search = "\\.\\.\\.HEAD", replace = "...{{tag_name}}", exactly = 1 },
]

The replacements are what keep a hand-written CHANGELOG.md in step: the "Unreleased" heading becomes the version at release time. This is the model for a maintainer who writes the changelog as work lands and wants a one-command release. It is local, auditable, and has no service to run.

release-plz: a pull request that is the release

.github/workflows/release-plz.ymlYAML
name: release-plz
on:
  push:
    branches: [main]
jobs:
  release-plz:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: dtolnay/rust-toolchain@stable
      - uses: release-plz/[email protected]
        with:
          command: release-pr
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

On every push to main, the action compares the repository against the last published version, works out what changed, and opens (or updates) a pull request titled something like "chore: release v0.5.0". The PR's diff is the release: the new version in Cargo.toml, the updated lockfile, and a changelog section generated from commits. A second job with command: release publishes to the registry, creates the tag and the GitHub release when that PR is merged. The human work shrinks to reading the diff and pressing merge.

The feature that distinguishes it is the version choice. release-plz can run cargo semver-checks to detect breaking API changes and pick the bump accordingly, and it reads Conventional Commits for the rest: a feat: gives a minor, a fix: a patch, feat!: a major. It will not let a breaking change ride out as a patch because the check fails the PR. That turns the version number from a decision remembered at release time into a property enforced on every merge.

release-plz.tomlTOML
[workspace]
changelog_update = true
semver_check = true
publish_allow_dirty = false

[[package]]
name = "client-internal"
release = false

[[package]]
name = "client"
changelog_path = "CHANGELOG.md"

Comparing them on the same scenario

The library has an unreleased breaking change and a bug fix. Here is what each tool asks of the maintainer:

Stepcargo releaserelease-plz
Decide the versionyou pick minor, major or patchcomputed from commits and semver-checks
Write the changelogyou, beforehand, in CHANGELOG.mdgenerated from commits, editable in the PR
Reviewread the dry-run outputread the release PR diff
Publishruns from your machine with your tokenruns in CI with a scoped secret
Cadencewhen you choosea PR is always open; release when you merge
Needsnothing but a terminalCI, a token, a commit convention

The difference that matters in practice is the commit convention. release-plz is only as good as the messages it reads. A project with disciplined Conventional Commit titles on every merged PR gets accurate versions and a changelog that needs light editing. A project that merges "updates" and "fix stuff" gets a release PR that says nothing, and the maintainer ends up rewriting it by hand, at which point cargo release would have been simpler. Enforce the format in CI on pull request titles if you choose the bot.

Workspaces and the publish order

Both tools understand that crates in a workspace depend on each other and must publish in dependency order, with each internal dependency carrying a version that matches. cargo release --workspace publishes members in topological order and bumps internal requirements in lockstep, or per crate with --package. release-plz releases each package whose files changed, and marks crates that are for internal use only with release = false. The rules for what crates.io accepts, including why path dependencies need versions, are in workspaces, features and publishing. A wise precaution on either tool is to run cargo publish --dry-run --workspace first, since the failure that wastes the most time is a partial publish where crate A is on the registry and crate B failed.

Credentials and blast radius

Publishing is the dangerous step, so look at where the token lives. cargo release uses whatever cargo login stored on the maintainer's machine, which means a laptop compromise is a registry compromise and releases depend on one person's availability. release-plz keeps the token as a CI secret, which removes the single maintainer bottleneck but widens the audience: anyone who can modify the workflow or merge to main can cause a publish. Scope the crates.io token to the packages and to the publish-update scope, require reviews on the release PR, and consider a protected environment that needs approval for the publish job. Tokens and rotation are covered in secrets management.

Which to pick

Use release-plz when you ship often, have more than one maintainer, and are willing to hold a commit convention. Use cargo release for a crate with one maintainer and few releases, or when you want everything to happen on a machine you control. Neither replaces a decision about what is breaking (what counts as a breaking change), and neither belongs on a crate that publishes twice a year, where a checklist is enough. The changelog side of the same pipeline is in changelogs and release automation.

Also inDevOps
Taggedrelease
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.