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
17 changes: 12 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,15 @@ in the same file, where a *provider* is a DI recipe. Say **provider** for the fo
**modern-di provider** for the other.

**Strategy**:
The rule that turns **one** commit into a `Bump`. It receives the head commit of the default branch
and nothing else — no network, no tag history, no commit range. A strategy never picks a tag or a
version; the use-case does that around it.
The rule that turns **one** commit into a `Bump` or a **decline**. It receives the head commit of the
default branch and nothing else — no network, no tag history, no commit range. A strategy never picks
a tag or a version; the use-case does that around it.

**Decline**:
A strategy's answer that the head commit warrants no bump, naming which of its rules applied as a
status token and a fixed reason. Each distinct rule gets its own token, so a status never claims
something false about the commit (a merge commit is never reported as `no_merge_commit`).
_Avoid_: "no bump" for the strategy's answer. `NoBump` is the **Outcome** variant a decline becomes.

**Latest tag**:
The bump baseline: the **highest by SemVer precedence** among the repo's SemVer-form tags, bare
Expand All @@ -57,8 +63,9 @@ What a run did, as the closed sum `Created | DryRun | NoTags | AlreadyTagged | N
`semvertag/_outcome.py`. It is internal and free to grow — the renderers `match` it exhaustively, so
a new variant is a type error until handled. Distinct from **`RunResult`**, the JSON wire DTO it
projects onto, and from **status**, the one string field on that DTO. The wire status tokens are a
frozen public contract (`schema_version` `"1.0"`, parsed by `jq` in `action.yml`); the `Outcome`
variant names are not.
frozen public contract (`schema_version` `"1.0"`, parsed by `jq` in `action.yml`): a token never
changes meaning, though new ones may be added. The `Outcome` variant names are not frozen, and neither
is **reason**, the human-facing sentence beside the status, which may be reworded.

**Prerelease**:
Two incompatible spellings live in this repo and only one is recognized as a bump baseline.
Expand Down
6 changes: 3 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ runs:
# to no argument; non-empty expands to the literal `--dry-run`.
result=$(uvx 'semvertag>=0.5.0,<1' tag --json $dry_run_flag)
printf '%s\n' "$result"
# Normalize the CLI's internal status (`no_tags`, `already_tagged`,
# `no_merge_commit`, `no_conforming_commit`, `dry_run`, ...) to a stable
# consumer-facing enum. `set -euo pipefail` ensures we never reach
# Normalize the CLI's internal status to a stable consumer-facing enum:
# `created`, or `no-bump` for any other status (`no_tags`, `dry_run`, a
# strategy's decline, ...). `set -euo pipefail` ensures we never reach
# here on CLI errors, so there is no `error` value to surface.
case "$(jq -r '.status' <<<"$result")" in
created) status='created' ;;
Expand Down
11 changes: 6 additions & 5 deletions docs/adr/0002-outcome-renderings-stay-split.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
`to_run_result` in `_outcome.py` and `_format_outcome` in `_output.py` are two exhaustive `match`
statements over the same closed `Outcome` sum, rather than two renderings hung on the variants
themselves. Only the match skeleton is shared, and no string is, by design: the wire arm builds a
frozen machine contract of fixed status tokens and reasons that `action.yml` parses with `jq`, while
the human arm builds a sentence that is free to be reworded. Co-locating them would trade
locality-of-concern for locality-of-variant and drag presentation phrasing into a module that today
depends only on `_types`. The drift a shared home would guard against is already type-enforced:
both matches end in `assert_never`, so a sixth variant is a type error in both arms until handled.
machine contract of frozen status tokens, which `action.yml` parses with `jq`, beside a
human-facing `reason`, while the human arm builds a sentence; both texts are free to be reworded.
Co-locating them would trade locality-of-concern for locality-of-variant and drag presentation
phrasing into a module that today depends only on `_types`. The drift a shared home would guard
against is already type-enforced: both matches end in `assert_never`, so a sixth variant is a type
error in both arms until handled.
Unification earns its keep only once a variant's two renderings must be the identical string.
8 changes: 4 additions & 4 deletions docs/providers/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ When you give the step an `id:`, downstream steps can read three outputs:
|---|---|
| `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. |
| `status` | `created` (tag pushed) \| `no-bump` (nothing to tag: no prior tag, already tagged, or the strategy declined the head commit). On CLI error the action itself exits non-zero and this output is not written. |

Example: trigger a downstream release-notes job only when a tag was
created.
Expand Down Expand Up @@ -203,9 +203,9 @@ Pick `branch-prefix` if your team merges PRs with branch 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
`hotfix/` bump patch, and a merge with any other prefix bumps nothing
(`unmapped_branch_prefix`). 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.

Expand Down
4 changes: 2 additions & 2 deletions docs/providers/gitlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ Pick `branch-prefix` if your team merges merge requests with branch
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
minor, `bugfix/` and `hotfix/` bump patch, and a merge with any other
prefix bumps nothing (`unmapped_branch_prefix`). 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
Expand Down
8 changes: 4 additions & 4 deletions docs/strategies/branch-prefix.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ behavior.
|---|---|
| `feature/` | minor |
| `bugfix/` or `hotfix/` | patch |
| anything else | none |
| anything else | none, status `unmapped_branch_prefix` |

