From a92c3de906779b7bf4f272dc5eb1013dcb95cb94 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 22:48:43 +0300 Subject: [PATCH 1/4] docs(planning): semver-tag-selection bundle + semver-form-tags-only decision (D) --- .../design.md | 135 ++++++++++ .../plan.md | 236 ++++++++++++++++++ .../2026-06-26-semver-form-tags-only.md | 65 +++++ 3 files changed, 436 insertions(+) create mode 100644 planning/changes/2026-06-26.03-semver-tag-selection/design.md create mode 100644 planning/changes/2026-06-26.03-semver-tag-selection/plan.md create mode 100644 planning/decisions/2026-06-26-semver-form-tags-only.md diff --git a/planning/changes/2026-06-26.03-semver-tag-selection/design.md b/planning/changes/2026-06-26.03-semver-tag-selection/design.md new file mode 100644 index 0000000..689f0f1 --- /dev/null +++ b/planning/changes/2026-06-26.03-semver-tag-selection/design.md @@ -0,0 +1,135 @@ +--- +summary: Fold the four-helper semver-tag-selection chain into one selector that carries the parsed Version (killing the double-parse), and finalize prerelease baselines via next_version. +--- + +# Design: Deepen the semver-tag selection chain + +## Summary + +The "pick the bump baseline and bump it" step in `semvertag/_use_case.py` is four +shallow pure helpers: `_try_parse_semver` → `_parse_semver_tags` → +`_pick_latest_semver_tag` → `_compute_new_version`. Each is trivially correct in +isolation, but the *composed* behavior — skip-unparseable, sort by precedence, +pick-max, then bump — has emergent semantics that no helper test captures, and the +winning tag is **parsed twice** (the selector discards the parsed `Version`, then +`_compute_new_version` re-parses it). This change folds the three parse-helpers +into one `_select_latest_semver_tag(tags) -> tuple[Tag, semver.Version] | None` +that carries the parsed `Version` through, and switches the bump arithmetic from +`bump_*` to `Version.next_version` so a SemVer-form prerelease baseline finalizes +(`1.0.0-rc.1` + patch → `1.0.0`, not `1.0.1`). One interface becomes the real test +surface; the emergent edges get explicit tests. See +[`decisions/2026-06-26-semver-form-tags-only.md`](../../decisions/2026-06-26-semver-form-tags-only.md). + +## Motivation + +`semvertag/_use_case.py:54-85`: + +- **Parse-twice.** `_pick_latest_semver_tag` parses every tag into a `Version`, + sorts, returns only the `Tag` — discarding the `Version`. `_compute_new_version` + then re-parses `last_tag.name`. +- **Untested emergent surface.** PEP 440 prereleases (`0.9.0rc1`) and `v`-prefixed + tags (`v0` — a real tag in this repo) are silently skipped by strict + `Version.parse`; build-metadata ties are input-order-dependent. None of this is + tested at the seam where it lives — the only non-semver test uses + `release-2024-Q1`/`latest`. +- **Latent prerelease bug.** `bump_patch` on a SemVer-form prerelease baseline + (`1.0.0-rc.1`) jumps to `1.0.1` instead of finalizing to `1.0.0`. + +The four helpers are the textbook "extracted for testability, but the real bug is +in how they're composed" shape — low locality. Deletion test: folding them +concentrates the selection logic in one interface rather than scattering a +four-hop chain. + +## Non-goals + +- **No PEP 440 recognition** and **no `v`-prefix recognition** — both decided in + `decisions/2026-06-26-semver-form-tags-only.md` (rejected / deferred). Selection + stays SemVer-form only. +- No change to the `Outcome` variants, the providers, strategies, output, or DI. +- No change for *stable* baselines: `next_version(part)` equals `bump_*` there, so + every existing bare-semver tag behaves identically. + +## Design + +### 1. One selector carrying the parsed `Version` + +Replace `_try_parse_semver` / `_parse_semver_tags` / `_pick_latest_semver_tag` +with: + +```python +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) + except ValueError: + continue + parsed.append((version, tag)) + if not parsed: + return None + parsed.sort(key=lambda item: item[0]) + version, tag = parsed[-1] + return tag, version +``` + +`sorted(...)[-1]` (not `max`) preserves the current **last-equal-wins** tie order +for versions that compare equal (build metadata is ignored in precedence). + +### 2. Bump via `next_version`, on the carried `Version` + +```python +_BUMP_PARTS: typing.Final[dict[Bump, str]] = {Bump.MAJOR: "major", Bump.MINOR: "minor", Bump.PATCH: "patch"} + +def _compute_new_version(version: semver.Version, bump: Bump) -> str: + return str(version.next_version(_BUMP_PARTS[bump])) +``` + +It takes the `Version` from the selector tuple — the winning tag is never parsed +twice. `next_version` is behavior-preserving on stable baselines and finalizes +SemVer-form prerelease baselines. + +### 3. Use-case wiring + +```python +tags = self.provider.list_tags() +selected = _select_latest_semver_tag(tags) +if selected is None: + return self._emit(output, NoTags(commit=commit.sha)) +latest_tag, latest_version = selected +if latest_tag.commit_sha == commit.sha: + return self._emit(output, AlreadyTagged(tag=latest_tag.name, commit=commit.sha)) +... +new_version = _compute_new_version(latest_version, bump) +``` + +Same `NoTags` / `AlreadyTagged` / no-bump logic; only the carried `Version` is new. + +## Testing + +TDD. Direct helper tests in `tests/unit/test_use_case.py` (helpers stay in +`_use_case.py`): + +- `_select_latest_semver_tag`: empty → `None`; all-unparseable + (`release-2024-Q1`, `latest`, `v0`) → `None`; PEP 440 skip (`[0.8.1, 0.9.0rc1]` + → `0.8.1`); SemVer-form prerelease participates/orders (`[1.0.0-rc.1, 0.9.0]` → + `1.0.0-rc.1`); tie last-wins (`[1.0.0+a (x), 1.0.0+b (y)]` → `1.0.0+b`); returns + the parsed `Version` alongside the `Tag`. +- `_compute_new_version`: finalize (`Version.parse("1.0.0-rc.1")`, `Bump.PATCH`) → + `"1.0.0"`; stable cases unchanged. + +Keep all existing use-case integration tests (behavior-preservation proof; they +stay green). Gates: `just test` (100% branch), `just lint-ci`, `just docs-build`. + +## Risk + +- **`next_version` behavior change (medium × low).** Mitigated: it equals `bump_*` + on every stable baseline (so all existing tests pass unchanged), and differs only + by finalizing prerelease baselines, which is the intended fix. The existing + parametrized bump test (`1.4.2` → major/minor/patch) is the guardrail. +- **Tie-order regression (low × low).** `sorted(...)[-1]` preserves last-equal-wins; + a new test pins it. +- **Coverage (low × low).** The folded selector's branches (parse ok/skip, empty, + sort) and the `_BUMP_PARTS` lookups are covered by the direct tests plus the + existing suite. +- **`architecture/cli.md` drift (low × low).** The Use-case section names + `_pick_latest_semver_tag` and `bump_*`; promote it in the same PR. diff --git a/planning/changes/2026-06-26.03-semver-tag-selection/plan.md b/planning/changes/2026-06-26.03-semver-tag-selection/plan.md new file mode 100644 index 0000000..c01f174 --- /dev/null +++ b/planning/changes/2026-06-26.03-semver-tag-selection/plan.md @@ -0,0 +1,236 @@ +# semver-tag-selection — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fold the four-helper semver-tag-selection chain in `_use_case.py` into +one selector that carries the parsed `Version` (no double-parse), and finalize +prerelease baselines via `next_version`. + +**Spec:** [`design.md`](./design.md) · **Decision:** +[`../../decisions/2026-06-26-semver-form-tags-only.md`](../../decisions/2026-06-26-semver-form-tags-only.md) + +**Branch:** `refactor/semver-tag-selection` + +**Commit strategy:** Per-task commits. + +## Global Constraints + +- Python imports at MODULE LEVEL only; every test function argument annotated. +- `just test` enforces `fail_under = 100` branch coverage. +- BEHAVIOR-PRESERVING **except** the one deliberate change: `next_version` instead + of `bump_*`, which equals `bump_*` on every stable baseline and differs only by + finalizing SemVer-form prerelease baselines. No change to `Outcome`, providers, + strategies, output, or DI. +- Selection stays **SemVer-form only** — no PEP 440 / `v`-prefix recognition (per + the decision record). +- Tie order: `sorted(...)[-1]` (last-equal-wins) — do NOT use `max()`. + +--- + +### Task 1: Fold the selection chain; bump via `next_version` + +**Files:** +- Modify: `semvertag/_use_case.py` +- Test: `tests/unit/test_use_case.py` + +**Interfaces:** +- Removed: `_try_parse_semver`, `_parse_semver_tags`, `_pick_latest_semver_tag`, + `_BUMP_FUNCTIONS`. +- Produced: `_select_latest_semver_tag(tags: list[Tag]) -> tuple[Tag, semver.Version] | None`, + `_BUMP_PARTS: dict[Bump, str]`, and `_compute_new_version(version: semver.Version, bump: Bump) -> str`. + +- [ ] **Step 1: Write the failing direct helper tests** + + In `tests/unit/test_use_case.py`, add `_select_latest_semver_tag` and + `_compute_new_version` to the existing `from semvertag._use_case import ...` + line, and add `import semver` to the module imports. Append: + + ```python + def test_select_latest_returns_none_for_empty() -> None: + assert _select_latest_semver_tag([]) is None + + + def test_select_latest_returns_none_when_all_unparseable() -> None: + tags = [ + Tag(name="release-2024-Q1", commit_sha="a"), + Tag(name="latest", commit_sha="b"), + Tag(name="v0", commit_sha="c"), + ] + assert _select_latest_semver_tag(tags) is None + + + def test_select_latest_skips_pep440_prerelease() -> None: + tags = [Tag(name="0.8.1", commit_sha="a"), Tag(name="0.9.0rc1", commit_sha="b")] + selected = _select_latest_semver_tag(tags) + assert selected is not None + tag, version = selected + assert tag.name == "0.8.1" + assert version == semver.Version.parse("0.8.1") + + + def test_select_latest_includes_semver_form_prerelease_in_ordering() -> None: + tags = [Tag(name="1.0.0-rc.1", commit_sha="a"), Tag(name="0.9.0", commit_sha="b")] + selected = _select_latest_semver_tag(tags) + assert selected is not None + tag, _version = selected + assert tag.name == "1.0.0-rc.1" + + + def test_select_latest_tie_keeps_last_in_input() -> None: + tags = [Tag(name="1.0.0+a", commit_sha="x"), Tag(name="1.0.0+b", commit_sha="y")] + selected = _select_latest_semver_tag(tags) + assert selected is not None + tag, _version = selected + assert tag.commit_sha == "y" + + + def test_compute_new_version_finalizes_semver_prerelease() -> None: + assert _compute_new_version(semver.Version.parse("1.0.0-rc.1"), Bump.PATCH) == "1.0.0" + ``` + +- [ ] **Step 2: Run the tests to verify they fail** + + Run: `just test tests/unit/test_use_case.py -q` + Expected: FAIL — `ImportError: cannot import name '_select_latest_semver_tag'`. + +- [ ] **Step 3: Rewrite the helpers in `_use_case.py`** + + Remove `_try_parse_semver`, `_parse_semver_tags`, `_pick_latest_semver_tag`, and + the `_BUMP_FUNCTIONS` dict. Add: + + ```python + 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) + except ValueError: + continue + parsed.append((version, tag)) + if not parsed: + return None + parsed.sort(key=lambda item: item[0]) + version, tag = parsed[-1] + return tag, version + + + _BUMP_PARTS: typing.Final[dict[Bump, str]] = { + Bump.MAJOR: "major", + Bump.MINOR: "minor", + Bump.PATCH: "patch", + } + + + def _compute_new_version(version: semver.Version, bump: Bump) -> str: + return str(version.next_version(_BUMP_PARTS[bump])) + ``` + +- [ ] **Step 4: Rewire `__call__`** + + Replace the selection + already-tagged + compute lines so they consume the + tuple and pass the carried `Version`: + + ```python + output.progress("Fetching tag history...") + tags: typing.Final = self.provider.list_tags() + selected: typing.Final = _select_latest_semver_tag(tags) + + if selected is None: + return self._emit(output, NoTags(commit=commit.sha)) + + latest_tag, latest_version = selected + if latest_tag.commit_sha == commit.sha: + return self._emit(output, AlreadyTagged(tag=latest_tag.name, commit=commit.sha)) + + output.progress("Computing bump...") + bump: typing.Final = self.strategy.decide(commit) + if bump is Bump.NONE: + return self._emit( + output, + 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) + ``` + + Leave the `dry_run` / `create_tag` / `Created` lines below unchanged. + +- [ ] **Step 5: Run the tests to verify they pass** + + Run: `just test tests/unit/test_use_case.py -q` + Expected: PASS — new direct tests green, AND every existing use-case test green + (behavior preserved; `next_version` equals `bump_*` on the `1.4.2`/`2.0.0` + stable baselines those tests use). + +- [ ] **Step 6: Full suite + lint gate** + + Run: `just test` then `just lint-ci` + Expected: full suite at 100% branch coverage; ruff/ty/planning clean. + +- [ ] **Step 7: Commit** + + ```bash + git add semvertag/_use_case.py tests/unit/test_use_case.py + git commit -m "use-case: fold semver-tag selection into one selector; bump via next_version" + ``` + +--- + +### Task 2: Promote architecture; finalize bundle + +**Files:** +- Modify: `architecture/cli.md` (the "Use-case" section) +- Modify: `planning/changes/2026-06-26.03-semver-tag-selection/design.md` (finalize `summary`) + +- [ ] **Step 1: Update `architecture/cli.md`** + + In the "Use-case" section, find step 2 (currently: "list tags and pick the + highest semver-parseable one (`_pick_latest_semver_tag` sorts by + `semver.Version`; unparseable names are skipped)") and step 5 (currently: + "compute the new version (`_compute_new_version` via `semver`'s + `bump_major/minor/patch`)"). Rewrite them to the new reality, grounding every + claim against `semvertag/_use_case.py`: + - Step 2: `_select_latest_semver_tag` parses each tag, skips non-SemVer names + (PEP 440 prereleases and `v`-prefixed tags included), sorts by `semver.Version` + precedence, and returns the winning `Tag` **with its parsed `Version`** + (last-equal-wins on ties). + - Step 5: the new version is computed by `_compute_new_version` from the carried + `Version` via `Version.next_version` (which finalizes a SemVer-form prerelease + baseline), so the winning tag is not parsed twice. + Match the file's prose style. Add a brief pointer to + `decisions/2026-06-26-semver-form-tags-only.md` if the section already + cross-references decisions; otherwise keep it inline. + +- [ ] **Step 2: Finalize the bundle summary** + + Edit the `summary:` frontmatter in this bundle's `design.md` to the realized + result (past tense, one line). + +- [ ] **Step 3: All gates** + + Run: `just lint-ci && just test && just docs-build` + Expected: lint/ty/planning clean, 100% branch coverage, strict mkdocs build + succeeds. + +- [ ] **Step 4: Commit** + + ```bash + git add architecture/cli.md planning/changes/2026-06-26.03-semver-tag-selection/design.md + git commit -m "docs: promote semver-tag selection + next_version to architecture" + ``` + +--- + +## Self-review notes + +- **Spec coverage:** selector fold + carried `Version` (Task 1), `next_version` + bump (Task 1), direct edge tests (Task 1), architecture promotion + summary + (Task 2). The decision record ships with the bundle's planning commit. +- **Type consistency:** `_select_latest_semver_tag(...) -> tuple[Tag, + semver.Version] | None` and `_compute_new_version(version: semver.Version, bump: + Bump) -> str` used identically across tasks. +- **Behavior preservation:** existing use-case tests are the green-bar proof; + `next_version` is the only deliberate behavior change (prerelease finalize). diff --git a/planning/decisions/2026-06-26-semver-form-tags-only.md b/planning/decisions/2026-06-26-semver-form-tags-only.md new file mode 100644 index 0000000..72a2703 --- /dev/null +++ b/planning/decisions/2026-06-26-semver-form-tags-only.md @@ -0,0 +1,65 @@ +--- +status: accepted +summary: The bump baseline is selected from SemVer-form tags only; PEP 440 prereleases and v-prefixed tags are skipped, and SemVer-form prereleases finalize via next_version. +--- + +# Bump baseline is SemVer-form only; prereleases finalize via `next_version` + +**Decision:** `_select_latest_semver_tag` (in `semvertag/_use_case.py`) picks the +bump baseline from tags parseable by `semver.Version.parse` — i.e. **SemVer-form** +(`MAJOR.MINOR.PATCH`, optionally `-prerelease`/`+build`). Tags that are not valid +SemVer are skipped: **PEP 440 prereleases** (`0.9.0rc1`, `0.8.1a1`) and +**`v`-prefixed** tags (`v1.2.3`). When a SemVer-form *prerelease* (`1.0.0-rc.1`) +is the selected baseline, the new version is computed with `Version.next_version` +(which **finalizes** the prerelease — `1.0.0-rc.1` + patch → `1.0.0`), **not** +`bump_*` (which would jump to `1.0.1`). + +## Context + +A research-grade review of the tag-selection chain found that the chain's +*composed* behavior had untested, emergent semantics. `semver.Version.parse` is +strict: it rejects `v1.2.3` and PEP 440 prereleases, so both are silently skipped +from selection. Separately, the old `bump_*` arithmetic on a (SemVer-form) +prerelease baseline jumps past the finalization (`1.0.0-rc.1` patch → `1.0.1`) +instead of finalizing to `1.0.0`. + +Options considered for prerelease/format recognition: + +- **(a)** SemVer-form only + `next_version` to finalize. *(chosen)* +- **(b)** Recognize PEP 440 prereleases too (via the `packaging` library or a + hand-rolled normalizer). *(rejected)* +- **(c)** Recognize `v`-prefixed tags (strip a leading `v`/`V` before parse). + *(deferred)* + +## Decision & rationale + +semvertag is a **SemVer** tagger: it emits bare `X.Y.Z`, sorts by SemVer +precedence, and the format it should expect in a managed repo is SemVer-form. The +discriminating "shared by standard, not by coincidence" lens applies: + +- **`next_version` (chosen) is feasible, dependency-free, and behavior-preserving + for every current tag.** On a stable baseline `next_version(part)` equals + `bump_*` exactly; it differs only by finalizing a SemVer-form prerelease + baseline — which is the correct release-ramp semantics and the bug the review + found. No new dependency, no new version model. +- **(b) PEP 440 recognition is rejected** because `python-semver` has no PEP 440 + parser (the `coerce` recipe extracts only `major.minor.patch` and *discards* the + `rc1`, making a prerelease masquerade as final — unusable). Real support needs + the `packaging` library running *alongside* `semver` — two version models in one + selection path — to recognize a form that a SemVer tool should not need to + consume. PEP 440 is semvertag's own PyPI-publishing quirk (on its dry-run + dogfood repo), not the form user repos managed by semvertag carry. +- **(c) `v`-prefix recognition is deferred,** not rejected: it is cheap + (strip a leading `v`/`V`) but a distinct *policy* change — semvertag would then + *consume* `v`-prefixed tags while still *emitting* bare semver, a mixed + convention worth deciding deliberately. It is a real adoption footgun (a repo + with `v`-prefixed history sees `NoTags`), tracked for its own change. + +## Revisit trigger + +- **(b)** Reopen if users need PEP 440 prerelease tags recognized as bump + baselines in managed repos — at which point adding `packaging` (PEP 440-native + ordering) for selection, kept separate from the `semver` bump, is worth pricing. +- **(c)** Reopen `v`-prefix recognition when adoption against `v`-prefixed repos + is a goal; the fix is a leading-`v` strip before parse, plus a decision on + whether semvertag should then also emit `v`-prefixed tags. From 31875fe2dbd49d36a3de068e82bda2709ed999bf Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 22:52:11 +0300 Subject: [PATCH 2/4] use-case: fold semver-tag selection into one selector; bump via next_version --- semvertag/_use_case.py | 51 ++++++++++++++++--------------------- tests/unit/test_use_case.py | 45 +++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/semvertag/_use_case.py b/semvertag/_use_case.py index 0c5b75c..b022438 100644 --- a/semvertag/_use_case.py +++ b/semvertag/_use_case.py @@ -22,13 +22,14 @@ def __call__(self, *, output: Output, dry_run: bool = False) -> Outcome: output.progress("Fetching tag history...") tags: typing.Final = self.provider.list_tags() - latest_semver_tag: typing.Final = _pick_latest_semver_tag(tags) + selected: typing.Final = _select_latest_semver_tag(tags) - if latest_semver_tag is None: + if selected is None: return self._emit(output, NoTags(commit=commit.sha)) - if latest_semver_tag.commit_sha == commit.sha: - return self._emit(output, AlreadyTagged(tag=latest_semver_tag.name, commit=commit.sha)) + latest_tag, latest_version = selected + if latest_tag.commit_sha == commit.sha: + return self._emit(output, AlreadyTagged(tag=latest_tag.name, commit=commit.sha)) output.progress("Computing bump...") bump: typing.Final = self.strategy.decide(commit) @@ -38,7 +39,7 @@ 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_semver_tag, bump) + new_version: typing.Final = _compute_new_version(latest_version, bump) if dry_run: return self._emit(output, DryRun(tag=new_version, bump=bump, commit=commit.sha)) @@ -51,35 +52,27 @@ def _emit(self, output: Output, outcome: Outcome) -> Outcome: return outcome -def _pick_latest_semver_tag(tags: list[Tag]) -> Tag | None: - parsed: typing.Final = [(version, tag) for tag, version in _parse_semver_tags(tags)] +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) + except ValueError: + continue + parsed.append((version, tag)) if not parsed: return None parsed.sort(key=lambda item: item[0]) - return parsed[-1][1] - - -def _parse_semver_tags(tags: list[Tag]) -> typing.Iterator[tuple[Tag, semver.Version]]: - for tag in tags: - version = _try_parse_semver(tag.name) - if version is not None: - yield tag, version - - -def _try_parse_semver(name: str) -> semver.Version | None: - try: - return semver.Version.parse(name) - except ValueError: - return None + version, tag = parsed[-1] + return tag, version -_BUMP_FUNCTIONS: typing.Final[dict[Bump, typing.Callable[[semver.Version], semver.Version]]] = { - Bump.MAJOR: semver.Version.bump_major, - Bump.MINOR: semver.Version.bump_minor, - Bump.PATCH: semver.Version.bump_patch, +_BUMP_PARTS: typing.Final[dict[Bump, str]] = { + Bump.MAJOR: "major", + Bump.MINOR: "minor", + Bump.PATCH: "patch", } -def _compute_new_version(last_tag: Tag, bump: Bump) -> str: - version: typing.Final = semver.Version.parse(last_tag.name) - return str(_BUMP_FUNCTIONS[bump](version)) +def _compute_new_version(version: semver.Version, bump: Bump) -> str: + return str(version.next_version(_BUMP_PARTS[bump])) diff --git a/tests/unit/test_use_case.py b/tests/unit/test_use_case.py index d8b6f91..b62557b 100644 --- a/tests/unit/test_use_case.py +++ b/tests/unit/test_use_case.py @@ -2,10 +2,11 @@ import typing import pytest +import semver from semvertag._outcome import AlreadyTagged, Created, DryRun, NoBump, NoTags, Outcome from semvertag._types import Bump, CheckResult, Commit, Tag -from semvertag._use_case import SemvertagUseCase +from semvertag._use_case import SemvertagUseCase, _compute_new_version, _select_latest_semver_tag _MERGE_MESSAGE: typing.Final = "Merge branch 'feature/foo' into main" @@ -291,3 +292,45 @@ def test_dry_run_false_default_creates_tag() -> None: assert isinstance(result, Created) assert provider.create_tag_calls == [(_EXPECTED_NEW_TAG, _LATEST_SHA)] + + +def test_select_latest_returns_none_for_empty() -> None: + assert _select_latest_semver_tag([]) is None + + +def test_select_latest_returns_none_when_all_unparseable() -> None: + tags = [ + Tag(name="release-2024-Q1", commit_sha="a"), + Tag(name="latest", commit_sha="b"), + Tag(name="v0", commit_sha="c"), + ] + assert _select_latest_semver_tag(tags) is None + + +def test_select_latest_skips_pep440_prerelease() -> None: + tags = [Tag(name="0.8.1", commit_sha="a"), Tag(name="0.9.0rc1", commit_sha="b")] + selected = _select_latest_semver_tag(tags) + assert selected is not None + tag, version = selected + assert tag.name == "0.8.1" + assert version == semver.Version.parse("0.8.1") + + +def test_select_latest_includes_semver_form_prerelease_in_ordering() -> None: + tags = [Tag(name="1.0.0-rc.1", commit_sha="a"), Tag(name="0.9.0", commit_sha="b")] + selected = _select_latest_semver_tag(tags) + assert selected is not None + tag, _version = selected + assert tag.name == "1.0.0-rc.1" + + +def test_select_latest_tie_keeps_last_in_input() -> None: + tags = [Tag(name="1.0.0+a", commit_sha="x"), Tag(name="1.0.0+b", commit_sha="y")] + selected = _select_latest_semver_tag(tags) + assert selected is not None + tag, _version = selected + assert tag.commit_sha == "y" + + +def test_compute_new_version_finalizes_semver_prerelease() -> None: + assert _compute_new_version(semver.Version.parse("1.0.0-rc.1"), Bump.PATCH) == "1.0.0" From aad0eed181caea1813be8dcec8434cc6080313a3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 22:57:17 +0300 Subject: [PATCH 3/4] docs: promote semver-tag selection + next_version to architecture --- architecture/cli.md | 13 +++++++++---- .../2026-06-26.03-semver-tag-selection/design.md | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/architecture/cli.md b/architecture/cli.md index 2ae6ee9..333831f 100644 --- a/architecture/cli.md +++ b/architecture/cli.md @@ -90,15 +90,20 @@ a `provider` and a `strategy`; calling it (`__call__(*, output, dry_run=False) -> Outcome`) is the whole orchestration: 1. fetch the latest commit on the default branch; -2. list tags and pick the highest semver-parseable one (`_pick_latest_semver_tag` - sorts by `semver.Version`; unparseable names are skipped); +2. list tags and select the highest semver-parseable one — `_select_latest_semver_tag` + parses each tag via `semver.Version.parse`, skipping non-SemVer names (PEP 440 + prereleases such as `0.9.0rc1` and `v`-prefixed tags such as `v0`), sorts by + `semver.Version` precedence (last-equal-wins on build-metadata ties), and returns + the winning `Tag` together with its parsed `Version`; 3. early no-bump exits — `NoTags` when there is no prior semver tag (it does **not** seed an initial tag in v1.0), `AlreadyTagged` when the head commit already carries the latest tag; 4. ask the strategy for a `Bump`; `Bump.NONE` exits with `NoBump`, carrying the strategy's own status/reason; -5. compute the new version (`_compute_new_version` via `semver`'s - `bump_major/minor/patch`); +5. compute the new version — `_compute_new_version` applies `Version.next_version` + to the `Version` carried from step 2 (finalizing a SemVer-form prerelease + baseline such as `1.0.0-rc.1` to `1.0.0`; identical to `bump_*` on stable + baselines), so the winning tag is never parsed twice; 6. if `dry_run`, return `DryRun`; else `provider.create_tag` and return `Created`. diff --git a/planning/changes/2026-06-26.03-semver-tag-selection/design.md b/planning/changes/2026-06-26.03-semver-tag-selection/design.md index 689f0f1..02ec2c9 100644 --- a/planning/changes/2026-06-26.03-semver-tag-selection/design.md +++ b/planning/changes/2026-06-26.03-semver-tag-selection/design.md @@ -1,5 +1,5 @@ --- -summary: Fold the four-helper semver-tag-selection chain into one selector that carries the parsed Version (killing the double-parse), and finalize prerelease baselines via next_version. +summary: Folded the tag-selection chain into _select_latest_semver_tag, which carries the parsed Version to _compute_new_version; next_version finalizes SemVer-form prerelease baselines. --- # Design: Deepen the semver-tag selection chain From 60d93401fd92e1a6fd73bbd33a5a899da8ad4e6a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 23:12:01 +0300 Subject: [PATCH 4/4] use-case: strip build metadata so next_version matches bump_* on stable baselines --- architecture/cli.md | 4 +++- .../2026-06-26.03-semver-tag-selection/design.md | 15 +++++++++++---- .../2026-06-26.03-semver-tag-selection/plan.md | 8 +++++--- .../2026-06-26-semver-form-tags-only.md | 16 ++++++++++++---- semvertag/_use_case.py | 2 +- tests/unit/test_use_case.py | 15 +++++++++++++++ 6 files changed, 47 insertions(+), 13 deletions(-) diff --git a/architecture/cli.md b/architecture/cli.md index 333831f..0d6042b 100644 --- a/architecture/cli.md +++ b/architecture/cli.md @@ -103,7 +103,9 @@ a `provider` and a `strategy`; calling it (`__call__(*, output, dry_run=False) 5. compute the new version — `_compute_new_version` applies `Version.next_version` to the `Version` carried from step 2 (finalizing a SemVer-form prerelease baseline such as `1.0.0-rc.1` to `1.0.0`; identical to `bump_*` on stable - baselines), so the winning tag is never parsed twice; + baselines without build metadata — the selector strips build metadata via + `.replace(build=None)`, so the carried `Version` is always build-free), so the + winning tag is never parsed twice; 6. if `dry_run`, return `DryRun`; else `provider.create_tag` and return `Created`. diff --git a/planning/changes/2026-06-26.03-semver-tag-selection/design.md b/planning/changes/2026-06-26.03-semver-tag-selection/design.md index 02ec2c9..827f479 100644 --- a/planning/changes/2026-06-26.03-semver-tag-selection/design.md +++ b/planning/changes/2026-06-26.03-semver-tag-selection/design.md @@ -46,8 +46,11 @@ four-hop chain. `decisions/2026-06-26-semver-form-tags-only.md` (rejected / deferred). Selection stays SemVer-form only. - No change to the `Outcome` variants, the providers, strategies, output, or DI. -- No change for *stable* baselines: `next_version(part)` equals `bump_*` there, so - every existing bare-semver tag behaves identically. +- No change for *stable* baselines **without build metadata**: `next_version(part)` + equals `bump_*` there, so every existing bare-semver tag behaves identically. The + selector strips build metadata (precedence-irrelevant; semvertag never emits it), + so the carried `Version` is always build-free and `next_version` is never tripped + by a `1.0.0+build`-style tag. ## Design @@ -61,7 +64,7 @@ def _select_latest_semver_tag(tags: list[Tag]) -> tuple[Tag, semver.Version] | N parsed: list[tuple[semver.Version, Tag]] = [] for tag in tags: try: - version = semver.Version.parse(tag.name) + version = semver.Version.parse(tag.name).replace(build=None) except ValueError: continue parsed.append((version, tag)) @@ -73,7 +76,11 @@ def _select_latest_semver_tag(tags: list[Tag]) -> tuple[Tag, semver.Version] | N ``` `sorted(...)[-1]` (not `max`) preserves the current **last-equal-wins** tie order -for versions that compare equal (build metadata is ignored in precedence). +for versions that compare equal (build metadata is ignored in precedence). The +`.replace(build=None)` strips build metadata from the carried `Version` so that +`next_version` never treats a `1.0.0+build`-style baseline as already-finalized +and skips the bump. semvertag never emits build metadata; stripping it is +precedence-neutral. ### 2. Bump via `next_version`, on the carried `Version` diff --git a/planning/changes/2026-06-26.03-semver-tag-selection/plan.md b/planning/changes/2026-06-26.03-semver-tag-selection/plan.md index c01f174..4273995 100644 --- a/planning/changes/2026-06-26.03-semver-tag-selection/plan.md +++ b/planning/changes/2026-06-26.03-semver-tag-selection/plan.md @@ -21,9 +21,11 @@ prerelease baselines via `next_version`. - Python imports at MODULE LEVEL only; every test function argument annotated. - `just test` enforces `fail_under = 100` branch coverage. - BEHAVIOR-PRESERVING **except** the one deliberate change: `next_version` instead - of `bump_*`, which equals `bump_*` on every stable baseline and differs only by - finalizing SemVer-form prerelease baselines. No change to `Outcome`, providers, - strategies, output, or DI. + of `bump_*`, which equals `bump_*` on stable baselines **without build metadata** + and differs only by finalizing SemVer-form prerelease baselines. The selector + strips build metadata (`.replace(build=None)`) so the carried `Version` is always + build-free; a hand-pushed `1.0.0+build` tag therefore bumps correctly to `1.0.1`. + No change to `Outcome`, providers, strategies, output, or DI. - Selection stays **SemVer-form only** — no PEP 440 / `v`-prefix recognition (per the decision record). - Tie order: `sorted(...)[-1]` (last-equal-wins) — do NOT use `max()`. diff --git a/planning/decisions/2026-06-26-semver-form-tags-only.md b/planning/decisions/2026-06-26-semver-form-tags-only.md index 72a2703..3697f16 100644 --- a/planning/decisions/2026-06-26-semver-form-tags-only.md +++ b/planning/decisions/2026-06-26-semver-form-tags-only.md @@ -38,10 +38,18 @@ precedence, and the format it should expect in a managed repo is SemVer-form. Th discriminating "shared by standard, not by coincidence" lens applies: - **`next_version` (chosen) is feasible, dependency-free, and behavior-preserving - for every current tag.** On a stable baseline `next_version(part)` equals - `bump_*` exactly; it differs only by finalizing a SemVer-form prerelease - baseline — which is the correct release-ramp semantics and the bug the review - found. No new dependency, no new version model. + for every current tag.** On a stable baseline **without build metadata** + `next_version(part)` equals `bump_*` exactly; it differs only by finalizing a + SemVer-form prerelease baseline — which is the correct release-ramp semantics + and the bug the review found. The selector strips build metadata + (`.replace(build=None)`) before carrying the `Version`, so a hand-pushed + `1.0.0+build`-style tag (SemVer-valid, precedence-irrelevant) is carried as + `1.0.0` and bumps correctly to `1.0.1`; semvertag never emits build metadata, + so stripping is safe. A SemVer-form prerelease baseline also finalizes on + major/minor/patch alike (e.g. `1.0.0-rc.1` + major → `1.0.0`, not `2.0.0`, + because the lower parts are already zero) — defensible release-ramp semantics, + dormant because semvertag never self-emits prereleases. No new dependency, no + new version model. - **(b) PEP 440 recognition is rejected** because `python-semver` has no PEP 440 parser (the `coerce` recipe extracts only `major.minor.patch` and *discards* the `rc1`, making a prerelease masquerade as final — unusable). Real support needs diff --git a/semvertag/_use_case.py b/semvertag/_use_case.py index b022438..c15c711 100644 --- a/semvertag/_use_case.py +++ b/semvertag/_use_case.py @@ -56,7 +56,7 @@ def _select_latest_semver_tag(tags: list[Tag]) -> tuple[Tag, semver.Version] | N parsed: list[tuple[semver.Version, Tag]] = [] for tag in tags: try: - version = semver.Version.parse(tag.name) + version = semver.Version.parse(tag.name).replace(build=None) except ValueError: continue parsed.append((version, tag)) diff --git a/tests/unit/test_use_case.py b/tests/unit/test_use_case.py index b62557b..0f0c260 100644 --- a/tests/unit/test_use_case.py +++ b/tests/unit/test_use_case.py @@ -334,3 +334,18 @@ def test_select_latest_tie_keeps_last_in_input() -> None: def test_compute_new_version_finalizes_semver_prerelease() -> None: assert _compute_new_version(semver.Version.parse("1.0.0-rc.1"), Bump.PATCH) == "1.0.0" + + +def test_select_latest_strips_build_metadata() -> None: + selected = _select_latest_semver_tag([Tag(name="1.2.3+build.4", commit_sha="a")]) + assert selected is not None + _tag, version = selected + assert version.build is None + assert version == semver.Version.parse("1.2.3") + + +def test_build_metadata_baseline_still_bumps() -> None: + selected = _select_latest_semver_tag([Tag(name="1.2.3+build.4", commit_sha="a")]) + assert selected is not None + _tag, version = selected + assert _compute_new_version(version, Bump.PATCH) == "1.2.4"