DEV Community

Kunal
Kunal

Posted on Originally published at kunalganglani.com

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

Originally published at kunalganglani.com — read it there for inline code, hero image, and live links.

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.

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.

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

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

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:

[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}\\]"
Enter fullscreen mode Exit fullscreen mode

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:

# patch bump
bump2version patch

# minor bump
bump2version minor
Enter fullscreen mode Exit fullscreen mode

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:

git cliff --init
Enter fullscreen mode Exit fullscreen mode

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
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}"
Enter fullscreen mode Exit fullscreen mode

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.

hyperfine \
  --warmup 5 \
  --min-runs 50 \
  'bump2version patch --dry-run' \
  'python -m bumpversion patch --dry-run'
Enter fullscreen mode Exit fullscreen mode

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.


Originally published on kunalganglani.com

Top comments (0)