# How to Build a Rust Version Bump Tool [2026 Tutorial]

> Build a CI-safe Rust release toolchain: semver bump rules, workspace updates, changelog generation, annotated/signed git tags, and reproducible benchmarks.

- Canonical: https://www.kunalganglani.com/blog/rust-version-bump-tool-tutorial
- Author: Kunal Ganglani
- Published: 2026-09-07 · Updated: 2026-09-07
- Category: Developer Tools · Tags: rust, release-engineering, cli, ci-cd, github-actions

## TL;DR

A Rust release flow doesn’t have to be a fragile pile of scripts. You can automate version bumps, changelog updates, and git tags with a small set of tools and a few safety rules. This tutorial shows how to decide the right version bump, update multiple Cargo.toml files in a workspace, generate CHANGELOG.md from commit history, and run the whole thing in GitHub Actions without tag races. You’ll also set up a simple benchmark so “10,000× faster” performance claims are actually testable, not just vibes.

I’ve watched more teams lose a release day to “one tiny script” than I care to admit. And the pattern is always the same: it works for months, nobody looks at it, then it breaks at the exact moment you’re trying to ship a hotfix.

By the end of this, you’ll have a working **rust version bump tool tutorial** setup you can run locally in under 5 minutes and wire into GitHub Actions in under 20.

It will:

- decide a semver bump from commit history (including pre-1.0 rules)
- bump versions across a Rust workspace (multiple `Cargo.toml` files)
- generate a Conventional Commits-style `CHANGELOG.md`
- create **safe git tags** (annotated, optionally signed)
- avoid duplicate/conflicting tags in CI
- benchmark the whole thing reproducibly so “10,000× faster” claims are testable
This is my stance: release automation should be a boring binary with guardrails. Not a pile of shell scripts that silently “works” until the worst possible day.

## What is a Rust version bump tool?

A Rust version bump tool is a CLI that updates version strings (for example in `Cargo.toml`, workspace manifests, and docs), then optionally commits, tags, and prepares release notes in a repeatable, automatable way.

