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.

Part of theDev Tools & AI Workflow series
a white dice with a black github logo on it
Listen to this article
--:--

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

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. 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)
  2. update versions (Cargo.toml + workspace)
  3. update CHANGELOG.md
  4. commit
  5. create an annotated tag (signed if you can)
  6. push commit + tag
  7. 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

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 historyExampleBumpNotes
`BREAKING CHANGE:` footer or `!` in type`feat!:`majoreven if it’s “small”, it’s breaking
`feat:`add APIminornew backwards-compatible capability
`fix:`bug fixpatchbackwards-compatible bug fix
`perf:`perf improvementpatchunless it changes API
`refactor:`internal changepatchassuming no behavior change
`docs:`, `chore:`, `ci:`tooling/docsnoneshould 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.
  2. 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 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
  • 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. It’s config-driven (.bumpversion.toml) and supports multi-file search/replace.

For changelogs, Orhun Parmaksız 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.

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 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
  2. 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 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 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:

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 and 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.

Continue reading

Computer screen displaying code and terminal prompts

How to Set Up gitleaks + pre-commit + CI [2026]

A terminal-first, defense-in-depth secrets workflow for 2026: staged-only hooks, CI full-history scans, baselines for legacy repos, and sane allowlists.

Open laptop with code on screen, neon lighting

How to Reduce Rust Compile Time [2026] (sccache + mold)

A measurable 2026 playbook to reduce Rust compile time: profile with Cargo timings, fix the build graph, get real sccache hit rates, and cut link time with mold.

Computer screen displaying code and terminal prompts

AI Code Review in Your CI/CD Pipeline: 2026 Setup

Every vendor shipped a 2026 'best tools' listicle. None shipped the YAML. Here's a complete, copy-pasteable GitHub Actions config that wires AI code review into your pipeline — triggers, secrets, cost caps, and a real merge gate.

GitHub Actions vs CircleCI 2026: Which CI/CD Pipeline Wins?

GitHub Actions vs CircleCI 2026: Which CI/CD Pipeline Wins?

I'd pick GitHub Actions for solo devs and GitHub-native teams who want zero-friction setup; I'd pick CircleCI for performance-obsessed teams who need faster parallelism and fine-grained resource control. The split isn't about features — it's about where your bottleneck actually lives.

Cite this article
Kunal Ganglani (2026, September 7). How to Build a Rust Version Bump Tool [2026 Tutorial]. Kunal Ganglani. Retrieved September 7, 2026, from https://www.kunalganglani.com/blog/rust-version-bump-tool-tutorial