From 13d0edc9f42aea978b7bbe7bf1f128c6a53fb21a Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 13 Sep 2025 23:42:00 -0600 Subject: [PATCH 1/3] test(cmd-version): add e2e test cases for managing partial version tag creation & updates --- .../cmd_version/test_version_partial_tag.py | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 tests/e2e/cmd_version/test_version_partial_tag.py diff --git a/tests/e2e/cmd_version/test_version_partial_tag.py b/tests/e2e/cmd_version/test_version_partial_tag.py new file mode 100644 index 000000000..b6d2024d4 --- /dev/null +++ b/tests/e2e/cmd_version/test_version_partial_tag.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from tests.const import EXAMPLE_PROJECT_NAME, MAIN_PROG_NAME, VERSION_SUBCMD +from tests.fixtures import ( + repo_w_no_tags_conventional_commits, +) +from tests.util import ( + assert_successful_exit_code, + dynamic_python_import, +) + +if TYPE_CHECKING: + from typing import List + from unittest.mock import MagicMock + + from requests_mock import Mocker + from typing_extensions import TypeAlias + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ( + ExProjectDir, + GetExpectedVersionPyFileContentFn, + UpdatePyprojectTomlFn, + ) + from tests.fixtures.git_repo import BuiltRepoResult + + CaseId: TypeAlias = str + CliArgs: TypeAlias = List[str] + NextReleaseVersion: TypeAlias = str + ExistingTags: TypeAlias = List[str] + ExpectedNewPartialTags: TypeAlias = List[str] + ExpectedMovedPartialTags: TypeAlias = List[str] + + +cases: tuple[ + tuple[ + CaseId, + CliArgs, + NextReleaseVersion, + ExistingTags, + ExpectedNewPartialTags, + ExpectedMovedPartialTags, + ], + ..., +] = ( + # pre-release should not affect partial tags + ( + "pre-release", + ["--prerelease"], + "0.0.0-rc.1", + ["v0", "v0.0"], + [], + [], + ), + # Create partial tags when they don't exist + ( + "create-partial-tags-when-they-dont-exist__build-metadata", + ["--minor", "--build-metadata", "build.12345"], + "0.1.0+build.12345", + [], + ["v0", "v0.1", "v0.1.0"], + [], + ), + ( + "create-partial-tags-when-they-dont-exist__patch", + ["--patch"], + "0.0.1", + [], + ["v0", "v0.0"], + [], + ), + ( + "create-partial-tags-when-they-dont-exist__minor", + ["--minor"], + "0.1.0", + [], + ["v0", "v0.1"], + [], + ), + ( + "create-partial-tags-when-they-dont-exist__major", + ["--major"], + "1.0.0", + [], + ["v1", "v1.0"], + [], + ), + # Update existing partial tags + ( + "update-existing-partial-tags__build-metadata", + ["--patch", "--build-metadata", "build.12345"], + "0.1.1+build.12345", + ["v0", "v0.0", "v0.1", "v0.1.0"], + ["v0.1.1"], + ["v0", "v0.1"], + ), + ( + "update-existing-partial-tags__patch", + ["--patch"], + "0.0.1", + ["v0", "v0.0"], + [], + ["v0", "v0.0"], + ), + ( + "update-existing-partial-tags__minor", + ["--minor"], + "0.1.0", + ["v0", "v0.0", "v0.1"], + [], + ["v0", "v0.1"], + ), + ( + "update-existing-partial-tags__major", + ["--major"], + "1.0.0", + ["v0", "v0.0", "v0.1", "v1", "v1.0"], + [], + ["v1", "v1.0"], + ), + # Update existing partial tags and create new one + ( + "update-existing-partial-tags-and-create-new-one", + ["--minor"], + "0.1.0", + ["v0", "v0.0"], + ["v0.1"], + ["v0"], + ), + # Partial tag disabled for older version, now enabled + ( + "partial-tag-disabled-for-older-version__build-metadata", + ["--patch", "--build-metadata", "build.12345"], + "1.1.2+build.12345", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1+build.1234"], + ["v1", "v1.1", "v1.1.2"], + [], + ), + ( + "partial-tag-disabled-for-older-version__patch", + ["--patch"], + "1.1.2", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1"], + ["v1", "v1.1"], + [], + ), + ( + "partial-tag-disabled-for-older-version__minor", + ["--minor"], + "1.2.0", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1"], + ["v1", "v1.2"], + [], + ), + ( + "partial-tag-enabled-for-newer-version__major", + ["--major"], + "2.0.0", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1"], + ["v2", "v2.0"], + [], + ), +) + + +@pytest.mark.parametrize( + "repo_result, add_partial_tags, cli_args, next_release_version, existing_partial_tags, expected_new_partial_tags, expected_moved_partial_tags", + [ + *( + pytest.param( + lazy_fixture(repo_w_no_tags_conventional_commits.__name__), + True, + cli_args, + next_release_version, + existing_tags, + expected_new_partial_tags, + expected_moved_partial_tags, + id=f"{case_id}__partial-tags-enabled", + ) + for case_id, cli_args, next_release_version, existing_tags, expected_new_partial_tags, expected_moved_partial_tags in cases + ), + *( + pytest.param( + lazy_fixture(repo_w_no_tags_conventional_commits.__name__), + False, + cli_args, + next_release_version, + existing_tags, + expected_new_partial_tags, + expected_moved_partial_tags, + id=f"{case_id}__partial-tags-disabled", + ) + for case_id, cli_args, next_release_version, existing_tags, expected_new_partial_tags, expected_moved_partial_tags in cases + ), + ], +) +def test_version_partial_tag_creation( + repo_result: BuiltRepoResult, + add_partial_tags: bool, + cli_args: list[str], + next_release_version: str, + example_project_dir: ExProjectDir, + example_pyproject_toml: Path, + existing_partial_tags: list[str], + expected_new_partial_tags: list[str], + expected_moved_partial_tags: list[str], + run_cli: RunCliFn, + mocked_git_fetch: MagicMock, + mocked_git_push: MagicMock, + post_mocker: Mocker, + update_pyproject_toml: UpdatePyprojectTomlFn, + pyproject_toml_file: Path, + changelog_md_file: Path, + version_py_file: Path, + get_expected_version_py_file_content: GetExpectedVersionPyFileContentFn, +): + # Force clean directory state before test (needed for the repo_w_no_tags) + repo = repo_result["repo"] + repo.git.reset("HEAD", hard=True) + + # Enable partial tags + update_pyproject_toml("tool.semantic_release.add_partial_tags", add_partial_tags) + + expected_changed_files = sorted( + [ + str(changelog_md_file), + str(pyproject_toml_file), + str(version_py_file), + ] + ) + expected_new_partial_tags = expected_new_partial_tags if add_partial_tags else [] + expected_moved_partial_tags = ( + expected_moved_partial_tags if add_partial_tags else [] + ) + + expected_version_py_content = get_expected_version_py_file_content( + next_release_version + ) + + # Setup: create existing tags + for tag in existing_partial_tags: + repo.create_tag(tag) + + # Setup: take measurement before running the version command + head_sha_before = repo.head.commit.hexsha + tags_before = {tag.name: repo.commit(tag) for tag in repo.tags} + + pyproject_toml_before = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ) + + # Modify the pyproject.toml to remove the version so we can compare it later + pyproject_toml_before.get("tool", {}).get("poetry", {}).pop("version", None) + + # Define expectations before execution (hypothesis) + expected_git_fetch_calls = 1 + expected_vcs_release_calls = 1 + # 1 for commit, 1 for tag, 1 for each moved or created partial tag + expected_git_push_calls = ( + 2 + len(expected_new_partial_tags) + len(expected_moved_partial_tags) + ) + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *cli_args] + result = run_cli(cli_cmd[1:]) + + # take measurement after running the version command + head_after = repo.head.commit + tags_after = {tag.name: repo.commit(tag.name) for tag in repo.tags} + new_tags = {tag: sha for tag, sha in tags_after.items() if tag not in tags_before} + moved_tags = { + tag: sha + for tag, sha in tags_after.items() + if tag in tags_before and sha != tags_before[tag] + } + differing_files = sorted( + [ + # Make sure filepath uses os specific path separators + str(Path(file)) + for file in str( + repo.git.diff("HEAD", "HEAD~1", name_only=True) + ).splitlines() + ] + ) + pyproject_toml_after = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ) + pyproj_version_after = ( + pyproject_toml_after.get("tool", {}).get("poetry", {}).pop("version") + ) + + # Load python module for reading the version (ensures the file is valid) + actual_version_py_content = (example_project_dir / version_py_file).read_text() + + # Evaluate (normal release actions should have occurred when forced patch bump) + assert_successful_exit_code(result, cli_cmd) + + # A commit has been made + assert [head_sha_before] == [head.hexsha for head in head_after.parents] + + # A version tag and the expected partial tag have been created + assert 1 + len(expected_new_partial_tags) == len(new_tags) + assert len(expected_moved_partial_tags) == len(moved_tags) + assert f"v{next_release_version}" in new_tags + + # Check that all new tags and moved tags are present and on the head commit + for partial_tag in expected_new_partial_tags: + assert partial_tag in new_tags + assert repo.commit(partial_tag).hexsha == head_after.hexsha + + for partial_tag in expected_moved_partial_tags: + assert partial_tag in moved_tags + assert repo.commit(partial_tag).hexsha == head_after.hexsha + + # Expected external calls + assert ( + expected_git_fetch_calls == mocked_git_fetch.call_count + ) # fetch occurred before push + assert expected_git_push_calls == mocked_git_push.call_count + assert ( + expected_vcs_release_calls == post_mocker.call_count + ) # vcs release creation occurred + + # Changelog already reflects changes this should introduce + assert expected_changed_files == differing_files + + # Compare pyproject.toml + assert pyproject_toml_before == pyproject_toml_after + assert next_release_version == pyproj_version_after + + # Compare _version.py + assert expected_version_py_content == actual_version_py_content + + # Verify content is parsable & importable + dynamic_version = dynamic_python_import( + example_project_dir / version_py_file, f"{EXAMPLE_PROJECT_NAME}._version" + ).__version__ + + assert next_release_version == dynamic_version From f2cd4ee81da2f750b4b97ec2f8b5aa44d26f0855 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 13 Sep 2025 23:53:05 -0600 Subject: [PATCH 2/3] feat(cmd-version): add functionality to create & update partial version tags --- src/semantic_release/cli/commands/version.py | 35 ++++++++++++-- src/semantic_release/cli/config.py | 5 +- src/semantic_release/gitproject.py | 51 ++++++++++++-------- src/semantic_release/version/translator.py | 12 +++++ src/semantic_release/version/version.py | 9 ++++ 5 files changed, 88 insertions(+), 24 deletions(-) diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index ad60b95aa..ac7a8e374 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -80,10 +80,13 @@ def is_forced_prerelease( ) -def last_released(repo_dir: Path, tag_format: str) -> tuple[Tag, Version] | None: +def last_released( + repo_dir: Path, tag_format: str, add_partial_tags: bool = False +) -> tuple[Tag, Version] | None: with Repo(str(repo_dir)) as git_repo: ts_and_vs = tags_and_versions( - git_repo.tags, VersionTranslator(tag_format=tag_format) + git_repo.tags, + VersionTranslator(tag_format=tag_format, add_partial_tags=add_partial_tags), ) return ts_and_vs[0] if ts_and_vs else None @@ -454,7 +457,11 @@ def version( # noqa: C901 if print_last_released or print_last_released_tag: # TODO: get tag format a better way if not ( - last_release := last_released(config.repo_dir, tag_format=config.tag_format) + last_release := last_released( + config.repo_dir, + tag_format=config.tag_format, + add_partial_tags=config.add_partial_tags, + ) ): logger.warning("No release tags found.") return @@ -475,6 +482,7 @@ def version( # noqa: C901 major_on_zero = runtime.major_on_zero no_verify = runtime.no_git_verify opts = runtime.global_cli_options + add_partial_tags = config.add_partial_tags gha_output = VersionGitHubActionsOutput( gh_client=hvcs_client if isinstance(hvcs_client, Github) else None, mode=( @@ -777,6 +785,27 @@ def version( # noqa: C901 tag=new_version.as_tag(), noop=opts.noop, ) + # Create or update partial tags for releases + if add_partial_tags and not prerelease: + partial_tags = [new_version.as_major_tag(), new_version.as_minor_tag()] + # If build metadata is set, also retag the version without the metadata + if build_metadata: + partial_tags.append(new_version.as_patch_tag()) + + for partial_tag in partial_tags: + project.git_tag( + tag_name=partial_tag, + message=f"{partial_tag} => {new_version.as_tag()}", + isotimestamp=commit_date.isoformat(), + noop=opts.noop, + force=True, + ) + project.git_push_tag( + remote_url=remote_url, + tag=partial_tag, + noop=opts.noop, + force=True, + ) # Update GitHub Actions output value now that release has occurred gha_output.released = True diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 37b86a811..514d76ef1 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -366,6 +366,7 @@ class RawConfig(BaseModel): remote: RemoteConfig = RemoteConfig() no_git_verify: bool = False tag_format: str = "v{version}" + add_partial_tags: bool = False publish: PublishConfig = PublishConfig() version_toml: Optional[Tuple[str, ...]] = None version_variables: Optional[Tuple[str, ...]] = None @@ -827,7 +828,9 @@ def from_raw_config( # noqa: C901 # version_translator version_translator = VersionTranslator( - tag_format=raw.tag_format, prerelease_token=branch_config.prerelease_token + tag_format=raw.tag_format, + prerelease_token=branch_config.prerelease_token, + add_partial_tags=raw.add_partial_tags, ) build_cmd_env = {} diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index a29bb41de..9ea156da7 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -238,7 +238,12 @@ def git_commit( raise GitCommitError("Failed to commit changes") from err def git_tag( - self, tag_name: str, message: str, isotimestamp: str, noop: bool = False + self, + tag_name: str, + message: str, + isotimestamp: str, + force: bool = False, + noop: bool = False, ) -> None: try: datetime.fromisoformat(isotimestamp) @@ -248,21 +253,25 @@ def git_tag( if noop: command = str.join( " ", - [ - f"GIT_COMMITTER_DATE={isotimestamp}", - *( - [ - f"GIT_AUTHOR_NAME={self._commit_author.name}", - f"GIT_AUTHOR_EMAIL={self._commit_author.email}", - f"GIT_COMMITTER_NAME={self._commit_author.name}", - f"GIT_COMMITTER_EMAIL={self._commit_author.email}", - ] - if self._commit_author - else [""] - ), - f"git tag -a {tag_name} -m '{message}'", - ], - ) + filter( + None, + [ + f"GIT_COMMITTER_DATE={isotimestamp}", + *( + [ + f"GIT_AUTHOR_NAME={self._commit_author.name}", + f"GIT_AUTHOR_EMAIL={self._commit_author.email}", + f"GIT_COMMITTER_NAME={self._commit_author.name}", + f"GIT_COMMITTER_EMAIL={self._commit_author.email}", + ] + if self._commit_author + else [""] + ), + f"git tag -a {tag_name} -m '{message}'", + "--force" if force else "", + ], + ), + ).strip() noop_report( indented( @@ -279,7 +288,7 @@ def git_tag( {"GIT_COMMITTER_DATE": isotimestamp}, ): try: - repo.git.tag("-a", tag_name, m=message) + repo.git.tag(tag_name, a=True, m=message, force=force) except GitCommandError as err: self.logger.exception(str(err)) raise GitTagError(f"Failed to create tag ({tag_name})") from err @@ -305,13 +314,15 @@ def git_push_branch(self, remote_url: str, branch: str, noop: bool = False) -> N f"Failed to push branch ({branch}) to remote" ) from err - def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None: + def git_push_tag( + self, remote_url: str, tag: str, noop: bool = False, force: bool = False + ) -> None: if noop: noop_report( indented( f"""\ would have run: - git push {self._cred_masker.mask(remote_url)} tag {tag} + git push {self._cred_masker.mask(remote_url)} tag {tag} {"--force" if force else ""} """ # noqa: E501 ) ) @@ -319,7 +330,7 @@ def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None: with Repo(str(self.project_root)) as repo: try: - repo.git.push(remote_url, "tag", tag) + repo.git.push(remote_url, "tag", tag, force=force) except GitCommandError as err: self.logger.exception(str(err)) raise GitPushError(f"Failed to push tag ({tag}) to remote") from err diff --git a/src/semantic_release/version/translator.py b/src/semantic_release/version/translator.py index 16885af10..5026fd089 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -48,11 +48,19 @@ def __init__( self, tag_format: str = "v{version}", prerelease_token: str = "rc", # noqa: S107 + add_partial_tags: bool = False, ) -> None: check_tag_format(tag_format) self.tag_format = tag_format self.prerelease_token = prerelease_token + self.add_partial_tags = add_partial_tags self.from_tag_re = self._invert_tag_format_to_re(self.tag_format) + self.partial_tag_re = regexp( + regex_escape(tag_format).replace( + regex_escape(r"{version}"), r"[0-9]+(\.(0|[1-9][0-9]*))?$" + ), + flags=VERBOSE, + ) def from_string(self, version_str: str) -> Version: """ @@ -75,6 +83,10 @@ def from_tag(self, tag: str) -> Version | None: tag_match = self.from_tag_re.match(tag) if not tag_match: return None + if self.add_partial_tags: + partial_tag_match = self.partial_tag_re.match(tag) + if partial_tag_match: + return None raw_version_str = tag_match.group("version") return self.from_string(raw_version_str) diff --git a/src/semantic_release/version/version.py b/src/semantic_release/version/version.py index 032596e4a..3e97be9fe 100644 --- a/src/semantic_release/version/version.py +++ b/src/semantic_release/version/version.py @@ -203,6 +203,15 @@ def __repr__(self) -> str: def as_tag(self) -> str: return self.tag_format.format(version=str(self)) + def as_major_tag(self) -> str: + return self.tag_format.format(version=f"{self.major}") + + def as_minor_tag(self) -> str: + return self.tag_format.format(version=f"{self.major}.{self.minor}") + + def as_patch_tag(self) -> str: + return self.tag_format.format(version=f"{self.major}.{self.minor}.{self.patch}") + def as_semver_tag(self) -> str: return f"v{self!s}" From 2edeeb4bee3dda5370affa7b3c0198aa18ca0eb4 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 13 Sep 2025 23:54:37 -0600 Subject: [PATCH 3/3] docs(configuration): add description for `add_partial_tags` setting & use --- docs/configuration/configuration.rst | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index 685bfa8e4..342239152 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1162,6 +1162,52 @@ from the :ref:`remote.name ` location of your git repository ---- +.. _config-add_partial_tags: + +``add_partial_tags`` +"""""""""""""""""""" + +**Type:** ``bool`` + +Specify if partial version tags should be handled when creating a new version. If set to +``true``, a ``major`` and a ``major.minor`` tag will be created or updated, using the format +specified in :ref:`tag_format`. If version has build metadata, a ``major.minor.patch`` tag +will also be created or updated. + +Partial version tags are **disabled** for pre-release versions. + +**Example** + +.. code-block:: toml + + [semantic_release] + tag_format = "v{version}" + add_partial_tags = true + +This configuration with the next version of ``1.2.3`` will result in: + +.. code-block:: bash + + git log --decorate --oneline --graph --all + # * 4d4cb0a (tag: v1.2.3, tag: v1.2, tag: v1, origin/main, main) 1.2.3 + # * 3a2b1c0 fix: some bug + # * 2b1c0a9 (tag: v1.2.2) 1.2.2 + # ... + +If build-metadata is used, the next version of ``1.2.3+20251109`` will result in: + +.. code-block:: bash + + git log --decorate --oneline --graph --all + # * 4d4cb0a (tag: v1.2.3+20251109, tag: v1.2.3, tag: v1.2, tag: v1, origin/main, main) 1.2.3+20251109 + # * 3a2b1c0 chore: add partial tags to PSR configuration + # * 2b1c0a9 (tag: v1.2.3+20251031) 1.2.3+20251031 + # ... + +**Default:** ``false`` + +---- + .. _config-tag_format: ``tag_format``