fix(tags): substitute ${devrelease} placeholder in tag_format#1967
fix(tags): substitute ${devrelease} placeholder in tag_format#1967bearomorphism wants to merge 1 commit intocommitizen-tools:masterfrom
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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. |
|
Reviewer flagged dependency on #1972 ( This PR substitutes #1972 widens the regex to Sorry for the missing cross-link; flagging it explicitly here so reviewers don't have to discover it. |
There was a problem hiding this comment.
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
devreleasesubstitution value fromversion.dev(rendered asdev<N>or""). - Include
devreleasein theTemplate.safe_substitute(...)mapping used bynormalize_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.
| # `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 "" |
There was a problem hiding this comment.
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.
Description
Closes #1615.
Why
TagRules.normalize_tagincommitizen/tags.pybuilds tag strings by substituting placeholders intag_formatusing Python'sstring.Template.safe_substitute. Before this fix the substitution dictionary (lines 217–223) includedversion,major,minor,patch, andprerelease— but notdevrelease. Atag_formatthat 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 --yeswithtag_format = "${major}.${minor}-${patch}${devrelease}"producedtag 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 pinpointedcommitizen/tags.py:217–223as the missing substitution.The fix adds a
devreleasevariable to thesafe_substitutecall innormalize_tag. The value is computed asf"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 ofprerelease(version.prerelease or "").What changed
commitizen/tags.pydevreleasefromversion.dev(after line 214) and add it to thesafe_substitutecall innormalize_tagtests/test_bump_normalize_tag.pydev1), dev release at zero (dev0), and no dev release (empty string)How it works
version.dev(frompackaging.version.Version) is anint | None— it holds the dev-release number (e.g.1for1.2.3.dev1) orNoneif the version is not a dev release.getattr(version, "dev", None)is used defensively so that version-scheme implementations that don't expose adevattribute (custom schemes satisfyingVersionProtocol) don't raiseAttributeError.f"dev{dev}"(e.g."dev1"), which matches how PEP 440 and the SemVer2 scheme stringify dev releases in version strings. For atag_formatof${major}.${minor}-${patch}${devrelease}and version0.0.2dev1, this produces the tag0.0-2dev1.f"dev{dev}"rather thanstr(version.dev)or the full version string? Using the raw integer would produce1instead ofdev1, which is not the conventional dev-release suffix. Usingstr(version)would embed the full version string where only the suffix is wanted. Thef"dev{dev}"form gives the caller the smallest composable unit — it can be placed anywhere intag_formatwithout extra text leaking in.string.Template.safe_substitute(already used atcommitizen/tags.py:217) leaves unrecognised placeholders unchanged rather than raisingKeyError. Addingdevreleaseto the dict means${devrelease}is now substituted;tag_formatstrings that don't reference it are completely unaffected.commitizen/defaults.py::get_tag_regexes) currently requires a leading dot beforedev(\.dev\d+). Tags created with this PR's substitution (e.g.0.0-2dev1) don't round-trip throughTagRules.is_version_tag/extract_versionwithout 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_formatstrings that do not reference${devrelease}are completely unaffected —safe_substituteignores keys whose placeholders don't appear in the template.prereleasesubstitution and all other existing template variables are unchanged.tests/test_bump_normalize_tag.pycontinue to pass.Checklist
Was generative AI tooling used to co-author this PR?
Generated-by: Claude following the guidelines
Code Changes
uv run poe alllocally to ensure this change passes linter check and testsExpected Behavior
tag_format = "${major}.${minor}-${patch}${devrelease}"andcz bump --devrelease 10.0-2dev1; literal${devrelease}does not appeartag_format = "v$version"(no${devrelease}) andcz bump --devrelease 1v0.0.2dev1— existing behaviour unchangedtag_format = "$major.$minor.$patch$devrelease"andcz bump(no dev component)${devrelease}renders as the empty string; tag is0.0.2cz bump --devrelease 2after a successful first dev bump0.0-2dev2is created correctlySteps to Test This Pull Request
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–223as 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 samenormalize_tagpath.Merge dependency: this PR should land together with #1972 (
fix(tags): widen prerelease and devrelease tag regexes for SemVer2). Without #1972, a tag like0.0-2dev1created by this fix cannot be parsed back byextract_version, breaking subsequent bumps. See PR comment for the full explanation.