diff --git a/docs/configuration.rst b/docs/configuration.rst index 78e20d183..af50bdf4b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1156,6 +1156,30 @@ from the :ref:`remote.name ` location of your git repository ---- +.. _config-add_partial_tags: + +``add_partial`` +"""""""""""""" + +**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. + +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. 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 pre-release. + +**Default:** ``false`` + +---- + .. _config-tag_format: ``tag_format`` diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 86d209937..cabe5eef2 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, 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 @@ -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, + add_partial_tags=config.add_partial_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 + 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) @@ -703,6 +711,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} is {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 41ac02058..d9e7e2eb1 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}" + add_partial_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, + 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 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..b88ef8704 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 + 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 = 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.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 41ec5e107..5bd17f84c 100644 --- a/src/semantic_release/version/version.py +++ b/src/semantic_release/version/version.py @@ -206,6 +206,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}" 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..19dd3468f --- /dev/null +++ b/tests/e2e/cmd_version/test_version_partial_tag.py @@ -0,0 +1,194 @@ +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 + (["--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"], + ), + # 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, + 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], + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, + 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__ + + 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] + 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] + } + 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 + 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 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