Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
13 changes: 7 additions & 6 deletions docs/adr/0003-semver-form-tags-only.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 13 additions & 5 deletions docs/providers/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |

Expand Down
16 changes: 12 additions & 4 deletions docs/providers/gitlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion semvertag/_outcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down
2 changes: 1 addition & 1 deletion semvertag/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
Expand Down
21 changes: 14 additions & 7 deletions semvertag/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_outcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_output_rich.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/test_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
Loading