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
10 changes: 10 additions & 0 deletions docs/providers/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,16 @@ The default endpoint is `https://api.github.com`. Set
workflow-level env pointing to the instance's API root, e.g.
`https://github.example.com/api/v3`.

### `None of the repo's N tags is SemVer-form (1.2.0 or v1.2.0)`

The repo has tags, but semvertag reads none of them as a bump baseline,
so the run reports `no_tags` and exits 0 without tagging. It reads only
plain SemVer tags (`1.2.0`, `1.0.0-rc.1`) and the same with a lowercase
`v` (`v1.2.0`). It ignores other forms, such as `V1.2.0`,
`release-1.2.0`, PEP 440 prereleases like `0.9.0rc1`, and floating
major tags like `v1`. Push a SemVer tag on a default-branch commit to
start from, e.g. `git tag v0.1.0 && git push origin v0.1.0`.

### A bump-worthy push was never tagged

The run for that push failed or was skipped. Re-run it. Each run judges
Expand Down
10 changes: 10 additions & 0 deletions docs/providers/gitlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,16 @@ The default endpoint is `https://gitlab.com` and is not auto-derived
from `CI_SERVER_FQDN`. Set `SEMVERTAG_GITLAB__ENDPOINT` as a
project-level CI/CD variable pointing to the instance's API root.

### `None of the repo's N tags is SemVer-form (1.2.0 or v1.2.0)`

The repo has tags, but semvertag reads none of them as a bump baseline,
so the run reports `no_tags` and exits 0 without tagging. It reads only
plain SemVer tags (`1.2.0`, `1.0.0-rc.1`) and the same with a lowercase
`v` (`v1.2.0`). It ignores other forms, such as `V1.2.0`,
`release-1.2.0`, PEP 440 prereleases like `0.9.0rc1`, and floating
major tags like `v1`. Push a SemVer tag on a default-branch commit to
start from, e.g. `git tag v0.1.0 && git push origin v0.1.0`.

### A bump-worthy push was never tagged

The run for that push failed or was skipped. Re-run it. Each run judges
Expand Down
18 changes: 13 additions & 5 deletions semvertag/_outcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
# These are the JSON wire reasons. The human terminal path (_output._format_outcome)
# 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 "
"(or v0.1.0 for v-prefixed tags) on a default-branch commit."
_SEED_ADVICE: typing.Final = (
"create an initial tag such as 0.1.0 (or v0.1.0 for v-prefixed tags) on a default-branch commit."
)
_NO_TAGS_REASON: typing.Final = f"No prior semver-conforming tags found; {_SEED_ADVICE}"
_ALREADY_TAGGED_REASON: typing.Final = "Latest commit already tagged."


Expand All @@ -37,6 +37,7 @@ class NoTags:
"""No prior semver tag to bump from; semvertag does not create the first one."""

commit: str
skipped_tag_count: int


@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
Expand Down Expand Up @@ -72,14 +73,14 @@ def to_run_result(outcome: Outcome, *, strategy: str) -> RunResult:
return RunResult(strategy=strategy, bump=bump.value, status="created", tag=tag, commit=commit, reason=None)
case DryRun(tag=tag, bump=bump, commit=commit):
return RunResult(strategy=strategy, bump=bump.value, status="dry_run", tag=tag, commit=commit, reason=None)
case NoTags(commit=commit):
case NoTags(commit=commit, skipped_tag_count=skipped_tag_count):
return RunResult(
strategy=strategy,
bump=Bump.NONE.value,
status="no_tags",
tag=None,
commit=commit,
reason=_NO_TAGS_REASON,
reason=_no_tags_reason(skipped_tag_count),
)
case AlreadyTagged(tag=tag, commit=commit):
return RunResult(
Expand All @@ -96,3 +97,10 @@ def to_run_result(outcome: Outcome, *, strategy: str) -> RunResult:
)
case _: # pragma: no cover - exhaustiveness guard; ty verifies every Outcome is matched
typing.assert_never(outcome)


def _no_tags_reason(skipped_tag_count: int) -> str:
if not skipped_tag_count:
return _NO_TAGS_REASON
noun: typing.Final = "tag" if skipped_tag_count == 1 else "tags"
return f"None of the repo's {skipped_tag_count} {noun} is SemVer-form (1.2.0 or v1.2.0); {_SEED_ADVICE}"
12 changes: 9 additions & 3 deletions semvertag/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@


_COMMIT_SHORT_LEN: typing.Final = 7
_SEED_ADVICE: typing.Final = (
"create an initial tag such as 0.1.0 (or v0.1.0 for v-prefixed tags) on a default-branch commit."
)


