relnote docs · GitHub Action · CHANGELOG.md · Python packages · npm packages · release-please alternative

Release a Rust crate from conventional commits

Bump version in Cargo.toml, bring Cargo.lock along, prepend a CHANGELOG.md section, and tag in one local command. Pushing the tag publishes to crates.io with trusted publishing and creates a GitHub Release with grouped notes. No cargo plugin, no release bot, no config file.

1. The version lives in Cargo.toml

[package]
name = "demo"
version = "1.2.0"
edition = "2021"
description = "A small example crate"
license = "MIT"
repository = "https://github.com/you/demo"

[profile.release]
lto = true

Commit Cargo.lock (Cargo's advice for libraries and binaries alike since 2023). It also records your crate's version, which is why the release step below updates it.

2. Cut the release locally

Three commits since v1.2.0:

git log --oneline v1.2.0..HEAD
docs: document the --json flag
fix: handle empty input without panicking
feat(cli): add --json output

Install relnote (pipx install git+https://github.com/loki-inu/relnote.git, brew install loki-inu/tap/relnote, or the single file), then run the release script pinned to a version:

curl -fsSLO https://raw.githubusercontent.com/loki-inu/relnote/v0.3.7/examples/release.sh
bash release.sh --bump-file Cargo.toml --exec "cargo update --workspace"
next: v1.3.0
changelog: CHANGELOG.md
bump: Cargo.toml -> 1.3.0
exec: cargo update --workspace
committed: chore(release): v1.3.0
tagged: v1.3.0
next step: git push --follow-tags

A feat made it a minor bump. --exec runs after the bump and before the commit; cargo update --workspace changes only your own crate's entry in Cargo.lock, not your dependencies, and release.sh commits it with the rest:

git show --name-only --format= v1.3.0
CHANGELOG.md
Cargo.lock
Cargo.toml

Without it, Cargo.lock still says 1.2.0 and cargo publish --locked stops with an error that Cargo.lock needs updating but --locked was passed. The new CHANGELOG.md section:

## [v1.3.0] - 2026-11-02

### Features

- **cli:** add --json output (`4e39256`) — Ada Lovelace

### Fixes

- handle empty input without panicking (`a8cc6d8`) — Ada Lovelace

### Other

- **docs:** document the --json flag (`83256ad`) — Ada Lovelace

**Full changelog:** https://github.com/you/demo/compare/v1.2.0...v1.3.0

Output from a test repository built in relnote's CI with real cargo (tests/test_rust_crate_docs.py); your hashes and date differ. Use --dry-run first to see the version and notes without changing anything. Then:

git push --follow-tags

3. Publish and release on the tag

Save as .github/workflows/release.yml. Publish the first version by hand (cargo publish with an API token); then, in the crate's settings on crates.io, add a trusted publisher for this repository with workflow release.yml and environment release, and create that environment in the repository settings.

name: Release
on:
  push:
    tags: ["v*"]
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release
    permissions:
      contents: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Tag matches Cargo.toml
        run: |
          version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
          if [ "v$version" != "$GITHUB_REF_NAME" ]; then
            echo "::error::tag $GITHUB_REF_NAME but Cargo.toml says $version"
            exit 1
          fi
      - run: cargo test --locked
      - uses: rust-lang/crates-io-auth-action@v1
        id: auth
      - run: cargo publish --locked
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
      - id: relnote
        uses: loki-inu/relnote@v0.3.7
      - env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
          NOTES: ${{ steps.relnote.outputs.notes }}
        run: |
          printf '%s\n' "$NOTES" > notes.md
          gh release create "$GITHUB_REF_NAME" --verify-tag --notes-file notes.md

The first step stops the run if the tag and Cargo.toml disagree. --locked makes the test and publish steps fail instead of quietly rewriting a stale Cargo.lock. The GitHub Release is created only after crates.io accepted the crate. Its body is the action's notes output, passed through env: because commit subjects can contain backticks:

## What's changed

### Features

- **cli:** add --json output (`4e39256`) — Ada Lovelace

### Fixes

- handle empty input without panicking (`a8cc6d8`) — Ada Lovelace

### Other

- **docs:** document the --json flag (`83256ad`) — Ada Lovelace

---

Range: `v1.2.0` → `v1.3.0`.

**Full changelog:** https://github.com/you/demo/compare/v1.2.0...v1.3.0

The chore(release): v1.3.0 commit is left out of the notes by default.

FAQ

Why is Cargo.lock in the release commit?

Cargo.lock records your crate's own version. After the bump it is stale, so cargo publish --locked (or any --locked build) fails because the lock file needs to be updated. The --exec "cargo update --workspace" step rewrites only your workspace's entries, and release.sh commits every tracked file the command changed, so the tag points at a tree that builds with --locked.

Does it work for a workspace with several crates?

If the members share a version, put it in [workspace.package] in the root Cargo.toml, set version.workspace = true in each member, and pass --bump-file Cargo.toml once. For crates versioned separately, use one tag prefix per crate (release.sh --tag-prefix demo-core/v ... -- --path crates/demo-core) as on the monorepo page, and publish with cargo publish -p.

Why trusted publishing instead of a crates.io token?

rust-lang/crates-io-auth-action exchanges the job's OpenID Connect token for a crates.io token that lasts 30 minutes and is revoked when the job ends, so there is no long-lived secret in the repository. crates.io only lets you add a trusted publisher to a crate that already exists, so publish the first version by hand with cargo publish and a normal API token.

What if the tag and Cargo.toml disagree?

The check step fails before cargo publish runs, with an error naming both values. A crates.io version can be yanked but never replaced, so the check is worth keeping.

Can I dry-run the whole thing?

Locally, release.sh --dry-run prints the next version, the files it would bump, the --exec commands, and the notes, and changes nothing. cargo publish --dry-run --locked packages and builds the crate without uploading.

Do I need cargo-release or release-plz?

No. Those are good tools if you want them; this page is for people who want the version, changelog, and notes from commit messages with one small script and no extra cargo install.