![a computer screen with a bunch of text on it](https://cdn.sanity.io/images/vzekdneq/production/6f8496a6ed43a4d0722c79d731be9b421958275b-1200x675.webp)

The reason this is trending in 2026 is simple. Mahmoud Harmouch (WiseAI) shipped a Rust rewrite of a popular version-bumping workflow and claimed **~10,000× faster** performance than Python equivalents. The headline reason was pretty straightforward: avoid subprocess-heavy `git` calls and use pure-Rust git operations (`gix`) instead.

That hook is clickable. The real win is reliability.

If you want the “viral” reference point, read [Mahmoud Harmouch](https://dev.to/wiseai/i-built-a-version-bump-tool-in-rust-that-is-10000x-faster-than-its-python-counterparts-i6b). Then come back here for the part that actually ships.

I’m not going to pretend I’ve personally shipped a Rust release tool at scale. But building this site’s multi-agent publishing pipeline taught me the release-engineering lesson the hard way: **deterministic gates catch more than “better reviews.”** I treat releases the same way. If you can’t make a step deterministic and safe to rerun, you don’t really have automation. You have a dice roll.

Before we build anything, here’s the workflow I trust:

1. determine bump from commits (feat/fix/breaking)
1. update versions (`Cargo.toml` + workspace)
1. update `CHANGELOG.md`
1. commit
1. create an **annotated** tag (signed if you can)
1. push commit + tag
1. create a GitHub Release
## Semver rules that won’t embarrass you (including pre-1.0)

Semver is easy until someone says “it’s not breaking, it’s just… slightly incompatible.” The hard part isn’t the string math. It’s having a policy that stays consistent when you’re tired and shipping.

![a computer screen with a lot of data on it](https://cdn.sanity.io/images/vzekdneq/production/7db30eded97d2d521f35f79223e1ad3bbfa3526a-1200x675.webp)

For Rust crates, I want rules that are explicit, mechanical, and easy to defend in a code review.

### Commit type → bump mapping

If you use Conventional Commits (or you can approximate it from PR titles), this table gets you 90% of the way to fully automated bump decisions.

| Signal in history | Example | Bump | Notes |
| --- | --- | --- | --- |
| `BREAKING CHANGE:` footer or `!` in type | `feat!:` | major | even if it’s “small”, it’s breaking |
| `feat:` | add API | minor | new backwards-compatible capability |
| `fix:` | bug fix | patch | backwards-compatible bug fix |
| `perf:` | perf improvement | patch | unless it changes API |
| `refactor:` | internal change | patch | assuming no behavior change |
| `docs:`, `chore:`, `ci:` | tooling/docs | none | should not cut a release by itself |

Two things people weirdly avoid admitting out loud:

- **0.x.y** is where a lot of Rust crates live. For a long time.
- Semver says “anything can change before 1.0.0,” but ecosystems don’t thrive when maintainers treat that as permission to be chaotic.
My rule for pre-1.0 Rust crates:

- breaking change: bump **minor** (`0.3.0 → 0.4.0`)
- new feature: bump **patch** (`0.3.0 → 0.3.1`) if it barely expands surface area. Otherwise bump minor
- fixes/docs/chore: patch or none
Is that “pure semver”? No. It’s socially compatible semver. That’s what your users actually experience.

### Workspace versioning (multi-crate)

You’ve got two sane choices:

1. **Unified version**: every crate in the workspace shares one version.
1. **Independent versions**: each crate bumps on its own cadence.
If you’re publishing 3+ crates that are designed to be used together, unified versions are kinder to humans. If you’re building a library + a binary + a couple internal support crates, independent versions can be totally fine.

Practical rule I’d enforce:

- one public crate + several private crates: bump the public crate only
- multiple public crates: unify versions
Internal link if you’re also considering leaving git: my tutorial on [jj version control](https://www.kunalganglani.com/blog/jj-version-control-git-compatible) is relevant because it forces you to think harder about history and release boundaries.

## Getting started: install + basic CLI usage

You have two paths:

![a computer screen with a program running on it](https://cdn.sanity.io/images/vzekdneq/production/eca5d20ec9db66c2b90878890d4aa1ba50bd157f-1200x675.webp)

- Use an existing tool (recommended for most teams).
- Build your own thin wrapper around the pieces you already trust.
### Option A: Use bump2version + git-cliff

Mahmoud’s project is here: [wiseaidev/bump2version](https://github.com/wiseaidev/bump2version). It’s config-driven (`.bumpversion.toml`) and supports multi-file search/replace.

For changelogs, [Orhun Parmaksız](https://github.com/orhun/git-cliff) maintains `git-cliff`, which generates changelogs from Conventional Commits.

If you want a baseline “everything release” Cargo subcommand, `cargo-release` exists: [crate-ci/cargo-release](https://github.com/crate-ci/cargo-release).

### My recommended minimal toolchain

I like separating concerns:

- version bumping: `bump2version` (fast, config-driven)
- changelog: `git-cliff` (templated, Conventional Commits-aligned)
- publish: Cargo (`cargo publish`) or your artifact pipeline
That split isn’t “architecture astronaut” stuff. It’s about reruns. When one layer fails, I want to rerun it idempotently instead of debugging some half-applied state.

### Example `.bumpversion.toml`

The Dev.to post shows the idea: a config file declares a current version, bump policy, and file rewrites. A minimal workspace-friendly config might look like:

```toml
[bumpversion]
current_version = "0.3.7"
commit = false
tag = false

# root workspace manifest
[bumpversion:file:Cargo.toml]
search = 'version = "{current_version}"'
replace = 'version = "{new_version}"'

# a member crate
[bumpversion:file:crates/my_crate/Cargo.toml]
search = 'version = "{current_version}"'
replace = 'version = "{new_version}"'

# keep-a-changelog style header
[bumpversion:file:CHANGELOG.md]
search = "## \\[{current_version}\\]"
replace = "## \\[{new_version}\\]"
```

You’ll notice I set `commit = false` and `tag = false`. That’s on purpose. I prefer committing and tagging in CI, where I can enforce the safety checks and keep the machine as the source of truth.

Quick local usage looks like:

```bash
# patch bump
bump2version patch

# minor bump
bump2version minor
```

Exact flags can differ by version. Treat this as the workflow shape, not a promise about argument names.

## Dry runs + safety checks (local and CI)

This is where most “release scripts” fall apart. They optimize for the happy path because the author only ever ran them on a good day.

Steal the mindset from `cargo-release`: prerequisites and dry runs are not optional. They’re the whole point.

Here’s my minimum safety contract.

### Local dry run checklist

Before you let any automation commit or tag:

- working tree clean: `git status --porcelain` must be empty
- on the right branch: usually `main`
- up-to-date with remote: `git fetch` + verify `HEAD` matches `origin/main`
- tests pass: `cargo test`
- formatting/lints clean: `cargo fmt --check` and `cargo clippy -D warnings`
Two rules I’m pretty militant about:

- Exit non-zero on the **first** failed prerequisite. Don’t “collect errors” and keep going.
- `--dry-run` should print every planned file edit and the computed next version. If I can’t scan the output in **30 seconds** and feel confident, I won’t trust the tool.
### CI dry run strategy

In pull requests, run everything except commit/tag/publish.

That means:

- compute bump based on commits in the PR
- generate a changelog entry
- verify the diffs are what you expect
If your team already treats CI as a gate, make this required. A release is not the moment to discover that your changelog template has been broken for three months.

Internal link: my post on [7 safer defaults for code review automation](/blog/code-review-automation-defaults) maps well here. Releases are “automation on the sharp edge.” Treat them like it.

## Changelog generation from git commits (Conventional Commits style)

If you want a changelog that doesn’t turn into a junk drawer, you need two constraints:

1. commit/PR title conventions that are machine-readable
1. a generator that’s deterministic
`git-cliff` hits the sweet spot. It’s built around Conventional Commits and templates.

Start by generating a default config:

```bash
git cliff --init
```

Then wire it so your release pipeline produces:

- a `CHANGELOG.md` entry for the new version
- optionally, GitHub Release notes from the same template
Two gotchas you should handle upfront:

- **Merge commits**: if you merge PRs with GitHub’s default merge commit, you’ll get “Merge pull request #123” noise. Prefer squash merges. Or configure your generator to ignore merge commits.
- **Reverts**: a `revert:` commit should show up as a fix, but flagged as a revert. Don’t sweep it under the rug.
If your team isn’t consistent about Conventional Commits, you can still make this work by enforcing PR title prefixes. It’s less elegant. It’s enforceable, which is what matters.

Internal link: if you’re using AI tooling for PR text, read my [vibe coding](/blog/vibe-coding-best-practices-2026) post and stop letting the model invent release notes. You want templates, not poetry.

## Git tag safety + CI integration (GitHub Actions)

Most release pipelines mess up tags because they treat tags like “just another git command.” They aren’t. Tags are an API surface for humans and tooling.

### Annotated vs lightweight tags

- **Lightweight tags** are basically a named pointer.
- **Annotated tags** are tag objects with a message, tagger identity, and optional signature.
Use annotated tags for releases. Always.

If you want the official behavior spelled out, see the `git tag` docs on the [Junio C Hamano](https://git-scm.com/docs/git-tag) page (Junio is Git’s long-time maintainer).

### Signed tags (GPG or Sigstore)

If you’re releasing artifacts that other people run, signing matters.

In 2026, sigstore-based signing is increasingly common, but GPG is still the default in a lot of ecosystems. I’m not religious about which one you pick. I am religious about consistency:

- if your org already does GPG signing, don’t create a parallel universe
- if you don’t, consider adding sigstore signing as a parallel track
### Preventing duplicate/conflicting tags

The easiest race looks like this:

- two workflows run concurrently (manual rerun, or two pushes close together)
- both decide the “next version” is `1.4.2`
- both try to create and push `v1.4.2`
You prevent this with **idempotency + remote checks**.

Minimum policy:

- fetch tags first
- fail if the tag already exists remotely
- only allow releases from one branch (`main`)
- use a concurrency group in GitHub Actions
### Copy/pasteable GitHub Actions workflow

This is a skeleton you can adapt. It assumes:

- `main` pushes can publish
- tags are `vX.Y.Z`
```yaml
name: release

on:
  push:
    branches: [main]

concurrency:
  group: release-main
  cancel-in-progress: false

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable

      - name: Prereqs
        run: |
          cargo fmt --check
          cargo clippy --all-targets -- -D warnings
          cargo test

      - name: Fetch tags
        run: git fetch --tags --force

      - name: Compute next version
        run: |
          # your logic here: conventional commits -> bump
          echo "NEXT_VERSION=0.3.8" >> $GITHUB_ENV

      - name: Fail if tag exists
        run: |
          git show-ref --tags --verify --quiet "refs/tags/v${NEXT_VERSION}" && exit 1 || true

      - name: Bump versions (no commit/tag)
        run: bump2version patch

      - name: Generate changelog
        run: |
          git cliff -o CHANGELOG.md

      - name: Commit
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add -A
          git commit -m "chore(release): v${NEXT_VERSION}"

      - name: Create annotated tag
        run: |
          git tag -a "v${NEXT_VERSION}" -m "Release v${NEXT_VERSION}"

      - name: Push commit + tag
        run: |
          git push origin HEAD:main
          git push origin "v${NEXT_VERSION}"
```

Two details people keep “optimizing” away and then regret:

- `fetch-depth: 0` is non-negotiable. Shallow clones break changelog generation and tag checks.
- The concurrency key (`release-main`) prevents two `main` pushes from racing.
Internal links that are relevant to CI hardening:

- [CI/CD](/pillars/developer-tools-workflow)
- [Kubernetes](/glossary/kubernetes) if your release ends up deploying anything
- [CI/CD](/glossary/ci-cd) as a glossary refresher
Yes, I’m intentionally linking glossary/pillar content because that’s how you build real topical clusters.

## Reproducible benchmarking: validate the “10,000× faster” claim

I like performance claims. I just don’t trust them without a harness sitting next to the repo.

Mahmoud Harmouch’s post claims **~10,000×** vs a Python CLI. The comment section debates methodology. Of course it does. Benchmarks are fragile, and people love arguing about them.

So here’s a harness you can copy.

### What you’re actually measuring

For a version bump tool, total runtime is a mix of:

- file I/O (read + write)
- parsing and regex matching
- git operations (status checks, commit, tag)
- process overhead (if you spawn `git` subprocesses repeatedly)
If a Rust tool uses pure-Rust git operations (`gix`) and the Python tool shells out to `git` ten times, you can absolutely see huge multipliers. Especially when the task itself is tiny.

### A simple `hyperfine` benchmark

Use `hyperfine` because it reports distributions and warms caches.

```bash
hyperfine \
  --warmup 5 \
  --min-runs 50 \
  'bump2version patch --dry-run' \
  'python -m bumpversion patch --dry-run'
```

A couple choices here are deliberate:

- `--warmup 5` cuts down first-run filesystem noise.
- `--min-runs 50` gives you a stable median.
Benchmarking tip: run on the same machine, same repo fixture, and pin tool versions. If you can’t reproduce it a week later, it’s marketing, not engineering.

Internal link: I treat benchmarking methodology as a product feature. That’s why I wrote [LLM latency benchmark methodology](/blog/llm-latency-benchmark-methodology) and [TypeScript 7 native compiler benchmark](/blog/typescript-7-native-compiler-benchmark). Different domain, same discipline.

### My data anchor (from this site)

Based on the benchmark data I maintain at **kunalganglani.com/llm-benchmarks**, the difference between a believable benchmark and a meme benchmark is almost always the same. Clear fixtures and repeatable runs. I hold release tooling to the same standard.

## What I’d ship in a real team tool (and what I’d refuse)

If you’re building a custom release tool in Rust in 2026, here’s what I’d include on day 1:

- `--dry-run` that prints a patch-style diff
- a “prereqs” step that checks branch, cleanliness, and remote tags
- a workspace mode that finds and updates every `Cargo.toml`
- a changelog generator step (`git-cliff`) with a committed template
- annotated tags by default (`vX.Y.Z`)
- CI concurrency + “tag exists” hard failure
And here’s what I’d refuse to ship:

- automatic “guess the bump” without showing the reasoning
- tagging before tests
- pushing tags without fetching tags
- mixing “generate changelog” and “publish” in one irreversible step
Release automation is one of those things where the boring answer is actually the right one.

If you take one challenge from this post, make it this: implement the **tag-exists check** and the **CI concurrency group** this week. Those two lines will save you from the most common release-day footgun. And if you’re going to chase the “10,000×” story, commit the benchmark harness into the repo so the claim stays honest when someone reruns it six months from now.

Photo by Rubaitul Azad on Unsplash.