A bump of `none` means the commit contributes nothing to the release
decision. `branch-prefix` never produces a major bump. Promote
Expand All @@ -26,12 +26,12 @@ to a new major version manually, or switch to
The strategy only fires on commits whose subject contains one of the
default merge marks: the literal string `Merge branch` (the default
`git merge` subject) or `Merge pull request` (GitHub's merge-commit
subject). Commits without one of those marks return `none` regardless
of prefix. For example:
subject). Commits without one of those marks are declined with
`no_merge_commit` regardless of prefix. For example:

- Standard `git merge feature/foo` → subject `Merge branch 'feature/foo' into main` → bump = minor ✓
- GitHub's `Merge pull request #N from user/feature/foo` → bump = minor ✓
- Direct pushes to the default branch → bump = none, unless
- Direct pushes to the default branch → bump = none (`no_merge_commit`), unless
`patch_on_non_merge_commit` is enabled (see below), in which case
bump = patch.

Expand Down
8 changes: 5 additions & 3 deletions docs/strategies/conventional-commits.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ commits since the latest tag; see [Head commit only](#head-commit-only).
| `!` suffix on the type (e.g. `feat!:`, `fix!:`, `refactor!:`) | major |
| `feat:` (or `feat(scope):`) | minor |
| `fix:` (or `fix(scope):`), `perf:` (or `perf(scope):`) | patch |
| Any other type (`chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci`, `revert`, ...) | none |
| Commits whose subject does not match the type-grammar at all | none |
| Any other type (`chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci`, `revert`, ...) | none, status `no_bumping_type` |
| Commits whose subject does not match the type-grammar at all | none, status `no_conforming_commit` |

The grammar checked is `^(type)(?:\((scope)\))?(!)?:`. Anything not
matching this pattern returns `none`. The `!` marker takes precedence
matching this pattern is declined with `no_conforming_commit`; a
matching commit whose type is in neither list is declined with
`no_bumping_type`. The `!` marker takes precedence
over the type: `chore!:` is a major bump even though `chore` is
otherwise unmapped.

Expand Down
9 changes: 4 additions & 5 deletions semvertag/_outcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
_NO_TAGS_REASON: typing.Final = f"No prior semver-conforming tags found; {_SEED_ADVICE}"
_ALREADY_TAGGED_REASON: typing.Final = "Latest commit already tagged."
_NO_BUMP: typing.Final = "none"


@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
Expand Down Expand Up @@ -76,7 +77,7 @@ def to_run_result(outcome: Outcome, *, strategy: str) -> RunResult:
case NoTags(commit=commit, skipped_tag_count=skipped_tag_count):
return RunResult(
strategy=strategy,
bump=Bump.NONE.value,
bump=_NO_BUMP,
status="no_tags",
tag=None,
commit=commit,
Expand All @@ -85,16 +86,14 @@ def to_run_result(outcome: Outcome, *, strategy: str) -> RunResult:
case AlreadyTagged(tag=tag, commit=commit):
return RunResult(
strategy=strategy,
bump=Bump.NONE.value,
bump=_NO_BUMP,
status="already_tagged",
tag=tag,
commit=commit,
reason=_ALREADY_TAGGED_REASON,
)
case NoBump(status=status, reason=reason, commit=commit):
return RunResult(
strategy=strategy, bump=Bump.NONE.value, status=status, tag=None, commit=commit, reason=reason
)
return RunResult(strategy=strategy, bump=_NO_BUMP, status=status, tag=None, commit=commit, reason=reason)
case _: # pragma: no cover - exhaustiveness guard; ty verifies every Outcome is matched
typing.assert_never(outcome)

Expand Down
1 change: 0 additions & 1 deletion semvertag/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@


class Bump(enum.Enum):
NONE = "none"
PATCH = "patch"
MINOR = "minor"
MAJOR = "major"
Expand Down
19 changes: 8 additions & 11 deletions semvertag/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from semvertag._output import Output
from semvertag._types import Bump, Tag
from semvertag.providers._base import Provider
from semvertag.strategies._base import BumpStrategy
from semvertag.strategies._base import BumpStrategy, Decline


_V_PREFIX: typing.Final = "v"
Expand Down Expand Up @@ -35,20 +35,17 @@ def __call__(self, *, output: Output, dry_run: bool = False) -> Outcome:
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_tag: typing.Final = _tag_prefix(latest_tag) + _compute_new_version(latest_version, bump)
decision: typing.Final = self.strategy.decide(commit)
if isinstance(decision, Decline):
return self._emit(output, NoBump(status=decision.status, reason=decision.reason, commit=commit.sha))

new_tag: typing.Final = _tag_prefix(latest_tag) + _compute_new_version(latest_version, decision)
if dry_run:
return self._emit(output, DryRun(tag=new_tag, bump=bump, commit=commit.sha))
return self._emit(output, DryRun(tag=new_tag, bump=decision, 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))
return self._emit(output, Created(tag=new_tag, bump=decision, commit=commit.sha))

def _emit(self, output: Output, outcome: Outcome) -> Outcome:
output.emit(outcome, strategy=self.strategy.name)
Expand Down
13 changes: 8 additions & 5 deletions semvertag/strategies/_base.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import dataclasses
import typing

from semvertag._types import Bump, Commit


@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
class Decline:
status: str
reason: str


class BumpStrategy(typing.Protocol):
@property
def name(self) -> str: ...
@property
def no_bump_status(self) -> str: ...
@property
def no_bump_reason(self) -> str: ...

def decide(self, commit: Commit) -> Bump: ...
def decide(self, commit: Commit) -> Bump | Decline: ...
17 changes: 10 additions & 7 deletions semvertag/strategies/branch_prefix.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@

from semvertag._commit_parse import subject_line
from semvertag._types import Bump, Commit
from semvertag.strategies._base import Decline


_NOT_A_MERGE_COMMIT: typing.Final = Decline(
status="no_merge_commit", reason="Latest commit on default branch is not a merge commit."
)
_UNMAPPED_BRANCH_PREFIX: typing.Final = Decline(
status="unmapped_branch_prefix", reason="Merge commit's source branch has no configured prefix."
)
_NonEmptyStr: typing.TypeAlias = typing.Annotated[str, pydantic.Field(min_length=1)]


Expand All @@ -25,18 +32,14 @@ class BranchPrefixConfig(pydantic.BaseModel):
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
class BranchPrefixStrategy:
name: typing.ClassVar[str] = "branch-prefix"
no_bump_status: typing.ClassVar[str] = "no_merge_commit"
no_bump_reason: typing.ClassVar[str] = "Latest commit on default branch is not a merge commit."
config: BranchPrefixConfig

def decide(self, commit: Commit) -> Bump:
def decide(self, commit: Commit) -> Bump | Decline:
subject: typing.Final = subject_line(commit.message)
if not any(mark in subject for mark in self.config.merge_mark_texts):
# Non-merge commit: opt-in to a patch bump, else no bump
# (the no_bump_* ClassVars only surface on the Bump.NONE path).
return Bump.PATCH if self.config.patch_on_non_merge_commit else Bump.NONE
return Bump.PATCH if self.config.patch_on_non_merge_commit else _NOT_A_MERGE_COMMIT
if any(prefix in subject for prefix in self.config.minor):
return Bump.MINOR
if any(prefix in subject for prefix in self.config.patch):
return Bump.PATCH
return Bump.NONE
return _UNMAPPED_BRANCH_PREFIX
13 changes: 8 additions & 5 deletions semvertag/strategies/conventional_commits.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@

from semvertag._commit_parse import body_lines, subject_line
from semvertag._types import Bump, Commit
from semvertag.strategies._base import Decline


_TYPE_PATTERN: typing.Final = re.compile(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<bang>!?):")
_VALID_TYPE_RE: typing.Final = re.compile(r"^[a-z]+$")
_BREAKING_TOKENS: typing.Final = ("BREAKING CHANGE:", "BREAKING-CHANGE:")
_NOT_CONFORMING: typing.Final = Decline(
status="no_conforming_commit", reason="Commit subject is not a Conventional Commit."
)
_NO_BUMPING_TYPE: typing.Final = Decline(status="no_bumping_type", reason="Commit type is not configured to bump.")


class ConventionalCommitsConfig(pydantic.BaseModel):
Expand All @@ -32,15 +37,13 @@ def _validate_types(cls, value: tuple[str, ...]) -> tuple[str, ...]:
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
class ConventionalCommitsStrategy:
name: typing.ClassVar[str] = "conventional-commits"
no_bump_status: typing.ClassVar[str] = "no_conforming_commit"
no_bump_reason: typing.ClassVar[str] = "No conforming Conventional Commits type found in commit message."
config: ConventionalCommitsConfig

def decide(self, commit: Commit) -> Bump:
def decide(self, commit: Commit) -> Bump | Decline:
subject: typing.Final = subject_line(commit.message)
match: typing.Final = _TYPE_PATTERN.match(subject)
if match is None:
return Bump.NONE
return _NOT_CONFORMING
for line in body_lines(commit.message):
stripped = line.lstrip()
if any(stripped.startswith(token) for token in _BREAKING_TOKENS):
Expand All @@ -52,4 +55,4 @@ def decide(self, commit: Commit) -> Bump:
return Bump.MINOR
if commit_type in self.config.patch_types:
return Bump.PATCH
return Bump.NONE
return _NO_BUMPING_TYPE
2 changes: 1 addition & 1 deletion tests/integration/test_strategy_switching.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def test_skips_with_no_conforming_commit_when_strategy_is_cc_and_message_has_no_

assert result.exit_code == 0
assert "No tag created" in result.stdout
assert "No conforming Conventional Commits type" in result.stdout
assert "Commit subject is not a Conventional Commit." in result.stdout


def test_marina_journey_same_fixture_different_strategies_produces_different_bumps(
Expand Down
Loading
Loading