From 5f4ca46409c67dc01e4ea7fee0e076aefa5ab851 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 4 Oct 2026 11:39:17 +0300 Subject: [PATCH] feat: read v-prefixed tags and keep their prefix on the next tag --- CONTEXT.md | 16 ++++-- README.md | 5 +- action.yml | 2 +- docs/adr/0003-semver-form-tags-only.md | 13 ++--- docs/index.md | 5 +- docs/providers/github.md | 18 +++++-- docs/providers/gitlab.md | 16 ++++-- semvertag/_outcome.py | 3 +- semvertag/_output.py | 2 +- semvertag/_use_case.py | 21 +++++--- tests/unit/test_outcome.py | 1 + tests/unit/test_output_rich.py | 1 + tests/unit/test_use_case.py | 72 ++++++++++++++++++++++++++ 13 files changed, 142 insertions(+), 33 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 80d8fe5..bceb205 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,12 +39,19 @@ and nothing else — no network, no tag history, no commit range. A strategy nev version; the use-case does that around it. **Latest tag**: -The bump baseline: the **highest by SemVer precedence** among the repo's SemVer-parseable tags — not -the most recently created one, and not the tag reachable from HEAD. `_select_latest_semver_tag` -sorts by `semver.Version` and takes the maximum. +The bump baseline: the **highest by SemVer precedence** among the repo's SemVer-form tags, bare +(`1.2.0`) or **`v`-prefixed** (`v1.2.0`) — not the most recently created one, and not the tag +reachable from HEAD. `_select_latest_semver_tag` sorts by `semver.Version` and takes the maximum. +The new tag inherits the latest tag's **tag prefix**: after `v1.2.0` comes `v1.3.0`, after `1.2.0` +comes `1.3.0`. _Avoid_: last tag, most recent tag. Both read as "newest by date", which names a different tag the moment a patch on an older line is pushed after a newer minor. +**Tag prefix**: +What precedes the SemVer version in a tag name: lowercase `v`, or nothing. Any other prefix +(`V1.2.0`, `release-1.2.0`) makes the tag non-SemVer-form, so it is never a latest-tag candidate. +_Avoid_: bare "prefix". The `branch-prefix` strategy owns that word for `feature/`, `bugfix/`, …. + **Outcome**: What a run did, as the closed sum `Created | DryRun | NoTags | AlreadyTagged | NoBump` in `semvertag/_outcome.py`. It is internal and free to grow — the renderers `match` it exhaustively, so @@ -61,7 +68,8 @@ is the form semvertag publishes *itself* under, and what `release.yml` means by which form you mean. **Release tag** / **floating major tag**: -A release tag is **bare semver, no `v`** (`0.4.0`): what the tool emits, what `just publish` feeds to +This repo's own release tags are **bare semver, no `v`** (`0.4.0`): what semvertag emits here because +its latest tag is bare, what `just publish` feeds to `uv version $GITHUB_REF_NAME`, and what `release.yml` triggers on. The floating major tag is **`v`-prefixed** (`v0`): a single moving ref `release.yml` force-updates so action consumers can pin `uses: modern-python/semvertag@v0`. Two conventions, one repo; a `v` on a release tag breaks diff --git a/README.md b/README.md index 7c15a6b..bd152a9 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,9 @@ uvx semvertag tag ``` semvertag bumps from the highest existing semver tag and never creates -the first one. Before the first run, create a plain semver tag such as -`0.1.0` (a `v` prefix does not parse and is ignored). +the first one. Before the first run, create a semver tag such as +`0.1.0`, or `v0.1.0` if you want `v`-prefixed tags: each new tag keeps +the prefix of the one it bumps from. ## Use it in GitLab CI diff --git a/action.yml b/action.yml index 9e2e1ab..29f6a94 100644 --- a/action.yml +++ b/action.yml @@ -22,7 +22,7 @@ inputs: outputs: tag: - description: 'The created tag (e.g. 1.2.3), or empty string if no bump was warranted.' + description: 'The created tag (e.g. 1.2.3, or v1.2.3 in a v-prefixed repo), or empty string if no bump was warranted.' value: ${{ steps.run.outputs.tag }} bump: description: 'The computed bump: none | patch | minor | major.' diff --git a/docs/adr/0003-semver-form-tags-only.md b/docs/adr/0003-semver-form-tags-only.md index 0bd568a..df855d5 100644 --- a/docs/adr/0003-semver-form-tags-only.md +++ b/docs/adr/0003-semver-form-tags-only.md @@ -1,13 +1,14 @@ # The bump baseline is SemVer-form only, and a prerelease baseline finalizes -`_select_latest_semver_tag` keeps only tags `semver.Version.parse` accepts, strips build metadata, -and takes the maximum by SemVer precedence; `_compute_new_version` then calls `Version.next_version` +`_select_latest_semver_tag` keeps only tags `semver.Version.parse` accepts once a leading `v` is +stripped, strips build metadata, and takes the maximum by SemVer precedence; `_compute_new_version` +then calls `Version.next_version` rather than `bump_*`, so a prerelease baseline finalizes (`1.0.0-rc.1` plus a patch bump gives `1.0.0`, not `1.0.1`). That is the correct release-ramp semantics and is identical to `bump_*` on every stable baseline, so it changed no existing behaviour. PEP 440 prereleases such as `0.9.0rc1` are deliberately skipped: python-semver cannot parse them, its `coerce` recipe discards the `rc1` and makes a prerelease masquerade as final, and honest support means running `packaging` alongside -`semver` to consume a form a SemVer tagger should not have to. A leading `v` is skipped too, which is -a deferral rather than a rejection, since it is a one-line strip but would make semvertag consume a -convention it does not emit; the cost until then is that a repo whose history is entirely -`v`-prefixed reports `NoTags` and never bumps. +`semver` to consume a form a SemVer tagger should not have to. A leading lowercase `v` is different: +it is a prefix on a SemVer-form version, not another version grammar, so it is stripped before +parsing and carried onto the next tag; a repo keeps whichever convention its latest tag uses (a `v` +tag wins an exact precedence tie). Any other prefix, `V` included, is skipped. diff --git a/docs/index.md b/docs/index.md index 29a7037..f31aca8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,8 +18,9 @@ git tag. ## Quick start semvertag bumps from the highest existing semver tag and never creates -the first one. Before the first run, create a plain semver tag such as -`0.1.0` (a `v` prefix does not parse and is ignored). +the first one. Before the first run, create a semver tag such as +`0.1.0`, or `v0.1.0` if you want `v`-prefixed tags: each new tag keeps +the prefix of the one it bumps from. ### GitHub Actions diff --git a/docs/providers/github.md b/docs/providers/github.md index 1b84659..08c52ee 100644 --- a/docs/providers/github.md +++ b/docs/providers/github.md @@ -41,10 +41,18 @@ ref via the GitHub API. If no bump is warranted, the job exits 0 without pushing. > semvertag bumps from the highest existing semver tag -> and never creates the first one. It reads only plain semver tags such -> as `0.1.0`; a `v` prefix (`v0.1.0`) does not parse and is ignored. Until -> one exists, every run reports `no_tags` and exits 0. Push one with -> `git tag 0.1.0 && git push origin 0.1.0`. +> and never creates the first one. It reads plain semver tags such as +> `0.1.0` and `v`-prefixed ones such as `v0.1.0`, and each new tag keeps +> the prefix of the one it bumps from. Other forms (`V0.1.0`, +> `release-0.1.0`) are ignored. Until a readable tag exists, every run +> reports `no_tags` and exits 0. Push one with +> `git tag 0.1.0 && git push origin 0.1.0` (or `v0.1.0` for `v` tags). + +> **Upgrading from a release that ignored `v` tags:** if your history is +> `v`-prefixed and you seeded a bare tag such as `0.1.0` to get started, +> semvertag now bumps from whichever tag has the highest version, usually +> your latest `v` tag (after `v1.4.0` comes `v1.5.0`, not `0.2.0`). When +> the same version exists in both forms, the `v` tag wins. > semvertag detects GitHub Actions from the `GITHUB_ACTIONS=true` env > var that GHA sets automatically, so the `--provider` flag is optional @@ -97,7 +105,7 @@ When you give the step an `id:`, downstream steps can read three outputs: | Output | Value | |---|---| -| `tag` | The created tag (e.g. `1.2.3`), or empty string when `status` is `no-bump`. | +| `tag` | The created tag (e.g. `1.2.3`, or `v1.2.3` in a `v`-prefixed repo), or empty string when `status` is `no-bump`. | | `bump` | `none` \| `patch` \| `minor` \| `major`. | | `status` | `created` (tag pushed) \| `no-bump` (nothing to tag: no prior tag, already tagged, no merge commit, or non-conforming commit). On CLI error the action itself exits non-zero and this output is not written. | diff --git a/docs/providers/gitlab.md b/docs/providers/gitlab.md index 3504aa9..d0a0529 100644 --- a/docs/providers/gitlab.md +++ b/docs/providers/gitlab.md @@ -44,10 +44,18 @@ project's `origin`. If no bump is warranted, the job exits 0 without pushing. > semvertag bumps from the highest existing semver tag -> and never creates the first one. It reads only plain semver tags such -> as `0.1.0`; a `v` prefix (`v0.1.0`) does not parse and is ignored. Until -> one exists, every run reports `no_tags` and exits 0. Push one with -> `git tag 0.1.0 && git push origin 0.1.0`. +> and never creates the first one. It reads plain semver tags such as +> `0.1.0` and `v`-prefixed ones such as `v0.1.0`, and each new tag keeps +> the prefix of the one it bumps from. Other forms (`V0.1.0`, +> `release-0.1.0`) are ignored. Until a readable tag exists, every run +> reports `no_tags` and exits 0. Push one with +> `git tag 0.1.0 && git push origin 0.1.0` (or `v0.1.0` for `v` tags). + +> **Upgrading from a release that ignored `v` tags:** if your history is +> `v`-prefixed and you seeded a bare tag such as `0.1.0` to get started, +> semvertag now bumps from whichever tag has the highest version, usually +> your latest `v` tag (after `v1.4.0` comes `v1.5.0`, not `0.2.0`). When +> the same version exists in both forms, the `v` tag wins. > `resource_group: semvertag` makes GitLab serialize concurrent > `semvertag` jobs across pipelines on the same project, so diff --git a/semvertag/_outcome.py b/semvertag/_outcome.py index 8d9a679..31a9e23 100644 --- a/semvertag/_outcome.py +++ b/semvertag/_outcome.py @@ -8,7 +8,8 @@ # words NoTags/AlreadyTagged differently on purpose — edit both if you change the # message for one audience. _NO_TAGS_REASON: typing.Final = ( - "No prior semver-conforming tags found; create an initial tag such as 0.1.0 on a default-branch commit." + "No prior semver-conforming tags found; create an initial tag such as 0.1.0 " + "(or v0.1.0 for v-prefixed tags) on a default-branch commit." ) _ALREADY_TAGGED_REASON: typing.Final = "Latest commit already tagged." diff --git a/semvertag/_output.py b/semvertag/_output.py index b848953..fd18e21 100644 --- a/semvertag/_output.py +++ b/semvertag/_output.py @@ -65,7 +65,7 @@ def _format_outcome(outcome: Outcome, *, strategy: str) -> str: case NoTags(): return ( "No tag created — no prior semver-conforming tag to bump from; " - "create an initial tag such as 0.1.0 on a default-branch commit." + "create an initial tag such as 0.1.0 (or v0.1.0 for v-prefixed tags) on a default-branch commit." ) case AlreadyTagged(tag=tag): return f"No tag created — latest commit is already tagged {tag}." diff --git a/semvertag/_use_case.py b/semvertag/_use_case.py index c15c711..6318c6c 100644 --- a/semvertag/_use_case.py +++ b/semvertag/_use_case.py @@ -10,6 +10,9 @@ from semvertag.strategies._base import BumpStrategy +_V_PREFIX: typing.Final = "v" + + @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class SemvertagUseCase: provider: Provider @@ -39,30 +42,34 @@ def __call__(self, *, output: Output, dry_run: bool = False) -> Outcome: NoBump(status=self.strategy.no_bump_status, reason=self.strategy.no_bump_reason, commit=commit.sha), ) - new_version: typing.Final = _compute_new_version(latest_version, bump) + new_tag: typing.Final = _tag_prefix(latest_tag) + _compute_new_version(latest_version, bump) if dry_run: - return self._emit(output, DryRun(tag=new_version, bump=bump, commit=commit.sha)) + return self._emit(output, DryRun(tag=new_tag, bump=bump, commit=commit.sha)) - output.progress(f"Creating tag {new_version}...") - self.provider.create_tag(name=new_version, commit_sha=commit.sha) - return self._emit(output, Created(tag=new_version, bump=bump, commit=commit.sha)) + output.progress(f"Creating tag {new_tag}...") + self.provider.create_tag(name=new_tag, commit_sha=commit.sha) + return self._emit(output, Created(tag=new_tag, bump=bump, commit=commit.sha)) def _emit(self, output: Output, outcome: Outcome) -> Outcome: output.emit(outcome, strategy=self.strategy.name) return outcome +def _tag_prefix(tag: Tag) -> str: + return _V_PREFIX if tag.name.startswith(_V_PREFIX) else "" + + def _select_latest_semver_tag(tags: list[Tag]) -> tuple[Tag, semver.Version] | None: parsed: list[tuple[semver.Version, Tag]] = [] for tag in tags: try: - version = semver.Version.parse(tag.name).replace(build=None) + version = semver.Version.parse(tag.name.removeprefix(_V_PREFIX)).replace(build=None) except ValueError: continue parsed.append((version, tag)) if not parsed: return None - parsed.sort(key=lambda item: item[0]) + parsed.sort(key=lambda item: (item[0], item[1].name.startswith(_V_PREFIX))) version, tag = parsed[-1] return tag, version diff --git a/tests/unit/test_outcome.py b/tests/unit/test_outcome.py index 43d20ce..de120ec 100644 --- a/tests/unit/test_outcome.py +++ b/tests/unit/test_outcome.py @@ -42,6 +42,7 @@ def test_no_tags_reason_says_how_to_seed_the_first_tag() -> None: reason: typing.Final = to_run_result(NoTags(commit=_COMMIT), strategy=_STRATEGY).reason assert reason is not None assert "create an initial tag such as 0.1.0" in reason + assert "or v0.1.0 for v-prefixed tags" in reason assert "v1.0" not in reason diff --git a/tests/unit/test_output_rich.py b/tests/unit/test_output_rich.py index ac5f780..a67a432 100644 --- a/tests/unit/test_output_rich.py +++ b/tests/unit/test_output_rich.py @@ -96,6 +96,7 @@ def test_matrix_keeps_stderr_for_errors(quiet: bool) -> None: [ (NoTags(commit="abc1234def"), "no prior semver-conforming tag"), (NoTags(commit="abc1234def"), "create an initial tag such as 0.1.0"), + (NoTags(commit="abc1234def"), "or v0.1.0 for v-prefixed tags"), (AlreadyTagged(tag="1.2.0", commit="abc1234def"), "already tagged 1.2.0"), ( NoBump(status="no_merge_commit", reason="Latest commit is not a merge commit.", commit="abc1234def"), diff --git a/tests/unit/test_use_case.py b/tests/unit/test_use_case.py index bd382a9..01f45d6 100644 --- a/tests/unit/test_use_case.py +++ b/tests/unit/test_use_case.py @@ -337,3 +337,75 @@ def test_build_metadata_baseline_still_bumps() -> None: assert selected is not None _tag, version = selected assert _compute_new_version(version, Bump.PATCH) == "1.2.4" + + +def test_new_tag_inherits_v_prefix_from_latest_tag() -> None: + use_case, provider, output = _make_use_case(tags=[Tag(name="v1.4.2", commit_sha=_PRIOR_SHA)]) + + result: typing.Final = use_case(output=output) + + assert isinstance(result, Created) + assert result.tag == "v1.5.0" + assert provider.create_tag_calls == [("v1.5.0", _LATEST_SHA)] + + +@pytest.mark.parametrize( + ("tag_names", "expected_tag"), + [ + (["0.1.0", "v1.4.2"], "v1.5.0"), + (["v0.9.0", "1.4.2"], "1.5.0"), + ], +) +def test_highest_precedence_wins_regardless_of_tag_prefix(tag_names: list[str], expected_tag: str) -> None: + use_case, _provider, output = _make_use_case( + tags=[Tag(name=name, commit_sha=f"sha{index}") for index, name in enumerate(tag_names)], + ) + + result: typing.Final = use_case(output=output) + + assert isinstance(result, Created) + assert result.tag == expected_tag + + +@pytest.mark.parametrize("tag_names", [["v1.4.2", "1.4.2"], ["1.4.2", "v1.4.2"]]) +def test_v_prefixed_tag_wins_an_exact_precedence_tie(tag_names: list[str]) -> None: + use_case, _provider, output = _make_use_case( + tags=[Tag(name=name, commit_sha=f"sha{index}") for index, name in enumerate(tag_names)], + ) + + result: typing.Final = use_case(output=output) + + assert isinstance(result, Created) + assert result.tag == "v1.5.0" + + +def test_v_prefixed_prerelease_baseline_finalizes_and_keeps_its_prefix() -> None: + use_case, _provider, output = _make_use_case( + tags=[Tag(name="v1.0.0-rc.1", commit_sha=_PRIOR_SHA)], + bump=Bump.PATCH, + ) + + result: typing.Final = use_case(output=output, dry_run=True) + + assert isinstance(result, DryRun) + assert result.tag == "v1.0.0" + + +@pytest.mark.parametrize("tag_name", ["V1.2.0", "release-1.2.0", "v0", "vv1.2.0"]) +def test_tags_with_another_tag_prefix_are_not_latest_tag_candidates(tag_name: str) -> None: + use_case, provider, output = _make_use_case(tags=[Tag(name=tag_name, commit_sha=_PRIOR_SHA)]) + + result: typing.Final = use_case(output=output) + + assert isinstance(result, NoTags) + assert provider.create_tag_calls == [] + + +def test_skips_with_already_tagged_when_head_carries_the_v_prefixed_latest_tag() -> None: + use_case, provider, output = _make_use_case(tags=[Tag(name="v1.4.2", commit_sha=_LATEST_SHA)]) + + result: typing.Final = use_case(output=output) + + assert isinstance(result, AlreadyTagged) + assert result.tag == "v1.4.2" + assert provider.create_tag_calls == []