class Output(typing.Protocol):
Expand Down Expand Up @@ -62,10 +65,13 @@ def _format_outcome(outcome: Outcome, *, strategy: str) -> str:
case DryRun(tag=tag, bump=bump, commit=commit):
short = commit[:_COMMIT_SHORT_LEN]
return f"Dry run: would create tag {tag} on commit {short} (strategy: {strategy}, bump: {bump.value})"
case NoTags():
case NoTags(skipped_tag_count=0):
return f"No tag created — no prior semver-conforming tag to bump from; {_SEED_ADVICE}"
case NoTags(skipped_tag_count=skipped_tag_count):
noun: typing.Final = "tag" if skipped_tag_count == 1 else "tags"
return (
"No tag created — no prior semver-conforming tag to bump from; "
"create an initial tag such as 0.1.0 (or v0.1.0 for v-prefixed tags) on a default-branch commit."
f"No tag created — none of the repo's {skipped_tag_count} {noun} is SemVer-form "
f"(1.2.0 or v1.2.0); {_SEED_ADVICE}"
)
case AlreadyTagged(tag=tag):
return f"No tag created — latest commit is already tagged {tag}."
Expand Down
2 changes: 1 addition & 1 deletion semvertag/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def __call__(self, *, output: Output, dry_run: bool = False) -> Outcome:
selected: typing.Final = _select_latest_semver_tag(tags)

if selected is None:
return self._emit(output, NoTags(commit=commit.sha))
return self._emit(output, NoTags(commit=commit.sha, skipped_tag_count=len(tags)))

latest_tag, latest_version = selected
if latest_tag.commit_sha == commit.sha:
Expand Down
35 changes: 32 additions & 3 deletions tests/unit/test_outcome.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import typing

import pytest

from semvertag._outcome import (
_ALREADY_TAGGED_REASON,
_NO_TAGS_REASON,
Expand Down Expand Up @@ -32,14 +34,14 @@ def test_dry_run_maps_to_dry_run_wire_result() -> None:


def test_no_tags_maps_with_none_bump_and_fixed_reason() -> None:
result: typing.Final = to_run_result(NoTags(commit=_COMMIT), strategy=_STRATEGY)
result: typing.Final = to_run_result(NoTags(commit=_COMMIT, skipped_tag_count=0), strategy=_STRATEGY)
assert result == RunResult(
strategy=_STRATEGY, bump="none", status="no_tags", tag=None, commit=_COMMIT, reason=_NO_TAGS_REASON
)


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
reason: typing.Final = to_run_result(NoTags(commit=_COMMIT, skipped_tag_count=0), 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
Expand Down Expand Up @@ -74,5 +76,32 @@ def test_no_bump_passes_strategy_status_and_reason_through() -> None:


def test_schema_version_is_preserved_on_the_wire() -> None:
result: typing.Final = to_run_result(NoTags(commit=_COMMIT), strategy=_STRATEGY)
result: typing.Final = to_run_result(NoTags(commit=_COMMIT, skipped_tag_count=0), strategy=_STRATEGY)
assert result.schema_version == "1.0"


@pytest.mark.parametrize(
("skipped_tag_count", "expected_reason"),
[
(
1,
(
"None of the repo's 1 tag is SemVer-form (1.2.0 or v1.2.0); create an initial tag such as 0.1.0 "
"(or v0.1.0 for v-prefixed tags) on a default-branch commit."
),
),
(
3,
(
"None of the repo's 3 tags is SemVer-form (1.2.0 or v1.2.0); create an initial tag such as 0.1.0 "
"(or v0.1.0 for v-prefixed tags) on a default-branch commit."
),
),
],
)
def test_no_tags_reason_counts_tags_that_are_not_semver_form(skipped_tag_count: int, expected_reason: str) -> None:
result: typing.Final = to_run_result(
NoTags(commit=_COMMIT, skipped_tag_count=skipped_tag_count), strategy=_STRATEGY
)
assert result.status == "no_tags"
assert result.reason == expected_reason
17 changes: 14 additions & 3 deletions tests/unit/test_output_rich.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,20 @@ def test_matrix_keeps_stderr_for_errors(quiet: bool) -> None:
@pytest.mark.parametrize(
("outcome", "expected"),
[
(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"),
(NoTags(commit="abc1234def", skipped_tag_count=0), "no prior semver-conforming tag"),
(NoTags(commit="abc1234def", skipped_tag_count=0), "create an initial tag such as 0.1.0"),
(NoTags(commit="abc1234def", skipped_tag_count=0), "or v0.1.0 for v-prefixed tags"),
(
NoTags(commit="abc1234def", skipped_tag_count=3),
"none of the repo's 3 tags is SemVer-form (1.2.0 or v1.2.0); create an initial tag such as 0.1.0",
),
(
NoTags(commit="abc1234def", skipped_tag_count=1),
(
"none of the repo's 1 tag is SemVer-form (1.2.0 or v1.2.0); create an initial tag such as 0.1.0 "
"(or v0.1.0 for v-prefixed tags) on a default-branch commit."
),
),
(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
18 changes: 18 additions & 0 deletions tests/unit/test_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,21 @@ def test_skips_with_already_tagged_when_head_carries_the_v_prefixed_latest_tag()
assert isinstance(result, AlreadyTagged)
assert result.tag == "v1.4.2"
assert provider.create_tag_calls == []


@pytest.mark.parametrize(
("tag_names", "expected_count"),
[
([], 0),
(["V1.2.0", "release-1.2.0", "v1"], 3),
],
)
def test_no_tags_counts_the_tags_that_are_not_semver_form(tag_names: list[str], expected_count: int) -> 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, NoTags)
assert result.skipped_tag_count == expected_count
Loading