Auto-Tagging
Automatically create version tags from commit messages
The anodizer tag command reads commit messages for bump directives, finds the latest semver tag, bumps the version, and creates a new tag.
Usage
anodizer tag # create the tag locally (nothing is pushed)
anodizer tag --push # push the version-sync bump commit + tag, atomically
anodizer tag --dry-run # show what would happen
anodizer tag --custom-tag v2.0 # override with specific tagPushing (--push, --push-tags-only)
anodizer tag is fully local in every configuration — single-crate,
lockstep, --crate, and per-crate auto-dispatch alike. The tag and the
version-sync chore(release): bump … commit both stay in your clone, so you can
inspect the bump before anything reaches the remote, exactly like git tag.
Reaching the remote is always an explicit opt-in:
--pushpushes the bump commit to the release branch atomically with the tag (git push --atomic) — branch HEAD and tag land together or not at all, so this mode can never leave an orphan tag (a remote tag whose bump commit is absent from the remote branch).--push-tags-onlypushes the tag(s) but not the bump commit — the deferred-branch CI pattern (see the table). This is the one mode that deliberately creates a remote tag ahead of its branch, so the branch must be fast-forwarded onto the bump commit after publish succeeds.
| Flag | Effect |
|---|---|
--push | Push the bump commit (branch HEAD) atomically with the tag |
--no-push | Push nothing — the explicit, redundant form of the default (the tag(s) and bump commit stay local) |
--push-tags-only | Push the tag(s) but not the bump commit — the deferred-branch CI pattern: the pipeline is triggered by the tag, and the branch is fast-forwarded onto the bump commit only after publish succeeds. The branch must be advanced separately or the tag permanently references a commit missing from every branch |
--push-remote <name> | Push to <name> instead of origin |
--push-dry-run | Create the tag + bump commit locally, but only print the git push commands --push would run instead of executing them |
--changelog | Refresh CHANGELOG.md as part of this tag — opt-in; requires a changelog: config block |
tag.push: true in config is the persistent equivalent of --push; the CLI
flags override it per invocation.
Enrolled version_files ride the bump commit
The same bump commit also rewrites any files enrolled under version_files —
a Helm Chart.yaml, an install doc, a README badge — from the old release
version to the new one, so files that embed the version outside Cargo.toml
are tagged together and never drift from the tag. See
Version Files for enrollment and the
anodizer check version-files CI guard.
Refreshing CHANGELOG.md (--changelog)
Pass --changelog and the same bump commit also prepends a new
## [version] - date section to your CHANGELOG.md — rendered by anodizer's
native changelog engine (the same one
anodizer bump --commit --changelog uses: conventional commits since the last
tag, grouped and filtered per your changelog: config).
The refreshed CHANGELOG.md rides the same chore(release): bump … commit as
the Cargo.toml / Cargo.lock bump and any enrolled version_files, so the
changelog is tagged atomically with the version and never drifts.
The refresh is opt-in: without --changelog, anodizer tag never touches
CHANGELOG.md. A changelog: block must also be configured for --changelog
to have anything to render:
changelog:
sort: asc
groups:
- title: Features
regexp: "^feat"
order: 0
- title: Bug Fixes
regexp: "^fix"
order: 1
filters:
exclude:
- "^chore"
- "^docs"
Given the latest tag v0.1.0, a minor bump, and an existing CHANGELOG.md
with a # Changelog H1 over prior ## [x.y.z] sections, anodizer tag --changelog
prepends the new section in the bump commit and leaves the prior ones intact:
$ anodizer tag --changelog
...
bundled changelog section for myapp → 0.2.0
new_tag=v0.2.0
old_tag=v0.1.0# Changelog
## [0.2.0] - 2026-06-03
### Features
* a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 add config validation
### Bug Fixes
* e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3 handle empty target list
## [0.1.0] - 2026-05-12
...
Omit --changelog for a tag that shouldn't touch the changelog — a hotfix tag,
for example. The tag and the Cargo.toml / version_files bump still happen;
CHANGELOG.md is simply left untouched:
$ anodizer tag
...
new_tag=v0.2.1 # CHANGELOG.md unchanged
old_tag=v0.2.0
To preview or refresh CHANGELOG.md outside of tagging, use the standalone
anodizer changelog command (anodizer changelog
to preview, --write to apply).
When the refresh runs
The refresh is opt-in via --changelog and only acts when a changelog:
block is configured. tag and bump --commit share one gate, so the same
config governs both:
| Setting | Effect on the bump commit's CHANGELOG.md refresh |
|---|---|
no --changelog flag | No refresh (default) — CHANGELOG.md untouched |
anodizer tag --changelog, changelog: present | Refreshes |
--changelog but no changelog: block | Nothing to render; refresh is a no-op |
--changelog but changelog: { skip: true } | Suppressed — skip: true overrides the flag, for both tag and bump --commit |
Config modes
The refresh follows the same per-mode file placement as the bump itself:
- Single-crate — one root
CHANGELOG.mdat the repo root. - Lockstep —
[workspace.package].versionin the rootCargo.toml: one sharedv*tag and one flat section per release. - Flat-aggregate — a flat
crates:list whose members all share onetag_templateprefix: treated exactly like lockstep — one sharedv*tag, one flat section — even though each crate carries its own[package].version. All members must agree on[package].version(the coherence rule); a divergence errors before tagging. - Multi-track (per-crate) — crates with distinct tag prefixes: only the
crates this tag actually bumps get their
CHANGELOG.mdrefreshed, each against its own version, tag, and commit range.
--push-dry-run vs --dry-run: --dry-run previews the whole run, touching
nothing (no bump commit, no tag, no push). --push-dry-run is narrower — it
still creates the tag and the version-sync bump commit locally, then prints
the git push … commands the push step would run rather than executing them.
Use it to confirm exactly which refs --push would publish (and to which
remote) before you commit to the push; combine with --dry-run to preview the
tagging too.
A non-fast-forward rejection is the most likely --push failure (someone
pushed to the release branch after your checkout). Because the push is atomic,
neither the branch nor the tag lands when it's rejected, and the error names
the stale ref and tells you to pull/rebase and re-run.
Commit message directives
Include these tokens in your commit messages to control version bumps:
| Token | Effect |
|---|---|
#major | Major version bump (1.0.0 → 2.0.0) |
#minor | Minor version bump (1.0.0 → 1.1.0) |
#patch | Patch version bump (1.0.0 → 1.0.1) |
#none | Skip tagging |
Resolution order
When more than one signal appears in a commit range, anodizer resolves the bump in this order (highest precedence first):
| # | Signal | Beats | Notes |
|---|---|---|---|
| 1 | Explicit token #major > #minor > #patch | everything | Literal operator intent — never lowered by the pre-1.0 demotion below. |
| 2 | Conventional marker — feat!/BREAKING CHANGE → major, feat → minor, fix/perf/revert → patch | #none, default_bump | A release-worthy marker overrides #none. chore/docs/style/refactor/test/build/ci are not release-worthy and contribute nothing. |
| 3 | #none | default_bump | Vetoes the fallback only — a range whose sole signal is #none skips. |
| 4 | default_bump | — | Used when nothing above matched. Default none. |
With default_bump: none (the default) a range of only chore/docs/ci commits
produces no release — the conventional-commit contract. Set
default_bump: patch (or minor) to cut a release on every range regardless of
commit type.
Pre-1.0 demotion
While the current major version is 0 the public API is unstable, so a
conventional breaking change need not force 1.0.0. Two opt-in toggles
(SemVer "major version zero", mirroring release-please) lower an inferred
bump:
| Field | Effect while major is 0 | Default |
|---|---|---|
bump_minor_pre_major | conventional major (feat!/BREAKING CHANGE) → minor (0.5.0 → 0.6.0, not 1.0.0) | false |
bump_patch_for_minor_pre_major | conventional minor (feat) → patch (0.5.0 → 0.5.1) | false |
The two axes are independent (no cascade). They apply only to bumps inferred
from the conventional layer or the default_bump fallback — an explicit
#major/#minor token, a custom_tag, or a manually-ahead Cargo.toml
version is literal intent and always wins. Both toggles are inert once a tag
reaches 1.x. Reaching 1.0.0 is therefore a deliberate act: a #major token,
a custom_tag, or a manifest bump.
Config
tag:
default_bump: none # none (default) | patch | minor | major
bump_minor_pre_major: true # pre-1.0: breaking → minor, not 1.0.0
tag_prefix: "v"
initial_version: "0.1.0"
release_branches:
- "main"
- "release/.*"
branch_history: last # last | full
tag_context: repo # repo | branchTag config fields
| Field | Type | Default | Description |
|---|---|---|---|
default_bump | string | none | Bump when a range has no # token and no conventional marker. none = chore/docs/ci-only ranges no-op (conventional-commit contract); patch/minor = release every range |
bump_minor_pre_major | bool | false | While major is 0, demote a conventional breaking change to a minor bump (0.5.0 → 0.6.0, not 1.0.0) |
bump_patch_for_minor_pre_major | bool | false | While major is 0, demote a conventional feat to a patch bump (0.5.0 → 0.5.1) |
tag_prefix | string | v | Prefix added to tags |
initial_version | string | 0.1.0 | Starting version when no tags exist |
release_branches | list | ["master", "main"] | Branch patterns that trigger tags |
custom_tag | string | none | Override all bump logic |
tag_context | string | repo | Scope: repo or branch |
branch_history | string | last | How many commits to scan: last, full |
prerelease | bool | false | Enable prerelease mode |
prerelease_suffix | string | beta | Prerelease suffix |
force_without_changes | bool | false | Tag even without new commits |
major_string_token | string | #major | Custom major bump trigger |
minor_string_token | string | #minor | Custom minor bump trigger |
patch_string_token | string | #patch | Custom patch bump trigger |
none_string_token | string | #none | Custom skip trigger |
git_api_tagging | bool | false | Create the tag through the GitHub API instead of the git CLI when pushing. Not supported with per-crate auto-dispatch (its atomic branch+multi-tag push has no API equivalent) |
push | bool | false | Also push the version-sync bump commit atomically with the tag (CLI --push / --no-push override) |
sign | bool | false | Create the version tag as a signed annotated tag (git tag -s) instead of unsigned (git tag -a). Key/method come from git config (user.signingkey, gpg.format — GPG or SSH). CLI --sign / --no-sign override. See below |
skip_ci_on_bump | bool | false | Append [skip ci] to the bump commit subject. Only safe with a workflow_run-triggered release (see below) |
Version source of truth
The bumped version comes from the latest git tag, not Cargo.toml. Given a
patch bump and the latest tag v0.3.4, the result is v0.3.5 — regardless
of what Cargo.toml currently says.
Cargo.toml only enters the picture when version_sync is enabled and its
version is strictly greater than the bumped version. In that case the higher
Cargo.toml wins and no further bump is applied — this protects manual
pre-bumps (e.g., version = "2.0.0" committed in advance of a major release)
from being downgraded to v1.1.0.
Workspace-aware tagging
Tag individual crates in a workspace:
anodizer tag --crate my-crate
Each crate has its own tag_template (e.g., my-crate-v{{ Version }}) used
for both tag discovery (finding the latest my-crate-v* tag) and tag
creation. Distinct prefixes keep workspaces independent — my-core-v0.5.0 and
my-cli-v1.2.0 can coexist without collision (the multi-track shape).
A bare anodizer tag (no --crate) groups a flat crates: list by
extracted tag_template prefix: every subset of crates sharing one prefix
(e.g. all v{{ Version }}) bumps together and creates one shared tag for
that prefix — the aggregate shape, treated like lockstep — while crates with a
unique (or no extractable) prefix stay independent tracks. When the whole list
shares a single prefix this is the flat-aggregate shape. Each aggregate's
members must agree on [package].version first; a divergence
errors before any tag is created (see the
coherence rule).
When version_sync.enabled: true is set per-crate, the tag command also
updates that crate's Cargo.toml version (and any intra-workspace path + version dependency specs that reference it), commits the change, and tags
that commit so cargo publish reads the right version.
Mixed configs (crates: alongside workspaces:)
A config can declare both top-level crates: entries and workspaces:
groups:
crates:
- name: alpha
path: crates/alpha
tag_template: "v{{ Version }}"
- name: beta
path: crates/beta
tag_template: "v{{ Version }}"
workspaces:
- name: tools
crates:
- name: gamma
path: tools/gamma
tag_template: "gamma-v{{ Version }}"
A bare anodizer tag groups this shape as follows:
- Each
workspaces:entry is one lockstep group (its crates bump and tag as a unit). - Top-level crates are grouped by extracted
tag_templateprefix: every subset sharing one prefix (alpha+betaabove, bothv*) joins as one aggregate group — one shared tag per release, exactly like the flat-aggregate shape. Each aggregate's members must agree on[package].version; a divergence errors before any tag is created. - Top-level crates with a unique (or no extractable) prefix stay independent singleton tracks.
$ anodizer tag --push --dry-run # feat on alpha + fix on beta, prev tag v0.1.0
• running auto-tag (per-crate) (dry-run)
• (dry-run) would push branch 'master' + tags [v0.2.0] to 'origin' atomically
anodizer-output crates=["alpha","beta"]
anodizer-output versions={"alpha":"0.2.0","beta":"0.2.0"}
A bare anodizer tag --dry-run (no --push) prints the same
anodizer-output lines with no push line — the tags would be created locally.
One shared v0.2.0 tag covers both members — never v0.2.0 AND v0.1.1
in the same namespace.
First-ever tags. Change detection includes every crate that has no tag
matching its tag_template yet. In a repo that adds a mixed config (or adds
new top-level crates to one), the next release-worthy push therefore cuts a
FIRST tag for each not-yet-tagged track — expect one release per new track on
the first run after the upgrade. A track's first tag starts from the crate's
own [package].version (the Cargo-ahead guard, same as lockstep), falling
back to tag.initial_version when the manifest carries no literal version.
Push behavior is uniform across modes. Every dispatch shape — single,
lockstep, --crate, and per-crate auto-dispatch — is fully local by
default: a bare anodizer tag creates the tag(s) and bump commit in your clone
and pushes nothing. Pass --push (or set tag.push: true) to push the bump
commit and every tag atomically, or --push-tags-only for the deferred-branch
CI pattern. Use --push-remote <name> to target a remote other than origin.
[skip ci] on the bump commit (skip_ci_on_bump)
By default the version-sync bump commit's subject does not carry
[skip ci]. The bump commit becomes the tag's target, and GitHub suppresses
both the master-push CI re-run and any on: push: tags: release
trigger when the tag target's message contains [skip ci]. Marking it would
silently skip a tag-push-triggered release.
The trade-off depends on how your release workflow is triggered:
| Release trigger | skip_ci_on_bump | Why |
|---|---|---|
on: push: tags: (GoReleaser-style) | off (default) | [skip ci] would suppress the tag-push trigger and the release never fires |
on: workflow_run: (decoupled) | may be on | The release fires off the completed CI run, not the tag push, so [skip ci] only skips the redundant master-push CI re-run (which is already crate-gated and harmless) |
tag:
skip_ci_on_bump: true # only with a workflow_run-triggered release
If left off, the bump commit's master push triggers a normal CI re-run; that run's auto-tag job no-ops because no new release-worthy commits exist since the freshly created tag (the conventional-commit gate in bump detection). See Release workflow patterns for the two trigger styles.
Signed tags (sign)
Set tag.sign: true and anodizer signs the version tag, creating it with
git tag -s (a cryptographically signed annotated tag) instead of the default
unsigned git tag -a. The signing key and method come entirely from your git
configuration — user.signingkey and gpg.format — so both GPG and SSH
signing work with no anodizer-specific key setup:
tag:
sign: true# GPG signing (git default gpg.format)
git config user.signingkey <KEY_ID>
# or SSH signing
git config gpg.format ssh
git config user.signingkey ~/.ssh/id_ed25519.pub
Signing is workspace-global: every tag anodizer cuts is signed in single-crate,
lockstep, and per-crate modes alike. The CLI overrides config per invocation —
--sign forces a signed tag and --no-sign forces an unsigned one:
anodizer tag --sign # sign this tag even if tag.sign is unset
anodizer tag --no-sign # unsigned tag even if tag.sign: true in config
Signing is mutually exclusive with git_api_tagging: true on a pushed tag. The
GitHub API creates the tag object on the remote, where your local GPG/SSH key
cannot sign it — so rather than ship a silently-unsigned tag, anodizer errors on
that combination. Signing works with local tagging and with --push-tags-only
(both cut the tag locally before any push); drop git_api_tagging to sign a
pushed tag.
GitHub Actions: single-crate repo
- uses: tj-smith47/anodizer-action@v1
with:
args: tag --push
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }} # PAT, not GITHUB_TOKEN
Use a PAT (not GITHUB_TOKEN) when pushing tags, so tag-scoped workflows
like release.yml fire on the resulting push. GITHUB_TOKEN-authored pushes
never trigger downstream workflows.
GitHub Actions: monorepo loop
For multi-crate workspaces, tag each crate independently so each gets its
own release.yml run:
- uses: tj-smith47/anodizer-action@v1
with:
install-only: true
- name: Auto-tag all workspaces
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
for crate in my-core my-cli my-operator my-plugin; do
echo "--- tagging $crate ---"
# --push lands each crate's version_sync bump commit atomically with its
# tag, so tagged commits are never orphaned from master and the manual
# `git push origin HEAD` below is unnecessary.
if anodizer tag --crate "$crate" --push; then
echo "::notice::$crate: tagged"
else
echo "::warning::$crate: skipped or failed"
fi
done
See GitHub Actions for the surrounding workflow.
Dry run
Preview what would happen without actually tagging:
anodizer tag --dry-run # single-crate repo
anodizer tag --crate my-core --dry-run # specific crate in a workspaceOverride the bump
anodizer tag --default-bump minor # override config default
anodizer tag --custom-tag v2.0.0 # skip bump logic entirelyRoll back a poisoned tag
When a downstream release fails on a freshly-tagged commit, the operator is
left with a tag pointing at a bumped-but-broken commit. The reverse direction
of anodizer tag is anodizer tag rollback:
anodizer tag rollback "$GITHUB_SHA" # delete tag(s) at SHA + revert the bump
anodizer tag rollback --dry-run HEAD # preview without mutation
This never runs automatically: a failed anodizer release leaves the tag
exactly where it is under on_failure: hold
— the only accepted behavior. Recovering a step failure is
re-running the identical release command,
never a rollback. Reach for tag rollback only when you have decided a
release should not exist at all. See
Release resilience — Recovering a poisoned tag
for the full flag matrix (--scope, --mode, --branch, --no-push) and
the publisher-unwind step it also performs.