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: 8 additions & 2 deletions auto-tagger/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
.venv
__pycache__/
.mypy_cache
.ruff_cache
.pytest_cache
.ruff_cache
.uv-cache
.venv
*.pyc
build/
dist/
3 changes: 2 additions & 1 deletion auto-tagger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,5 @@ If there are no tags already available with the specified format, a new one with
Bumping a tag version can happen in two methods:

- Any commit message that includes `[#major]`, `[#minor]`, `[#patch]` triggers the respective SemVer bump. If two or more are present, the order is from `major` to `patch`.
- If no commit message contains the keyword, the default value is used from `default_bump_strategy`.
- If any commit includes `[#skip]`, the action skips tagging even if other bump markers are present.
- If no commit message contains any keyword, the default value is used from `default_bump_strategy`.
2 changes: 1 addition & 1 deletion auto-tagger/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ inputs:
default: "."
prefix:
description: "Prefix to use for tag generation (e.g., 'v')."
default: ""
default: "v"
suffix:
description: "Suffix to use for tag generation (e.g., '-test')."
default: ""
Expand Down
135 changes: 102 additions & 33 deletions auto-tagger/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,64 +4,133 @@

import os
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Iterable, Mapping

from github_resources import Commit

from github_resources import BumpStrategy

class BumpStrategy(StrEnum):
"""Enum containing the different version bump strategy for semver."""
_TRUE_VALUES = {"1", "true", "yes", "y", "on"}
_FALSE_VALUES = {"0", "false", "no", "n", "off"}
_DEFAULT_BIND_TO_MAJOR = False
_DEFAULT_BUMP_STRATEGY = BumpStrategy.SKIP
_DEFAULT_BRANCH = "main"
_DEFAULT_PATH = "."
_DEFAULT_PREFIX = "v"
_DEFAULT_SUFFIX = ""
_DEFAULT_DRY_RUN = False

MAJOR = "major"
MINOR = "minor"
PATCH = "patch"
SKIP = "skip"

class ConfigurationError(ValueError):
"""Raised when configuration values are missing or invalid."""

def _env_flag(var_name: str, *, default: bool) -> bool:

def _env_flag(env: Mapping[str, str], var_name: str, *, default: bool) -> bool:
"""Return a boolean flag from an environment variable using a safe default."""
return os.environ.get(var_name, str(default)).strip().lower() == "true"
value = env.get(var_name)
if value is None:
return default
lowered = value.strip().lower()
if lowered in _TRUE_VALUES:
return True
if lowered in _FALSE_VALUES:
return False
return default


def _env_str(
env: Mapping[str, str], var_name: str, default: str, *, allow_empty: bool = False
) -> str:
"""Return a string value from env, falling back to default when empty."""
value = env.get(var_name)
if value is None:
return default
stripped = value.strip()
if not stripped and not allow_empty:
return default
return stripped


def _parse_bump_strategy(value: str | None, *, default: BumpStrategy) -> BumpStrategy:
"""Parse a bump strategy value; fall back to default when invalid."""
if value is None:
return default
try:
return BumpStrategy(value.strip().lower())
except ValueError:
return default


@dataclass
class Configuration:
"""Configuration resource populated from the environment."""

BIND_TO_MAJOR: bool = False
DEFAULT_BUMP_STRATEGY: BumpStrategy = BumpStrategy.SKIP
DEFAULT_BRANCH: str = "main"
PATH: str = "."
PREFIX: str = "v"
REPOSITORY: str = os.environ.get("GITHUB_REPOSITORY", "")
SUFFIX: str = ""
DRY_RUN: bool = False
BIND_TO_MAJOR: bool = _DEFAULT_BIND_TO_MAJOR
DEFAULT_BUMP_STRATEGY: BumpStrategy = _DEFAULT_BUMP_STRATEGY
DEFAULT_BRANCH: str = _DEFAULT_BRANCH
PATH: str = _DEFAULT_PATH
PREFIX: str = _DEFAULT_PREFIX
REPOSITORY: str = ""
SUFFIX: str = _DEFAULT_SUFFIX
DRY_RUN: bool = _DEFAULT_DRY_RUN

