diff --git a/README.md b/README.md index e6d3476..f035984 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ Auto-tag your GitLab or GitHub repository with semantic version tags from CI — 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). + ## Use it in GitLab CI Paste this job into your `.gitlab-ci.yml`: @@ -83,8 +87,8 @@ GitHub Enterprise setup, outputs, and troubleshooting. ## Strategies - **branch-prefix** (default): the head commit on the default branch - must be a merge commit whose source branch starts with `feature/` - (minor), `bugfix/`, or `hotfix/` (patch). + must be a merge commit whose subject names a `feature/` (minor), + `bugfix/`, or `hotfix/` (patch) branch. - **conventional-commits**: parses the head commit's [Conventional Commits](https://www.conventionalcommits.org/) header (`feat:` minor, `fix:`/`perf:` patch, `!` or `BREAKING diff --git a/action.yml b/action.yml index 95096d0..9e2e1ab 100644 --- a/action.yml +++ b/action.yml @@ -22,7 +22,7 @@ inputs: outputs: tag: - description: 'The created tag (e.g. v1.2.3), or empty string if no bump was warranted.' + description: 'The created tag (e.g. 1.2.3), 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/index.md b/docs/index.md index 6599a62..c22cfd9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,10 @@ semvertag: - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' ``` +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). + For local testing or one-off invocations: ```sh diff --git a/docs/providers/github.md b/docs/providers/github.md index 2e99f03..017a6d9 100644 --- a/docs/providers/github.md +++ b/docs/providers/github.md @@ -40,6 +40,12 @@ a bump is warranted by the configured strategy, creates a new tag ref via the GitHub API. If no bump is warranted, the job exits 0 without pushing. +> **First tag.** 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`. + > **Auto-detection.** semvertag detects GitHub Actions from the > `GITHUB_ACTIONS=true` env var that GHA sets automatically. The > `--provider` flag is therefore optional inside GHA — explicit @@ -92,7 +98,7 @@ When you give the step an `id:`, downstream steps can read three outputs: | Output | Value | |---|---| -| `tag` | The created tag (e.g. `v1.2.3`), or empty string when `status` is `no-bump`. | +| `tag` | The created tag (e.g. `1.2.3`), 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. | @@ -189,12 +195,12 @@ is the entire setup. ## Branch-prefix vs conventional-commits Pick `branch-prefix` if your team merges PRs with branch names that -follow a `fix/...`, `feat/...`, `chore/...` convention and lands them -as merge commits. semvertag reads the head commit's source-branch -prefix and bumps accordingly — `fix/` bumps patch, `feat/` bumps -minor, `chore/` bumps nothing. With squash merges the head is not a -merge commit and the run reports `no_merge_commit`. This is the -default. See +follow a `feature/...`, `bugfix/...`, `hotfix/...` convention and lands +them as merge commits. semvertag reads the head commit's source-branch +prefix and bumps accordingly: `feature/` bumps minor, `bugfix/` and +`hotfix/` bump patch, and any other prefix bumps nothing. With squash +merges the head is not a merge commit and the run reports +`no_merge_commit`. This is the default. See [Branch-prefix strategy](../strategies/branch-prefix.md) for the full prefix-to-bump table and edge-case behavior. diff --git a/docs/providers/gitlab.md b/docs/providers/gitlab.md index 446d9c7..4d80041 100644 --- a/docs/providers/gitlab.md +++ b/docs/providers/gitlab.md @@ -44,6 +44,12 @@ bump is warranted by the configured strategy, pushes a new tag to the project's `origin`. If no bump is warranted, the job exits 0 without pushing. +> **First tag.** 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`. + > **Concurrency default.** `resource_group: semvertag` makes GitLab > serialize concurrent `semvertag` jobs across pipelines on the same > project — back-to-back pushes will queue rather than race the @@ -117,10 +123,11 @@ write scope, the minimal job snippet above is the entire setup. ## Branch-prefix vs conventional-commits Pick `branch-prefix` if your team merges merge requests with branch -names that follow a `fix/...`, `feat/...`, `chore/...` convention -and lands them as merge commits. semvertag reads the head commit's -source-branch prefix and bumps accordingly — `fix/` bumps patch, -`feat/` bumps minor, `chore/` bumps nothing. With squash merges the +names that follow a `feature/...`, `bugfix/...`, `hotfix/...` +convention and lands them as merge commits. semvertag reads the head +commit's source-branch prefix and bumps accordingly: `feature/` bumps +minor, `bugfix/` and `hotfix/` bump patch, and any other prefix bumps +nothing. With squash merges the head is not a merge commit and the run reports `no_merge_commit`. This is the default. See [Branch-prefix strategy](../strategies/branch-prefix.md) for the full diff --git a/semvertag/_outcome.py b/semvertag/_outcome.py index f0ae988..8d9a679 100644 --- a/semvertag/_outcome.py +++ b/semvertag/_outcome.py @@ -7,7 +7,9 @@ # 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; not seeding an initial tag in v1.0." +_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." +) _ALREADY_TAGGED_REASON: typing.Final = "Latest commit already tagged." @@ -31,7 +33,7 @@ class DryRun: @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class NoTags: - """No prior semver tag to bump from; v1.0 does not seed one.""" + """No prior semver tag to bump from; semvertag does not create the first one.""" commit: str diff --git a/semvertag/_output.py b/semvertag/_output.py index 3686fa0..b848953 100644 --- a/semvertag/_output.py +++ b/semvertag/_output.py @@ -63,7 +63,10 @@ def _format_outcome(outcome: Outcome, *, strategy: str) -> str: short = commit[:_COMMIT_SHORT_LEN] return f"Dry run: would create tag {tag} on commit {short} (strategy: {strategy}, bump: {bump.value})" case NoTags(): - return "No tag created — no prior semver-conforming tag to bump from." + 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." + ) case AlreadyTagged(tag=tag): return f"No tag created — latest commit is already tagged {tag}." case NoBump(reason=reason): diff --git a/tests/unit/test_outcome.py b/tests/unit/test_outcome.py index b41a77d..43d20ce 100644 --- a/tests/unit/test_outcome.py +++ b/tests/unit/test_outcome.py @@ -38,6 +38,13 @@ def test_no_tags_maps_with_none_bump_and_fixed_reason() -> None: ) +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 "v1.0" not in reason + + def test_already_tagged_maps_with_tag_and_fixed_reason() -> None: result: typing.Final = to_run_result(AlreadyTagged(tag="0.3.1", commit=_COMMIT), strategy=_STRATEGY) assert result == RunResult( diff --git a/tests/unit/test_output_rich.py b/tests/unit/test_output_rich.py index 6ecc51a..ac5f780 100644 --- a/tests/unit/test_output_rich.py +++ b/tests/unit/test_output_rich.py @@ -95,6 +95,7 @@ def test_matrix_keeps_stderr_for_errors(quiet: bool) -> None: ("outcome", "expected"), [ (NoTags(commit="abc1234def"), "no prior semver-conforming tag"), + (NoTags(commit="abc1234def"), "create an initial tag such as 0.1.0"), (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"), @@ -105,7 +106,7 @@ def test_matrix_keeps_stderr_for_errors(quiet: bool) -> None: def test_emit_renders_no_bump_outcomes_as_human_sentences(outcome: Outcome, expected: str) -> None: output, stdout_buf, _stderr = _make_pair() output.emit(outcome, strategy=_STRATEGY) - stdout_text: typing.Final = stdout_buf.getvalue() + stdout_text: typing.Final = " ".join(stdout_buf.getvalue().split()) assert "No tag created" in stdout_text assert expected in stdout_text