Hooks#
Hooks run shell commands at three points in a bump. Each is a list of command strings:
| Key | Runs |
|---|---|
setup_hooks | Before anything is read or written |
pre_commit_hooks | After the files are rewritten, before git add and the commit |
post_commit_hooks | After the commit and the tag |
Every hook runs through sh -c from the repository root, and a non-zero exit aborts the bump. Hooks do not run under --dry-run — the report shows what would have run:
[tool.bumpversion]
current_version = "0.5.0"
commit = true
tag = true
tag_name = "v{new_version}"
message = "chore(release): {current_version} → {new_version}"
# Hooks run as `sh -c` from the repository root and abort the bump on a non-zero
# exit. Every BVHOOK_* variable is exported to them.
setup_hooks = [
"test -z \"$(git status --porcelain --untracked-files=no)\"",
]
pre_commit_hooks = [
"sh scripts/changelog.sh $BVHOOK_NEW_VERSION",
]
post_commit_hooks = [
"echo released $BVHOOK_NEW_VERSION",
]
# A pre-commit hook rewrites CHANGELOG.md, but no [[files]] entry produced it, so
# it must be listed here to end up in the release commit.
additional_files = ["CHANGELOG.md"]
[[tool.bumpversion.files]]
filename = "src/version.rs"$ bumpversion --dry-run -v bump minor [DRY-RUN] [current version] [DRY-RUN] 0.5.0 [DRY-RUN] [setup] [DRY-RUN] running test -z "$(git status --porcelain --untracked-files=no)" [DRY-RUN] [new version] [DRY-RUN] 0.6.0 [DRY-RUN] [DRY-RUN] [./src/version.rs] [DRY-RUN] replacing `{current_version}` (0.5.0) with `{new_version}` (0.6.0) [DRY-RUN] [DRY-RUN] Differences (-before|+after): [DRY-RUN] -pub const VERSION: &str = "0.5.0"; [DRY-RUN] +pub const VERSION: &str = "0.6.0"; [DRY-RUN] [DRY-RUN] [./.bumpversion.toml] [DRY-RUN] replacing `{current_version}` (0.5.0) with `{new_version}` (0.6.0) [DRY-RUN] [DRY-RUN] Differences (-before|+after): [DRY-RUN] [tool.bumpversion] [DRY-RUN] -current_version = "0.5.0" [DRY-RUN] +current_version = "0.6.0" [DRY-RUN] commit = true [DRY-RUN] tag = true [DRY-RUN] tag_name = "v{new_version}" [DRY-RUN] message = "chore(release): {current_version} → {new_version}" [DRY-RUN] [pre-commit] [DRY-RUN] running sh scripts/changelog.sh $BVHOOK_NEW_VERSION [DRY-RUN] [commit] [DRY-RUN] add ./src/version.rs [DRY-RUN] add ./CHANGELOG.md [DRY-RUN] add ./.bumpversion.toml [DRY-RUN] commit chore(release): 0.5.0 → 0.6.0 [DRY-RUN] [tag] [DRY-RUN] tag = v0.6.0 [DRY-RUN] message = Bump version: 0.5.0 → 0.6.0 [DRY-RUN] sign = false [DRY-RUN] [post-commit] [DRY-RUN] running echo released $BVHOOK_NEW_VERSION
What each is for#
setup_hooks are preconditions. Because they run before any file is touched, a failure costs nothing. The example above asserts the tree is clean; a release guard that refuses to ship from the wrong branch is the same shape:
setup_hooks = ['test "$BVHOOK_BRANCH_NAME" = main']pre_commit_hooks are for files that must be regenerated from the new version and land in the same commit — a lockfile whose package version just changed, or a changelog heading:
pre_commit_hooks = ["cargo metadata --offline --format-version 1 >/dev/null"]
additional_files = ["Cargo.lock"]Anything a pre-commit hook writes that no [[files]] entry produced must also be listed in additional_files, or it will not be staged. That lockfile case is worked through in full below.
post_commit_hooks run once the release exists — publishing, notifying, or kicking off a build. A failure here aborts the run but cannot undo the commit and tag that already happened.
Environment#
Hooks inherit the full environment, plus these BVHOOK_* variables:
| Variable | Value |
|---|---|
BVHOOK_NOW | Local time, RFC 3339 |
BVHOOK_UTCNOW | UTC, RFC 3339 |
BVHOOK_COMMIT_SHA | Current commit |
BVHOOK_DISTANCE_TO_LATEST_TAG | Commits since the most recent tag |
BVHOOK_IS_DIRTY | true or false |
BVHOOK_CURRENT_VERSION | Version before the bump |
BVHOOK_CURRENT_TAG | Most recent tag |
BVHOOK_BRANCH_NAME | Current branch |
BVHOOK_SHORT_BRANCH_NAME | Branch name, shortened |
BVHOOK_CURRENT_<PART> | One per component — BVHOOK_CURRENT_MAJOR, BVHOOK_CURRENT_MINOR, … |
pre_commit_hooks and post_commit_hooks additionally get:
| Variable | Value |
|---|---|
BVHOOK_NEW_VERSION | The new version |
BVHOOK_NEW_<PART> | One per component — BVHOOK_NEW_MAJOR, … |
BVHOOK_NEW_VERSION_TAG | The tag this bump will create, rendered from tag_name. Empty when tag is off |
Setup hooks do not get the NEW_* variables, because the new version has not been computed yet.
Writing a hook#
Because a hook is a single string handed to sh -c, anything beyond one command reads better in a script file. The example keeps the changelog rewrite in scripts/changelog.sh and passes the version as an argument:
#!/usr/bin/env sh
# Turn the "Unreleased" heading into a released one. Invoked as a pre_commit_hook,
# so CHANGELOG.md is listed in `additional_files` to reach the release commit.
set -eu
sed -i.bak "s/^## Unreleased$/## $1/" CHANGELOG.md
rm -f CHANGELOG.md.bakKeep hooks idempotent where you can. A bump that fails partway leaves the earlier hooks’ effects in place, and the natural response is to fix the problem and run it again.
Rust: keeping Cargo.lock in the release commit#
Bumping the version in Cargo.toml leaves Cargo.lock stale — it still records the workspace crates at the old version — and most projects want both in one commit. Any cargo command that resolves the workspace rewrites the lockfile, so the job needs nothing more than the cheapest one:
[tool.bumpversion]
current_version = "1.4.2"
commit = true
tag = true
pre_commit_hooks = ["cargo metadata --offline --format-version 1 >/dev/null"]
additional_files = ["Cargo.lock"]
[[tool.bumpversion.files]]
filename = "Cargo.toml"cargo metadata resolves the workspace, writes the refreshed lockfile, and prints a JSON dump the hook throws away. --format-version 1 silences cargo’s warning about the unpinned output format, and --offline keeps the hook off the network. Drop --offline if the bump may run somewhere the dependency cache is cold.
Do not reach for
cargo updatehere. It re-resolves every dependency to the newest version your requirements allow, so a release bump quietly becomes a dependency bump — which defeats a pinning or cooldown policy meant to slow supply-chain attacks down.It can also fail outright: re-resolving an unpinned git dependency looks at the cached checkout’s branch tip, which may no longer contain the package the lockfile pins. The bump then aborts after the files were rewritten, leaving the new version in the tree with no commit.
cargo update --workspace --offlineis the narrow form and does restrict itself to the workspace crates, but it is still an update command.cargo metadatacannot move a dependency even by accident.