@classmethod
def from_env(cls) -> Configuration:
def from_env(cls, env: Mapping[str, str] | None = None) -> Configuration:
"""Create default configuration instance with values from env variables."""
environment = env or os.environ
return cls(
BIND_TO_MAJOR=_env_flag("INPUT_BIND_TO_MAJOR", default=cls.BIND_TO_MAJOR),
DEFAULT_BUMP_STRATEGY=BumpStrategy(
os.environ.get("INPUT_DEFAULT_BUMP_STRATEGY", cls.DEFAULT_BUMP_STRATEGY)
BIND_TO_MAJOR=_env_flag(
environment, "INPUT_BIND_TO_MAJOR", default=_DEFAULT_BIND_TO_MAJOR
),
DEFAULT_BUMP_STRATEGY=_parse_bump_strategy(
environment.get("INPUT_DEFAULT_BUMP_STRATEGY"),
default=_DEFAULT_BUMP_STRATEGY,
),
DEFAULT_BRANCH=os.environ.get("INPUT_MAIN_BRANCH", cls.DEFAULT_BRANCH),
PATH=os.environ.get("INPUT_PATH", cls.PATH),
PREFIX=os.environ.get("INPUT_PREFIX", cls.PREFIX),
REPOSITORY=os.environ.get("GITHUB_REPOSITORY", ""),
SUFFIX=os.environ.get("INPUT_SUFFIX", cls.SUFFIX),
DRY_RUN=_env_flag("INPUT_DRY_RUN", default=cls.DRY_RUN),
DEFAULT_BRANCH=_env_str(
environment, "INPUT_DEFAULT_BRANCH", _DEFAULT_BRANCH
),
PATH=_env_str(environment, "INPUT_PATH", _DEFAULT_PATH),
PREFIX=_env_str(
environment, "INPUT_PREFIX", _DEFAULT_PREFIX, allow_empty=True
),
REPOSITORY=_env_str(environment, "GITHUB_REPOSITORY", ""),
SUFFIX=_env_str(
environment, "INPUT_SUFFIX", _DEFAULT_SUFFIX, allow_empty=True
),
DRY_RUN=_env_flag(environment, "INPUT_DRY_RUN", default=_DEFAULT_DRY_RUN),
)

def get_bump_strategy_from_commits(self, commits: Iterable[Commit]) -> BumpStrategy:
"""Get the bump strategy from a list of commits parsing the keywords [#<strategy>]."""
strategies = tuple(strategy.value for strategy in BumpStrategy)
"""Return the bump strategy from commits using [#<strategy>] markers."""
strategies_in_commits: set[BumpStrategy] = set()
for commit in commits:
lowered_message = commit.message.lower()
for strategy in strategies:
if f"[#{strategy}]" in lowered_message:
return BumpStrategy(strategy)
for strategy in BumpStrategy:
if f"[#{strategy.value}]" in lowered_message:
strategies_in_commits.add(strategy)
if BumpStrategy.SKIP in strategies_in_commits:
return BumpStrategy.SKIP
for strategy in (BumpStrategy.MAJOR, BumpStrategy.MINOR, BumpStrategy.PATCH):
if strategy in strategies_in_commits:
return strategy
return self.DEFAULT_BUMP_STRATEGY

@property
def commit_path_filter(self) -> str | None:
"""Return a path filter suitable for GitHub API, or None for the whole repo."""
path = self.PATH.strip()
if not path or path in {".", "./"}:
return None
return path

def validate(self) -> None:
"""Validate configuration values required at runtime."""
if not self.REPOSITORY:
message = "GITHUB_REPOSITORY is required to resolve the repository."
raise ConfigurationError(message)
if not self.DEFAULT_BRANCH:
message = "default_branch must be provided."
raise ConfigurationError(message)
47 changes: 34 additions & 13 deletions auto-tagger/github_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
if TYPE_CHECKING:
from collections.abc import Sequence

from configuration import Configuration

from github import Github, InputGitAuthor
from github.GithubException import GithubException
from semver import Version, VersionInfo

from configuration import BumpStrategy, Configuration
from github_resources import Commit, Tag
from github_resources import BumpStrategy, Commit, Tag


class GitCommitAuthor(Protocol):
Expand Down Expand Up @@ -120,6 +122,7 @@ def __init__(
"GitHubClient", github_client or Github(self.token)
)
self.repo: GitRepository = self.github_client.get_repo(self.config.REPOSITORY)
self._last_commit_cache: Commit | None = None
self.last_available_tag: Tag = self.get_latest_tag()
self.last_available_major_tag: Tag = self.get_latest_major_tag()

Expand All @@ -145,28 +148,39 @@ def bump_tag_version(self, strategy: BumpStrategy, tag: Tag) -> Tag:

def get_commits_since(self, since: datetime) -> list[Commit]:
"""Get commits since a predefined datetime."""
if since.tzinfo is None:
since = since.replace(tzinfo=UTC)
path_filter = self.config.commit_path_filter
return [
self._to_commit_resource(commit)
for commit in self.repo.get_commits(
since=since + timedelta(seconds=1), path=self.config.PATH
since=since + timedelta(seconds=1), path=path_filter
)
]

def get_last_commit(self) -> Commit:
"""Get the latest commit available on the repository."""
if self._last_commit_cache is not None:
return self._last_commit_cache
path_filter = self.config.commit_path_filter
default_commit_list = self.repo.get_commits(
since=datetime.fromtimestamp(0, tz=UTC), path=self.config.PATH
since=datetime.fromtimestamp(0, tz=UTC), path=path_filter
)
if not default_commit_list:
message = "Unable to resolve last commit: repository returned no commits."
raise RuntimeError(message)
fallback_sha = default_commit_list[0].sha
commit = self.repo.get_commit(os.environ.get("GITHUB_SHA", fallback_sha))
return self._to_commit_resource(commit)
target_sha = os.environ.get("GITHUB_SHA", fallback_sha)
try:
commit = self.repo.get_commit(target_sha)
except (GithubException, ValueError):
commit = default_commit_list[0]
self._last_commit_cache = self._to_commit_resource(commit)
return self._last_commit_cache

def get_latest_tag(self) -> Tag:
"""Get the latest semver tag matching prefix and suffix on the repository (e.g. v0.2.1)."""
matching_tags: list[Tag] = []
matching_tags: list[tuple[Version, Tag]] = []
for tag in self.repo.get_tags():
name = tag.name
if not name.startswith(self.config.PREFIX) or not name.endswith(
Expand All @@ -178,17 +192,24 @@ def get_latest_tag(self) -> Tag:
)
if not VersionInfo.is_valid(version_str):
continue
version = Version.parse(version_str)
matching_tags.append(
Tag(
name=name,
commit=tag.commit.sha,
message=tag.commit.commit.message,
date=tag.commit.commit.author.date,
(
version,
Tag(
name=name,
commit=tag.commit.sha,
message=tag.commit.commit.message,
date=tag.commit.commit.author.date,
),
)
)

if matching_tags:
return max(matching_tags, key=lambda candidate: candidate.date)
_, latest_tag = max(
matching_tags, key=lambda candidate: (candidate[0], candidate[1].date)
)
return latest_tag

last_commit = self.get_last_commit()
default_tag = Tag(
Expand Down
15 changes: 13 additions & 2 deletions auto-tagger/github_resources.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
"""Thin, typed resource wrappers for PyGitHub objects."""
"""Domain models and enums used by the action."""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
from typing import Final

__all__ = ["Commit", "Tag"]
__all__ = ["BumpStrategy", "Commit", "Tag"]

_DEFAULT_TAG_TYPE: Final[str] = "commit"
_DEFAULT_BUMP_STRATEGY: Final[str] = "skip"


class BumpStrategy(StrEnum):
"""Enum containing the different version bump strategies for semver."""

MAJOR = "major"
MINOR = "minor"
PATCH = "patch"
SKIP = _DEFAULT_BUMP_STRATEGY


@dataclass
Expand Down
Loading
Loading