From 7454164453cafc44086db685f56e949ddec9d5b5 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 d3aed80f3..2ba75306c 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -71,10 +71,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 @@ -445,7 +448,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, + ) ): logger.warning("No release tags found.") return @@ -466,6 +473,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) @@ -698,6 +706,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 1d2057a48..6318dbb37 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -364,6 +364,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 @@ -825,7 +826,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 0e4592599..7d7752105 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 6340701da..53b609307 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -42,11 +42,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: """ @@ -69,6 +75,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 032596e4a..dbe150ddb 100644 --- a/src/semantic_release/version/version.py +++ b/src/semantic_release/version/version.py @@ -203,6 +203,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 cc54f66efa9d811cb029cca08b28c5bdeb23fc30 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/configuration.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index 08368b337..a75e61152 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1160,6 +1160,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 462129aa35cd93e71897a5a56bfaaa26c23e87cd 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/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/configuration.rst b/docs/configuration/configuration.rst index a75e61152..08b9ad2ab 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1160,20 +1160,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 2ba75306c..26b49b502 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -72,12 +72,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 @@ -451,7 +451,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, ) ): logger.warning("No release tags found.") @@ -473,7 +473,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) @@ -706,21 +706,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 6318dbb37..09cb6178a 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -364,7 +364,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 @@ -828,7 +828,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 53b609307..2351cee81 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -42,14 +42,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, ) @@ -75,9 +75,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 348d073731815eded9b91c2edfa1b95e94d4b91d 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/configuration.rst | 3 +++ src/semantic_release/cli/commands/version.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index 08b9ad2ab..44a6d6948 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1175,6 +1175,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 26b49b502..6bcc32dbd 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -706,8 +706,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(), @@ -715,6 +715,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 a76bad721daba9c2c0f6963e9c9bc5d5e3f57ea6 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 c4fa3c4a97b002b64f9ae8424fc974043973046a 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/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/configuration.rst b/docs/configuration/configuration.rst index 44a6d6948..b0e683728 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1169,14 +1169,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 6bcc32dbd..07f28e3ba 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -707,11 +707,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 dbe150ddb..3e97be9fe 100644 --- a/src/semantic_release/version/version.py +++ b/src/semantic_release/version/version.py @@ -209,6 +209,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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy