Skip to main content

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 tag

Pushing (--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:

  • --push pushes 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-only pushes 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.
FlagEffect
--pushPush the bump commit (branch HEAD) atomically with the tag
--no-pushPush nothing — the explicit, redundant form of the default (the tag(s) and bump commit stay local)
--push-tags-onlyPush 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-runCreate the tag + bump commit locally, but only print the git push commands --push would run instead of executing them
--changelogRefresh 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:

SettingEffect on the bump commit's CHANGELOG.md refresh
no --changelog flagNo refresh (default) — CHANGELOG.md untouched
anodizer tag --changelog, changelog: presentRefreshes
--changelog but no changelog: blockNothing 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.md at the repo root.
  • Lockstep[workspace.package].version in the root Cargo.toml: one shared v* tag and one flat section per release.
  • Flat-aggregate — a flat crates: list whose members all share one tag_template prefix: treated exactly like lockstep — one shared v* 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.md refreshed, 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:

TokenEffect
#majorMajor version bump (1.0.0 → 2.0.0)
#minorMinor version bump (1.0.0 → 1.1.0)
#patchPatch version bump (1.0.0 → 1.0.1)
#noneSkip tagging

Resolution order

When more than one signal appears in a commit range, anodizer resolves the bump in this order (highest precedence first):

#SignalBeatsNotes
1Explicit token #major > #minor > #patcheverythingLiteral operator intent — never lowered by the pre-1.0 demotion below.
2Conventional marker — feat!/BREAKING CHANGE → major, feat → minor, fix/perf/revert → patch#none, default_bumpA release-worthy marker overrides #none. chore/docs/style/refactor/test/build/ci are not release-worthy and contribute nothing.
3#nonedefault_bumpVetoes the fallback only — a range whose sole signal is #none skips.
4default_bumpUsed 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:

FieldEffect while major is 0Default
bump_minor_pre_majorconventional major (feat!/BREAKING CHANGE) → minor (0.5.00.6.0, not 1.0.0)false
bump_patch_for_minor_pre_majorconventional minor (feat) → patch (0.5.00.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 | branch

Tag config fields

FieldTypeDefaultDescription
default_bumpstringnoneBump 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_majorboolfalseWhile major is 0, demote a conventional breaking change to a minor bump (0.5.00.6.0, not 1.0.0)
bump_patch_for_minor_pre_majorboolfalseWhile major is 0, demote a conventional feat to a patch bump (0.5.00.5.1)
tag_prefixstringvPrefix added to tags
initial_versionstring0.1.0Starting version when no tags exist
release_brancheslist["master", "main"]Branch patterns that trigger tags
custom_tagstringnoneOverride all bump logic
tag_contextstringrepoScope: repo or branch
branch_historystringlastHow many commits to scan: last, full
prereleaseboolfalseEnable prerelease mode
prerelease_suffixstringbetaPrerelease suffix
force_without_changesboolfalseTag even without new commits
major_string_tokenstring#majorCustom major bump trigger
minor_string_tokenstring#minorCustom minor bump trigger
patch_string_tokenstring#patchCustom patch bump trigger
none_string_tokenstring#noneCustom skip trigger
git_api_taggingboolfalseCreate 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)
pushboolfalseAlso push the version-sync bump commit atomically with the tag (CLI --push / --no-push override)
signboolfalseCreate 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_bumpboolfalseAppend [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_template prefix: every subset sharing one prefix (alpha + beta above, both v*) 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 triggerskip_ci_on_bumpWhy
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 onThe 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 workspace

Override the bump

anodizer tag --default-bump minor           # override config default
anodizer tag --custom-tag v2.0.0            # skip bump logic entirely

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