Skip to content

Commit df4ac0f

Browse files
authored
feat: read v-prefixed tags and keep their prefix on the next tag (#93)
1 parent 42dd4dc commit df4ac0f

13 files changed

Lines changed: 142 additions & 33 deletions

File tree

‎CONTEXT.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,19 @@ and nothing else — no network, no tag history, no commit range. A strategy nev
3939
version; the use-case does that around it.
4040

4141
**Latest tag**:
42-
The bump baseline: the **highest by SemVer precedence** among the repo's SemVer-parseable tags — not
43-
the most recently created one, and not the tag reachable from HEAD. `_select_latest_semver_tag`
44-
sorts by `semver.Version` and takes the maximum.
42+
The bump baseline: the **highest by SemVer precedence** among the repo's SemVer-form tags, bare
43+
(`1.2.0`) or **`v`-prefixed** (`v1.2.0`) — not the most recently created one, and not the tag
44+
reachable from HEAD. `_select_latest_semver_tag` sorts by `semver.Version` and takes the maximum.
45+
The new tag inherits the latest tag's **tag prefix**: after `v1.2.0` comes `v1.3.0`, after `1.2.0`
46+
comes `1.3.0`.
4547
_Avoid_: last tag, most recent tag. Both read as "newest by date", which names a different tag the
4648
moment a patch on an older line is pushed after a newer minor.
4749

50+
**Tag prefix**:
51+
What precedes the SemVer version in a tag name: lowercase `v`, or nothing. Any other prefix
52+
(`V1.2.0`, `release-1.2.0`) makes the tag non-SemVer-form, so it is never a latest-tag candidate.
53+
_Avoid_: bare "prefix". The `branch-prefix` strategy owns that word for `feature/`, `bugfix/`, ….
54+
4855
**Outcome**:
4956
What a run did, as the closed sum `Created | DryRun | NoTags | AlreadyTagged | NoBump` in
5057
`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
6168
which form you mean.
6269

6370
**Release tag** / **floating major tag**:
64-
A release tag is **bare semver, no `v`** (`0.4.0`): what the tool emits, what `just publish` feeds to
71+
This repo's own release tags are **bare semver, no `v`** (`0.4.0`): what semvertag emits here because
72+
its latest tag is bare, what `just publish` feeds to
6573
`uv version $GITHUB_REF_NAME`, and what `release.yml` triggers on. The floating major tag is
6674
**`v`-prefixed** (`v0`): a single moving ref `release.yml` force-updates so action consumers can pin
6775
`uses: modern-python/semvertag@v0`. Two conventions, one repo; a `v` on a release tag breaks

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ uvx semvertag tag
2727
```
2828

2929
semvertag bumps from the highest existing semver tag and never creates
30-
the first one. Before the first run, create a plain semver tag such as
31-
`0.1.0` (a `v` prefix does not parse and is ignored).
30+
the first one. Before the first run, create a semver tag such as
31+
`0.1.0`, or `v0.1.0` if you want `v`-prefixed tags: each new tag keeps
32+
the prefix of the one it bumps from.
3233

3334
## Use it in GitLab CI
3435

‎action.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ inputs:
2222

2323
outputs:
2424
tag:
25-
description: 'The created tag (e.g. 1.2.3), or empty string if no bump was warranted.'
25+
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.'
2626
value: ${{ steps.run.outputs.tag }}
2727
bump:
2828
description: 'The computed bump: none | patch | minor | major.'
Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
# The bump baseline is SemVer-form only, and a prerelease baseline finalizes
22

3-
`_select_latest_semver_tag` keeps only tags `semver.Version.parse` accepts, strips build metadata,
4-
and takes the maximum by SemVer precedence; `_compute_new_version` then calls `Version.next_version`
3+
`_select_latest_semver_tag` keeps only tags `semver.Version.parse` accepts once a leading `v` is
4+
stripped, strips build metadata, and takes the maximum by SemVer precedence; `_compute_new_version`
5+
then calls `Version.next_version`
56
rather than `bump_*`, so a prerelease baseline finalizes (`1.0.0-rc.1` plus a patch bump gives
67
`1.0.0`, not `1.0.1`). That is the correct release-ramp semantics and is identical to `bump_*` on
78
every stable baseline, so it changed no existing behaviour. PEP 440 prereleases such as `0.9.0rc1`
89
are deliberately skipped: python-semver cannot parse them, its `coerce` recipe discards the `rc1` and
910
makes a prerelease masquerade as final, and honest support means running `packaging` alongside
10-
`semver` to consume a form a SemVer tagger should not have to. A leading `v` is skipped too, which is
11-
a deferral rather than a rejection, since it is a one-line strip but would make semvertag consume a
12-
convention it does not emit; the cost until then is that a repo whose history is entirely
13-
`v`-prefixed reports `NoTags` and never bumps.
11+
`semver` to consume a form a SemVer tagger should not have to. A leading lowercase `v` is different:
12+
it is a prefix on a SemVer-form version, not another version grammar, so it is stripped before
13+
parsing and carried onto the next tag; a repo keeps whichever convention its latest tag uses (a `v`
14+
tag wins an exact precedence tie). Any other prefix, `V` included, is skipped.

‎docs/index.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ git tag.
1818
## Quick start
1919

2020
semvertag bumps from the highest existing semver tag and never creates
21-
the first one. Before the first run, create a plain semver tag such as
22-
`0.1.0` (a `v` prefix does not parse and is ignored).
21+
the first one. Before the first run, create a semver tag such as
22+
`0.1.0`, or `v0.1.0` if you want `v`-prefixed tags: each new tag keeps
23+
the prefix of the one it bumps from.
2324

2425
### GitHub Actions
2526

‎docs/providers/github.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,18 @@ ref via the GitHub API. If no bump is warranted, the job exits 0
4141
without pushing.
4242
4343
> semvertag bumps from the highest existing semver tag
44-
> and never creates the first one. It reads only plain semver tags such
45-
> as `0.1.0`; a `v` prefix (`v0.1.0`) does not parse and is ignored. Until
46-
> one exists, every run reports `no_tags` and exits 0. Push one with
47-
> `git tag 0.1.0 && git push origin 0.1.0`.
44+
> and never creates the first one. It reads plain semver tags such as
45+
> `0.1.0` and `v`-prefixed ones such as `v0.1.0`, and each new tag keeps
46+
> the prefix of the one it bumps from. Other forms (`V0.1.0`,
47+
> `release-0.1.0`) are ignored. Until a readable tag exists, every run
48+
> reports `no_tags` and exits 0. Push one with
49+
> `git tag 0.1.0 && git push origin 0.1.0` (or `v0.1.0` for `v` tags).
50+
51+
> **Upgrading from a release that ignored `v` tags:** if your history is
52+
> `v`-prefixed and you seeded a bare tag such as `0.1.0` to get started,
53+
> semvertag now bumps from whichever tag has the highest version, usually
54+
> your latest `v` tag (after `v1.4.0` comes `v1.5.0`, not `0.2.0`). When
55+
> the same version exists in both forms, the `v` tag wins.
4856

4957
> semvertag detects GitHub Actions from the `GITHUB_ACTIONS=true` env
5058
> 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:
97105

98106
| Output | Value |
99107
|---|---|
100-
| `tag` | The created tag (e.g. `1.2.3`), or empty string when `status` is `no-bump`. |
108+
| `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`. |
101109
| `bump` | `none` \| `patch` \| `minor` \| `major`. |
102110
| `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. |
103111

‎docs/providers/gitlab.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,18 @@ project's `origin`. If no bump is warranted, the job exits 0 without
4444
pushing.
4545

4646
> semvertag bumps from the highest existing semver tag
47-
> and never creates the first one. It reads only plain semver tags such
48-
> as `0.1.0`; a `v` prefix (`v0.1.0`) does not parse and is ignored. Until
49-
> one exists, every run reports `no_tags` and exits 0. Push one with
50-
> `git tag 0.1.0 && git push origin 0.1.0`.
47+
> and never creates the first one. It reads plain semver tags such as
48+
> `0.1.0` and `v`-prefixed ones such as `v0.1.0`, and each new tag keeps
49+
> the prefix of the one it bumps from. Other forms (`V0.1.0`,
50+
> `release-0.1.0`) are ignored. Until a readable tag exists, every run
51+
> reports `no_tags` and exits 0. Push one with
52+
> `git tag 0.1.0 && git push origin 0.1.0` (or `v0.1.0` for `v` tags).
53+
54+
> **Upgrading from a release that ignored `v` tags:** if your history is
55+
> `v`-prefixed and you seeded a bare tag such as `0.1.0` to get started,
56+
> semvertag now bumps from whichever tag has the highest version, usually
57+
> your latest `v` tag (after `v1.4.0` comes `v1.5.0`, not `0.2.0`). When
58+
> the same version exists in both forms, the `v` tag wins.
5159

5260
> `resource_group: semvertag` makes GitLab serialize concurrent
5361
> `semvertag` jobs across pipelines on the same project, so

‎semvertag/_outcome.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
# words NoTags/AlreadyTagged differently on purpose — edit both if you change the
99
# message for one audience.
1010
_NO_TAGS_REASON: typing.Final = (
11-
"No prior semver-conforming tags found; create an initial tag such as 0.1.0 on a default-branch commit."
11+
"No prior semver-conforming tags found; create an initial tag such as 0.1.0 "
12+
"(or v0.1.0 for v-prefixed tags) on a default-branch commit."
1213
)
1314
_ALREADY_TAGGED_REASON: typing.Final = "Latest commit already tagged."
1415

‎semvertag/_output.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def _format_outcome(outcome: Outcome, *, strategy: str) -> str:
6565
case NoTags():
6666
return (
6767
"No tag created — no prior semver-conforming tag to bump from; "
68-
"create an initial tag such as 0.1.0 on a default-branch commit."
68+
"create an initial tag such as 0.1.0 (or v0.1.0 for v-prefixed tags) on a default-branch commit."
6969
)
7070
case AlreadyTagged(tag=tag):
7171
return f"No tag created — latest commit is already tagged {tag}."

‎semvertag/_use_case.py‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from semvertag.strategies._base import BumpStrategy
1111

1212

13+
_V_PREFIX: typing.Final = "v"
14+
15+
1316
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
1417
class SemvertagUseCase:
1518
provider: Provider
@@ -39,30 +42,34 @@ def __call__(self, *, output: Output, dry_run: bool = False) -> Outcome:
3942
NoBump(status=self.strategy.no_bump_status, reason=self.strategy.no_bump_reason, commit=commit.sha),
4043
)
4144

42-
new_version: typing.Final = _compute_new_version(latest_version, bump)
45+
new_tag: typing.Final = _tag_prefix(latest_tag) + _compute_new_version(latest_version, bump)
4346
if dry_run:
44-
return self._emit(output, DryRun(tag=new_version, bump=bump, commit=commit.sha))
47+
return self._emit(output, DryRun(tag=new_tag, bump=bump, commit=commit.sha))
4548

46-
output.progress(f"Creating tag {new_version}...")
47-
self.provider.create_tag(name=new_version, commit_sha=commit.sha)
48-
return self._emit(output, Created(tag=new_version, bump=bump, commit=commit.sha))
49+
output.progress(f"Creating tag {new_tag}...")
50+
self.provider.create_tag(name=new_tag, commit_sha=commit.sha)
51+
return self._emit(output, Created(tag=new_tag, bump=bump, commit=commit.sha))
4952

5053
def _emit(self, output: Output, outcome: Outcome) -> Outcome:
5154
output.emit(outcome, strategy=self.strategy.name)
5255
return outcome
5356

5457

58+
def _tag_prefix(tag: Tag) -> str:
59+
return _V_PREFIX if tag.name.startswith(_V_PREFIX) else ""
60+
61+
5562
def _select_latest_semver_tag(tags: list[Tag]) -> tuple[Tag, semver.Version] | None:
5663
parsed: list[tuple[semver.Version, Tag]] = []
5764
for tag in tags:
5865
try:
59-
version = semver.Version.parse(tag.name).replace(build=None)
66+
version = semver.Version.parse(tag.name.removeprefix(_V_PREFIX)).replace(build=None)
6067
except ValueError:
6168
continue
6269
parsed.append((version, tag))
6370
if not parsed:
6471
return None
65-
parsed.sort(key=lambda item: item[0])
72+
parsed.sort(key=lambda item: (item[0], item[1].name.startswith(_V_PREFIX)))
6673
version, tag = parsed[-1]
6774
return tag, version
6875

0 commit comments

Comments
 (0)