From 90543c2455f7955cd223fef18791edb3853e9ab9 Mon Sep 17 00:00:00 2001 From: Matthieu Sarter Date: Sun, 8 Dec 2024 14:57:48 +0100 Subject: [PATCH 1/6] feat(cmd-version): add support for rolling tags Set rolling_tags to true to create/update major and major.minor tags when releasing a version. Also remove warnings about invalid version for the rolling tags. --- src/semantic_release/cli/commands/version.py | 32 ++++++++++-- src/semantic_release/cli/config.py | 5 +- src/semantic_release/gitproject.py | 51 ++++++++++++-------- src/semantic_release/version/translator.py | 10 ++++ src/semantic_release/version/version.py | 6 +++ 5 files changed, 80 insertions(+), 24 deletions(-) diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 86d209937..5278d9b21 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -74,10 +74,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, rolling_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, rolling_tags=rolling_tags), ) return ts_and_vs[0] if ts_and_vs else None @@ -451,7 +454,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, + rolling_tags=config.rolling_tags, + ) ): log.warning("No release tags found.") return @@ -472,6 +479,7 @@ def version( # noqa: C901 major_on_zero = runtime.major_on_zero no_verify = runtime.no_git_verify opts = runtime.global_cli_options + rolling_tags = config.rolling_tags gha_output = VersionGitHubActionsOutput(released=False) forced_level_bump = None if not force_level else LevelBump.from_string(force_level) @@ -703,6 +711,24 @@ def version( # noqa: C901 tag=new_version.as_tag(), noop=opts.noop, ) + # Update rolling tags + if rolling_tags: + for rolling_tag in ( + new_version.as_major_tag(), + new_version.as_minor_tag(), + ): + project.git_tag( + tag_name=rolling_tag, + message=f"{rolling_tag} is {new_version.as_tag()}", + noop=opts.noop, + force=True, + ) + project.git_push_tag( + remote_url=remote_url, + tag=rolling_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 41ac02058..f82e81062 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -365,6 +365,7 @@ class RawConfig(BaseModel): remote: RemoteConfig = RemoteConfig() no_git_verify: bool = False tag_format: str = "v{version}" + rolling_tags: bool = False publish: PublishConfig = PublishConfig() version_toml: Optional[Tuple[str, ...]] = None version_variables: Optional[Tuple[str, ...]] = None @@ -826,7 +827,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, + rolling_tags=raw.rolling_tags, ) build_cmd_env = {} diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index ef174d85c..b96e81c88 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -197,7 +197,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) @@ -207,21 +212,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( @@ -238,7 +247,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 @@ -264,13 +273,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 ) ) @@ -278,7 +289,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 7a4ce275f..9c233df77 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -44,11 +44,17 @@ def __init__( self, tag_format: str = "v{version}", prerelease_token: str = "rc", # noqa: S107 + rolling_tags: bool = False, ) -> None: check_tag_format(tag_format) self.tag_format = tag_format self.prerelease_token = prerelease_token + self.rolling_tags = rolling_tags self.from_tag_re = self._invert_tag_format_to_re(self.tag_format) + self.rolling_tag_re = re.compile( + tag_format.replace(r"{version}", r"[0-9]+(\.(0|[1-9][0-9]*))?$"), + flags=re.VERBOSE, + ) def from_string(self, version_str: str) -> Version: """ @@ -71,6 +77,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.rolling_tags: + rolling_tag_match = self.rolling_tag_re.match(tag) + if rolling_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 41ec5e107..dbaf56319 100644 --- a/src/semantic_release/version/version.py +++ b/src/semantic_release/version/version.py @@ -206,6 +206,12 @@ 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_semver_tag(self) -> str: return f"v{self!s}" From fa69417746342d6be94d9749e1f8549aea87ae7c Mon Sep 17 00:00:00 2001 From: Matthieu Sarter Date: Sun, 8 Dec 2024 16:11:25 +0100 Subject: [PATCH 2/6] docs(configuration): add rolling_tag option --- docs/configuration.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/configuration.rst b/docs/configuration.rst index 78e20d183..2cf6bac33 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1156,6 +1156,25 @@ from the :ref:`remote.name ` location of your git repository ---- +.. _config-rolling_tags: + +``rolling_tags`` +"""""""""""""" + +**Type:** ``bool`` + +Specify if rolling tags should be handled when creating a new version. If set to +``true``, a major and a major.minor rolling tag will be created/updated, using the format +specified in :ref:`tag_format` + +For example, with tag format ``v{version}`` and ``rolling_tags`` set to ``true``, when +creating version ``1.2.3``, the tags ``v1`` and ``v1.2`` will be created/updated and point +to the same commit as the ``v1.2.3`` tag. + +**Default:** ``false`` + +---- + .. _config-tag_format: ``tag_format`` From bc3bfe28ca1fa0ca2cdb4488bb19ad09f2b1f411 Mon Sep 17 00:00:00 2001 From: Matthieu Sarter Date: Sun, 8 Dec 2024 21:26:35 +0100 Subject: [PATCH 3/6] refactor(config): rename rolling_tag option to `add_partial_tags` --- docs/configuration.rst | 16 ++++++++-------- src/semantic_release/cli/commands/version.py | 20 ++++++++++---------- src/semantic_release/cli/config.py | 4 ++-- src/semantic_release/version/translator.py | 12 ++++++------ 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 2cf6bac33..ed104ce2f 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1156,20 +1156,20 @@ from the :ref:`remote.name ` location of your git repository ---- -.. _config-rolling_tags: +.. _config-add_partial_tags: -``rolling_tags`` +``add_partial`` """""""""""""" **Type:** ``bool`` -Specify if rolling tags should be handled when creating a new version. If set to -``true``, a major and a major.minor rolling tag will be created/updated, using the format -specified in :ref:`tag_format` +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`. -For example, with tag format ``v{version}`` and ``rolling_tags`` set to ``true``, when -creating version ``1.2.3``, the tags ``v1`` and ``v1.2`` will be created/updated and point -to the same commit as the ``v1.2.3`` tag. +For example, with tag format ``v{version}`` and ``add_partial_tags`` set to ``true``, when +creating version ``1.2.3``, the tags ``v1`` and ``v1.2`` will be created or updated and +will point to the same commit as the ``v1.2.3`` tag. **Default:** ``false`` diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 5278d9b21..d78b764e1 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -75,12 +75,12 @@ def is_forced_prerelease( def last_released( - repo_dir: Path, tag_format: str, rolling_tags: bool = False + 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, rolling_tags=rolling_tags), + VersionTranslator(tag_format=tag_format, add_partial_tags=add_partial_tags), ) return ts_and_vs[0] if ts_and_vs else None @@ -457,7 +457,7 @@ def version( # noqa: C901 last_release := last_released( config.repo_dir, tag_format=config.tag_format, - rolling_tags=config.rolling_tags, + add_partial_tags=config.add_partial_tags, ) ): log.warning("No release tags found.") @@ -479,7 +479,7 @@ def version( # noqa: C901 major_on_zero = runtime.major_on_zero no_verify = runtime.no_git_verify opts = runtime.global_cli_options - rolling_tags = config.rolling_tags + add_partial_tags = config.add_partial_tags gha_output = VersionGitHubActionsOutput(released=False) forced_level_bump = None if not force_level else LevelBump.from_string(force_level) @@ -711,21 +711,21 @@ def version( # noqa: C901 tag=new_version.as_tag(), noop=opts.noop, ) - # Update rolling tags - if rolling_tags: - for rolling_tag in ( + # Create or update partial tags + if add_partial_tags: + for partial_tag in ( new_version.as_major_tag(), new_version.as_minor_tag(), ): project.git_tag( - tag_name=rolling_tag, - message=f"{rolling_tag} is {new_version.as_tag()}", + tag_name=partial_tag, + message=f"{partial_tag} is {new_version.as_tag()}", noop=opts.noop, force=True, ) project.git_push_tag( remote_url=remote_url, - tag=rolling_tag, + tag=partial_tag, noop=opts.noop, force=True, ) diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index f82e81062..d9e7e2eb1 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -365,7 +365,7 @@ class RawConfig(BaseModel): remote: RemoteConfig = RemoteConfig() no_git_verify: bool = False tag_format: str = "v{version}" - rolling_tags: bool = False + add_partial_tags: bool = False publish: PublishConfig = PublishConfig() version_toml: Optional[Tuple[str, ...]] = None version_variables: Optional[Tuple[str, ...]] = None @@ -829,7 +829,7 @@ def from_raw_config( # noqa: C901 version_translator = VersionTranslator( tag_format=raw.tag_format, prerelease_token=branch_config.prerelease_token, - rolling_tags=raw.rolling_tags, + add_partial_tags=raw.add_partial_tags, ) build_cmd_env = {} diff --git a/src/semantic_release/version/translator.py b/src/semantic_release/version/translator.py index 9c233df77..b88ef8704 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -44,14 +44,14 @@ def __init__( self, tag_format: str = "v{version}", prerelease_token: str = "rc", # noqa: S107 - rolling_tags: bool = False, + add_partial_tags: bool = False, ) -> None: check_tag_format(tag_format) self.tag_format = tag_format self.prerelease_token = prerelease_token - self.rolling_tags = rolling_tags + self.add_partial_tags = add_partial_tags self.from_tag_re = self._invert_tag_format_to_re(self.tag_format) - self.rolling_tag_re = re.compile( + self.partial_tag_re = re.compile( tag_format.replace(r"{version}", r"[0-9]+(\.(0|[1-9][0-9]*))?$"), flags=re.VERBOSE, ) @@ -77,9 +77,9 @@ def from_tag(self, tag: str) -> Version | None: tag_match = self.from_tag_re.match(tag) if not tag_match: return None - if self.rolling_tags: - rolling_tag_match = self.rolling_tag_re.match(tag) - if rolling_tag_match: + 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) From 445e2ebe94b55b6ca9ab93516dcd30db6fca86e7 Mon Sep 17 00:00:00 2001 From: Matthieu Sarter Date: Fri, 14 Mar 2025 00:43:54 +0100 Subject: [PATCH 4/6] fix(cmd-version): don't update partial_tags for non release --- docs/configuration.rst | 3 +++ src/semantic_release/cli/commands/version.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index ed104ce2f..388b47c14 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1171,6 +1171,9 @@ For example, with tag format ``v{version}`` and ``add_partial_tags`` set to ``tr creating version ``1.2.3``, the tags ``v1`` and ``v1.2`` will be created or updated and will point to the same commit as the ``v1.2.3`` tag. +The partial version tags will not be created or updated if the version is a not a release +(ie. no pre-release and/or build metadata). + **Default:** ``false`` ---- diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index d78b764e1..ea2f3f456 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -711,8 +711,8 @@ def version( # noqa: C901 tag=new_version.as_tag(), noop=opts.noop, ) - # Create or update partial tags - if add_partial_tags: + # Create or update partial tags for releases + if add_partial_tags and not (prerelease or build_metadata): for partial_tag in ( new_version.as_major_tag(), new_version.as_minor_tag(), @@ -720,6 +720,7 @@ def version( # noqa: C901 project.git_tag( tag_name=partial_tag, message=f"{partial_tag} is {new_version.as_tag()}", + isotimestamp=commit_date.isoformat(), noop=opts.noop, force=True, ) From fc1b68506a19e4daff496b0196c6d378e4c0f763 Mon Sep 17 00:00:00 2001 From: Matthieu Sarter Date: Wed, 19 Mar 2025 01:12:34 +0100 Subject: [PATCH 5/6] test(cmd-version): add test cases for partial tags creation and update --- .../cmd_version/test_version_partial_tag.py | 117 ++++++++++++++++++ 1 file changed, 117 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..a6acd8342 --- /dev/null +++ b/tests/e2e/cmd_version/test_version_partial_tag.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import tomlkit + +# Limitation in pytest-lazy-fixture - see https://stackoverflow.com/a/69884019 +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.cli.commands.main import main + +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 unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn + from tests.fixtures.git_repo import BuiltRepoResult + + + +@pytest.mark.parametrize( + "repo_result, cli_args, next_release_version, existing_partial_tags, expected_new_partial_tags, expected_moved_partial_tags", + [ + *( + ( + lazy_fixture(repo_w_no_tags_conventional_commits.__name__), + cli_args, + next_release_version, + existing_partial_tags, + expected_new_partial_tags, + expected_moved_partial_tags, + ) + for cli_args, next_release_version, existing_partial_tags, expected_new_partial_tags, expected_moved_partial_tags in ( + # metadata release or pre-release should not affect partial tags + (["--build-metadata", "build.12345"], "0.1.0+build.12345", ["v0", "v0.0"], [], []), + (["--prerelease"], "0.0.0-rc.1", ["v0", "v0.0"], [], []), + # Create partial tags when they don't exist + (["--patch"], "0.0.1", [], ["v0", "v0.0"], []), + (["--minor"], "0.1.0", [], ["v0", "v0.1"], []), + (["--major"], "1.0.0", [], ["v1", "v1.0"], []), + # Update existing partial tags + (["--patch"], "0.0.1", ["v0", "v0.0"], [], ["v0", "v0.0"]), + (["--minor"], "0.1.0", ["v0", "v0.0", "v0.1"], [], ["v0", "v0.1"]), + (["--major"], "1.0.0", ["v0", "v0.0", "v0.1", "v1", "v1.0"], [], ["v1", "v1.0"]), + # Update existing partial tags and create new one + (["--minor"], "0.1.0", ["v0", "v0.0"], ["v0.1"], ["v0"]), + ) + ) + ], +) +def test_version_partial_tag_creation( + repo_result: BuiltRepoResult, + cli_args: list[str], + next_release_version: str, + existing_partial_tags: list[str], + expected_new_partial_tags: list[str], + expected_moved_partial_tags: list[str], + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> None: + """Test that the version creates the expected partial tags.""" + repo = repo_result["repo"] + + # Setup: create existing tags + for tag in existing_partial_tags: + repo.create_tag(tag) + + # Setup: take measurement before running the version command + tags_before = {tag.name: repo.commit(tag) for tag in repo.tags} + + # Enable partial tags + update_pyproject_toml("tool.semantic_release.add_partial_tags", True) + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *cli_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + head_after = repo.head.commit + tags_after = {tag.name: repo.commit(tag) 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]} + # + # Evaluate (normal release actions should have occurred when forced patch bump) + assert_successful_exit_code(result, cli_cmd) + + # A version tag and the expected partial tag have been created + assert len(new_tags) == 1 + len(expected_new_partial_tags) + assert len(moved_tags) == len(expected_moved_partial_tags) + assert f"v{next_release_version}" in new_tags + for partial_tag in expected_new_partial_tags: + assert partial_tag in new_tags + for partial_tag in expected_moved_partial_tags: + assert partial_tag in moved_tags + + # Check that all new tags and moved tags are on the head commit + for tag, sha in {**new_tags, **moved_tags}.items(): + assert repo.commit(tag).hexsha == head_after.hexsha + + # 1 for commit, 1 for tag, 1 for each moved or created partial tag + assert mocked_git_push.call_count == 2 + len(expected_new_partial_tags) + len(expected_moved_partial_tags) + assert post_mocker.call_count == 1 # vcs release creation occurred From 8862eb997b4631cd5be7da6f11e55e31927ed219 Mon Sep 17 00:00:00 2001 From: Matthieu Sarter Date: Wed, 23 Apr 2025 23:53:50 +0200 Subject: [PATCH 6/6] fix(cmd-version): update partial tags (including patch) on release with build metadata --- docs/configuration.rst | 10 +- src/semantic_release/cli/commands/version.py | 12 ++- src/semantic_release/version/version.py | 3 + .../cmd_version/test_version_partial_tag.py | 99 ++++++++++++++++--- 4 files changed, 104 insertions(+), 20 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 388b47c14..af50bdf4b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1165,14 +1165,16 @@ from the :ref:`remote.name ` location of your git repository 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`. +specified in :ref:`tag_format`. If version has build metadata, a major.minor.patch tag +will also be created or updated. For example, with tag format ``v{version}`` and ``add_partial_tags`` set to ``true``, when creating version ``1.2.3``, the tags ``v1`` and ``v1.2`` will be created or updated and -will point to the same commit as the ``v1.2.3`` tag. +will point to the same commit as the ``v1.2.3`` tag. When creating version ``1.2.3+build.1234``, +the tags ``v1``, ``v1.2`` and ``v1.2.3`` will be created or updated and will point to the +same commit as the ``v1.2.3+build.1234`` tag. -The partial version tags will not be created or updated if the version is a not a release -(ie. no pre-release and/or build metadata). +The partial version tags will not be created or updated if the version is a pre-release. **Default:** ``false`` diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index ea2f3f456..cabe5eef2 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -712,11 +712,13 @@ def version( # noqa: C901 noop=opts.noop, ) # Create or update partial tags for releases - if add_partial_tags and not (prerelease or build_metadata): - for partial_tag in ( - new_version.as_major_tag(), - new_version.as_minor_tag(), - ): + 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} is {new_version.as_tag()}", diff --git a/src/semantic_release/version/version.py b/src/semantic_release/version/version.py index dbaf56319..5bd17f84c 100644 --- a/src/semantic_release/version/version.py +++ b/src/semantic_release/version/version.py @@ -212,6 +212,9 @@ def as_major_tag(self) -> str: 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}" diff --git a/tests/e2e/cmd_version/test_version_partial_tag.py b/tests/e2e/cmd_version/test_version_partial_tag.py index a6acd8342..19dd3468f 100644 --- a/tests/e2e/cmd_version/test_version_partial_tag.py +++ b/tests/e2e/cmd_version/test_version_partial_tag.py @@ -30,7 +30,6 @@ from tests.fixtures.git_repo import BuiltRepoResult - @pytest.mark.parametrize( "repo_result, cli_args, next_release_version, existing_partial_tags, expected_new_partial_tags, expected_moved_partial_tags", [ @@ -45,16 +44,35 @@ ) for cli_args, next_release_version, existing_partial_tags, expected_new_partial_tags, expected_moved_partial_tags in ( # metadata release or pre-release should not affect partial tags - (["--build-metadata", "build.12345"], "0.1.0+build.12345", ["v0", "v0.0"], [], []), (["--prerelease"], "0.0.0-rc.1", ["v0", "v0.0"], [], []), # Create partial tags when they don't exist + ( + ["--build-metadata", "build.12345"], + "0.1.0+build.12345", + [], + ["v0", "v0.1", "v0.1.0"], + [], + ), (["--patch"], "0.0.1", [], ["v0", "v0.0"], []), (["--minor"], "0.1.0", [], ["v0", "v0.1"], []), (["--major"], "1.0.0", [], ["v1", "v1.0"], []), # Update existing partial tags + ( + ["--build-metadata", "build.12345"], + "0.1.0+build.12345", + ["v0", "v0.0", "v0.1", "v0.1.0"], + [], + ["v0", "v0.1", "v0.1.0"], + ), (["--patch"], "0.0.1", ["v0", "v0.0"], [], ["v0", "v0.0"]), (["--minor"], "0.1.0", ["v0", "v0.0", "v0.1"], [], ["v0", "v0.1"]), - (["--major"], "1.0.0", ["v0", "v0.0", "v0.1", "v1", "v1.0"], [], ["v1", "v1.0"]), + ( + ["--major"], + "1.0.0", + ["v0", "v0.0", "v0.1", "v1", "v1.0"], + [], + ["v1", "v1.0"], + ), # Update existing partial tags and create new one (["--minor"], "0.1.0", ["v0", "v0.0"], ["v0.1"], ["v0"]), ) @@ -65,6 +83,8 @@ def test_version_partial_tag_creation( repo_result: BuiltRepoResult, 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], @@ -74,17 +94,38 @@ def test_version_partial_tag_creation( update_pyproject_toml: UpdatePyprojectTomlFn, ) -> None: """Test that the version creates the expected partial tags.""" + # Enable partial tags + update_pyproject_toml("tool.semantic_release.add_partial_tags", True) + repo = repo_result["repo"] + version_file = example_project_dir.joinpath( + "src", EXAMPLE_PROJECT_NAME, "_version.py" + ) + expected_changed_files = sorted( + [ + "CHANGELOG.md", + "pyproject.toml", + str(version_file.relative_to(example_project_dir)), + ] + ) # 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} + version_py_before = dynamic_python_import( + version_file, f"{EXAMPLE_PROJECT_NAME}._version" + ).__version__ - # Enable partial tags - update_pyproject_toml("tool.semantic_release.add_partial_tags", True) + 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") # type: ignore[attr-defined] # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *cli_args] @@ -94,24 +135,60 @@ def test_version_partial_tag_creation( head_after = repo.head.commit tags_after = {tag.name: repo.commit(tag) 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]} + moved_tags = { + tag: sha + for tag, sha in tags_after.items() + if tag in tags_before and sha != tags_before[tag] + } + differing_files = [ + # 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) + version_py_after = dynamic_python_import( + version_file, f"{EXAMPLE_PROJECT_NAME}._version" + ).__version__ + # # 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 len(new_tags) == 1 + len(expected_new_partial_tags) assert len(moved_tags) == len(expected_moved_partial_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 - - # Check that all new tags and moved tags are on the head commit - for tag, sha in {**new_tags, **moved_tags}.items(): - assert repo.commit(tag).hexsha == head_after.hexsha + assert repo.commit(partial_tag).hexsha == head_after.hexsha # 1 for commit, 1 for tag, 1 for each moved or created partial tag - assert mocked_git_push.call_count == 2 + len(expected_new_partial_tags) + len(expected_moved_partial_tags) + assert mocked_git_push.call_count == 2 + len(expected_new_partial_tags) + len( + expected_moved_partial_tags + ) assert post_mocker.call_count == 1 # 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 next_release_version == version_py_after + assert version_py_before != version_py_after