Skip to content

fix(tags): substitute ${devrelease} placeholder in tag_format - #1967

Open
bearomorphism wants to merge 1 commit into
commitizen-tools:masterfrom
bearomorphism:fix/1615-tag-format-devrelease
Open

fix(tags): substitute ${devrelease} placeholder in tag_format#1967
bearomorphism wants to merge 1 commit into
commitizen-tools:masterfrom
bearomorphism:fix/1615-tag-format-devrelease

Conversation

@bearomorphism

@bearomorphism bearomorphism commented May 9, 2026

Copy link
Copy Markdown
Collaborator

Description

Closes #1615.

Why

TagRules.normalize_tag in commitizen/tags.py builds tag strings by substituting placeholders in tag_format using Python's string.Template.safe_substitute. Before this fix the substitution dictionary (lines 217–223) included version, major, minor, patch, and prerelease — but not devrelease. A tag_format that references ${devrelease} (for example ${major}.${minor}-${patch}${devrelease}) therefore left the placeholder unsubstituted, producing a literal ${devrelease} in the resulting tag name.

Reported by @pydal on commitizen 4.9.1 / Python 3.9 / Linux (#1615): running cz bump --devrelease 1 --yes with tag_format = "${major}.${minor}-${patch}${devrelease}" produced tag to create: 0.0-2${devrelease} and then actually created a git tag with that verbatim string. All subsequent dev-release bumps failed with a duplicate-tag error. A triage note from the open-issues audit (2026-05-09) confirmed the bug still reproduces on master (v4.15.1) and pinpointed commitizen/tags.py:217–223 as the missing substitution.

The fix adds a devrelease variable to the safe_substitute call in normalize_tag. The value is computed as f"dev{dev}" when the version carries a dev-release integer (version.dev is not None), and as the empty string otherwise — exactly mirroring the existing treatment of prerelease (version.prerelease or "").

What changed

File Change
commitizen/tags.py Compute devrelease from version.dev (after line 214) and add it to the safe_substitute call in normalize_tag
tests/test_bump_normalize_tag.py Add three parametrised cases: dev release present (dev1), dev release at zero (dev0), and no dev release (empty string)

How it works

  • version.dev (from packaging.version.Version) is an int | None — it holds the dev-release number (e.g. 1 for 1.2.3.dev1) or None if the version is not a dev release. getattr(version, "dev", None) is used defensively so that version-scheme implementations that don't expose a dev attribute (custom schemes satisfying VersionProtocol) don't raise AttributeError.
  • The rendered value is f"dev{dev}" (e.g. "dev1"), which matches how PEP 440 and the SemVer2 scheme stringify dev releases in version strings. For a tag_format of ${major}.${minor}-${patch}${devrelease} and version 0.0.2dev1, this produces the tag 0.0-2dev1.
  • Why f"dev{dev}" rather than str(version.dev) or the full version string? Using the raw integer would produce 1 instead of dev1, which is not the conventional dev-release suffix. Using str(version) would embed the full version string where only the suffix is wanted. The f"dev{dev}" form gives the caller the smallest composable unit — it can be placed anywhere in tag_format without extra text leaking in.
  • string.Template.safe_substitute (already used at commitizen/tags.py:217) leaves unrecognised placeholders unchanged rather than raising KeyError. Adding devrelease to the dict means ${devrelease} is now substituted; tag_format strings that don't reference it are completely unaffected.
  • Dependency on fix(tags): widen prerelease and devrelease tag regexes for SemVer2 #1972: the tag-parsing regex (commitizen/defaults.py::get_tag_regexes) currently requires a leading dot before dev (\.dev\d+). Tags created with this PR's substitution (e.g. 0.0-2dev1) don't round-trip through TagRules.is_version_tag / extract_version without the companion fix in fix(tags): widen prerelease and devrelease tag regexes for SemVer2 #1972, which widens the regex to \.?dev\d+. Both PRs should be merged together to avoid a window in which created tags can't be parsed back.

Backward compatibility

  • tag_format strings that do not reference ${devrelease} are completely unaffected — safe_substitute ignores keys whose placeholders don't appear in the template.
  • The prerelease substitution and all other existing template variables are unchanged.
  • All pre-existing parametrised test cases in tests/test_bump_normalize_tag.py continue to pass.
  • No change to CLI flags, exit codes, or any command path other than the tag-format rendering step.

Checklist

Was generative AI tooling used to co-author this PR?

  • Yes (please specify the tool below)

Generated-by: Claude following the guidelines

Code Changes

  • Add test cases to all the changes you introduce
  • Run uv run poe all locally to ensure this change passes linter check and tests
  • Manually test the changes (see "Steps to Test" below)
  • Update the documentation for the changes

Expected Behavior

Scenario Outcome
tag_format = "${major}.${minor}-${patch}${devrelease}" and cz bump --devrelease 1 Tag created is 0.0-2dev1; literal ${devrelease} does not appear
tag_format = "v$version" (no ${devrelease}) and cz bump --devrelease 1 Tag created is v0.0.2dev1 — existing behaviour unchanged
tag_format = "$major.$minor.$patch$devrelease" and cz bump (no dev component) ${devrelease} renders as the empty string; tag is 0.0.2
Second cz bump --devrelease 2 after a successful first dev bump No duplicate-tag error; tag 0.0-2dev2 is created correctly

Steps to Test This Pull Request

git fetch fork fix/1615-tag-format-devrelease
git checkout fork/fix/1615-tag-format-devrelease

# 1. Targeted regression test.
uv run pytest tests/test_bump_normalize_tag.py -v

# 2. Reproduce the bug, then verify the fix.
mkdir /tmp/cz-1615 && cd /tmp/cz-1615
git init
git config user.name test && git config user.email test@example.com
cat > cz.toml << 'EOF'
[tool.commitizen]
name = "cz_conventional_commits"
tag_format = "${major}.${minor}-${patch}${devrelease}"
version_scheme = "semver2"
version = "0.0.1"
EOF
echo "# test" > README.md
git add README.md cz.toml
git commit -m "fix: initial"
cz bump --devrelease 1 --yes
git tag --list
# Expected:  0.0-2dev1
# NOT:       0.0-2${devrelease}

# Second dev bump must also succeed (previously failed with duplicate tag).
echo "change" >> README.md
git add README.md && git commit -m "fix: another change"
cz bump --devrelease 2 --yes
git tag --list   # should contain 0.0-2dev1 and 0.0-2dev2

Additional Context

This fix was identified during the open-issues audit tracked in #1964. A triage note (@bearomorphism, 2026-05-09) confirmed the bug reproduces on master (v4.15.1), pinpointed commitizen/tags.py:217–223 as the site of the missing substitution, and noted that #1614 is a companion issue where ${prerelease} has a similar (but distinct) malformed-tag problem in the same normalize_tag path.

Merge dependency: this PR should land together with #1972 (fix(tags): widen prerelease and devrelease tag regexes for SemVer2). Without #1972, a tag like 0.0-2dev1 created by this fix cannot be parsed back by extract_version, breaking subsequent bumps. See PR comment for the full explanation.

TagRules.normalize_tag only substituted `version`, `major`, `minor`,
`patch` and `prerelease`. Users with `tag_format` referencing
`` got the literal placeholder in their generated tag
(e.g. `0.0-2`), which then broke subsequent bumps and
changelog generation.

Render `devrelease` as `dev<N>` when the version has a dev release,
matching how dev releases appear in PEP-440 / SemVer version strings,
and as the empty string otherwise -- mirroring the `prerelease`
behaviour.

Closes commitizen-tools#1615

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented May 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.23%. Comparing base (4b93a50) to head (5269c23).
⚠️ Report is 3 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1967   +/-   ##
=======================================
  Coverage   98.23%   98.23%           
=======================================
  Files          61       61           
  Lines        2779     2781    +2     
=======================================
+ Hits         2730     2732    +2     
  Misses         49       49           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@bearomorphism

Copy link
Copy Markdown
Collaborator Author

Reviewer flagged dependency on #1972 (fix(tags): widen prerelease and devrelease tag regexes for SemVer2):

This PR substitutes ${devrelease} as dev<N> (no leading dot). The current tag-parsing regex in commitizen/defaults.py::get_tag_regexes is (?P<devrelease>\.dev\d+)? (requires the leading dot), so without #1972 a tag created via this PR's substitution would fail to round-trip through TagRules.is_version_tag / extract_version.

#1972 widens the regex to \.?dev\d+ (optional leading dot), restoring round-trip. Both PRs should land together — #1972 first, or in a single squash-merge — to avoid creating unparseable tags between merges.

Sorry for the missing cross-link; flagging it explicitly here so reviewers don't have to discover it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes tag rendering in TagRules.normalize_tag by ensuring ${devrelease} / $devrelease is substituted when building tag names from tag_format, addressing a bug where tags could be created with a literal ${devrelease} suffix.

Changes:

  • Compute a devrelease substitution value from version.dev (rendered as dev<N> or "").
  • Include devrelease in the Template.safe_substitute(...) mapping used by normalize_tag.
  • Add regression test cases covering devrelease present, devrelease zero, and no devrelease.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
commitizen/tags.py Adds computation and substitution of devrelease in TagRules.normalize_tag.
tests/test_bump_normalize_tag.py Adds parameterized cases validating $devrelease substitution behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread commitizen/tags.py
Comment on lines +217 to +220
# `dev<N>` to match how dev releases appear in PEP-440 / SemVer
# version strings.
dev = getattr(version, "dev", None)
devrelease = f"dev{dev}" if dev is not None else ""

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is exactly what PR #1972 (#1972) addresses by widening the devrelease tag-regex to \.?dev\d+ (optional leading dot), so $prerelease.$devrelease formats round-trip even when the substituted devrelease has no leading dot. The two PRs should land together (or be squash-merged in the right order). I'll leave this thread for the reviewer to resolve once they've cross-checked #1972.

@Manny7717 Manny7717 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally (head 5269c23, base upstream master 4184174)

Regression proven (end-to-end):

  • Direct probe — TagRules().normalize_tag("1.2.3.dev1", "v$major.$minor.$patch.$devrelease"): base → v1.2.3.$devrelease (literal placeholder, the #1615 bug), head → v1.2.3.dev1. Plain 1.2.3 → base 1.2.3$devrelease vs head 1.2.3 (empty devrelease). 1.2.3.dev01.2.3dev0. All three new parametrized cases match.
  • Real CLI — fresh repo with version_scheme = "semver2", tag_format = "$major.$minor.$patch$devrelease", cz bump --devrelease 1: upstream master creates the literal tag 0.1.0$devrelease (then fails with [NO_COMMITS_TO_BUMP] on the next run because the tag doesn't match), head creates 0.1.0dev1 and tags cleanly. This is exactly the duplicate-tags failure in #1615.

Tests/CI: tests/test_bump_normalize_tag.py 12/12 + tests/test_tags.py 18/18 on head; all matrix checks green (python-check 3.10-3.14 x os, codecov, lint).

Notes (non-blocking):

  • getattr(version, "dev", None) correctly handles both PEP440 and SemVer scheme version objects (verified both render dev<N>).
  • safe_substitute leaves the placeholder alone if a version ever lacks .dev (empty string fallback) — behavior matches the documented contract.

Clean, minimal fix for a real bug. No issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment