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 and push the tag (bump commit stays local)
anodizer tag --push             # also push the version-sync bump commit, atomically
anodizer tag --dry-run          # show what would happen
anodizer tag --custom-tag v2.0  # override with specific tag

Pushing the bump commit (--push)

By default anodizer tag pushes only the tag and leaves the version-sync chore(release): bump … commit on the local branch — so you can inspect the bump before publishing the branch. Pass --push to push the bump commit to the release branch atomically with the tag (git push --atomic), so neither an orphan tag nor an orphan commit can ever exist on the remote.

FlagEffect
--pushPush the bump commit (branch HEAD) atomically with the tag
--no-pushPush the tag only; leave the bump commit local (the per-crate path's opt-out, since it pushes branch+tags by default)
--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 (or drop --push to publish the tag only).

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_taggingstringnone (disabled)Use GitHub API (github) or git CLI (git) to create tags
pushboolfalseAlso push the version-sync bump commit atomically with the tag (CLI --push / --no-push override)
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).

When every crate in a flat crates: list shares the same tag_template prefix (all v{{ Version }}), a bare anodizer tag (no --crate) bumps every member and creates one shared v* tag — the flat-aggregate shape, treated like lockstep. All 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.

Push behavior differs by mode. The per-crate auto-dispatch path (a multi-crate config with no --crate) pushes the single bump commit and every per-crate tag atomically by default — --no-push opts out of pushing the branch (tags still go up). The --crate <name> path follows the single-crate/lockstep default: it pushes the tag only and leaves the bump commit local unless you pass --push (or set tag.push: true), at which point the bump commit and tag push atomically. 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.

GitHub Actions: single-crate repo

- uses: tj-smith47/anodizer-action@v1
  with:
    args: tag
  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

In CI this runs automatically: a failed anodizer release executes the release.on_failure policy in-process, which performs this same rollback by default. Reach for the manual command when a run was killed before it could execute its own policy, or when on_failure: hold deliberately left the tag in place. See Release resilience — Recovering a poisoned tag for the full flag matrix (--scope, --mode, --branch, --no-push) and the manual-recovery flows.