Skip to content

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

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

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

Conversation

@bearomorphism
Copy link
Copy Markdown
Collaborator

@bearomorphism bearomorphism commented May 9, 2026

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
Copy link
Copy Markdown

codecov Bot commented May 9, 2026

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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tag not set correctly using devreleases with semver2 and custom tag_format

2 participants