Title: fix(tags): substitute ${devrelease} placeholder in tag_format by bearomorphism · Pull Request #1967 · commitizen-tools/commitizen · GitHub
Open Graph Title: fix(tags): substitute ${devrelease} placeholder in tag_format by bearomorphism · Pull Request #1967 · commitizen-tools/commitizen
X Title: fix(tags): substitute ${devrelease} placeholder in tag_format by bearomorphism · Pull Request #1967 · commitizen-tools/commitizen
Description: 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 #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 #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 I have read the contributing guidelines 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.
Open Graph Description: 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 t...
X Description: 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. Befo...
Opengraph URL: https://github.com/commitizen-tools/commitizen/pull/1967
X: @github
Domain: github.com
| route-pattern | /:user_id/:repository/pull/:id/files(.:format) |
| route-controller | pull_requests |
| route-action | files |
| fetch-nonce | v2:88bb8f99-3676-49bd-8b8c-1f1511a171db |
| current-catalog-service-hash | ae870bc5e265a340912cde392f23dad3671a0a881730ffdadd82f2f57d81641b |
| request-id | CF18:1F1613:B519F0:103B208:6A4E47EE |
| html-safe-nonce | 7e28bb9f036bf2d7c346be890599d761fc79e06d0cef52fdc3e5d87674dd4f06 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJDRjE4OjFGMTYxMzpCNTE5RjA6MTAzQjIwODo2QTRFNDdFRSIsInZpc2l0b3JfaWQiOiI2MzEzNzg3MTQxOTQ0OTU0NzAiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 42da9167a6c6f2bb2878d246eeb62e88f490199022931bfd14e9465b5c523529 |
| hovercard-subject-tag | pull_request:3654642901 |
| github-keyboard-shortcuts | repository,pull-request-list,pull-request-conversation,pull-request-files-changed,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/commitizen-tools/commitizen/pull/1967/files |
| twitter:image | https://avatars.githubusercontent.com/u/26526132?s=400&v=4 |
| twitter:card | summary_large_image |
| og:image | https://avatars.githubusercontent.com/u/26526132?s=400&v=4 |
| og:image:alt | 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 t... |
| og:site_name | GitHub |
| og:type | object |
| hostname | github.com |
| expected-hostname | github.com |
| None | 030096ee0db095447bfe77409d33bfac127ca7128299c58deef27c52eaa1b1f0 |
| turbo-cache-control | no-preview |
| diff-view | unified |
| go-import | github.com/commitizen-tools/commitizen git https://github.com/commitizen-tools/commitizen.git |
| octolytics-dimension-user_id | 62252524 |
| octolytics-dimension-user_login | commitizen-tools |
| octolytics-dimension-repository_id | 106127589 |
| octolytics-dimension-repository_nwo | commitizen-tools/commitizen |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 106127589 |
| octolytics-dimension-repository_network_root_nwo | commitizen-tools/commitizen |
| turbo-body-classes | logged-out env-production page-responsive full-width |
| disable-turbo | true |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | e8506f6d0538364886e3f0153c154c410965e70d |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width