diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 458f1a5ee..f175879c6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,93 @@ CHANGELOG ========= +.. _changelog-v10.5.0: + +v10.5.0 (2025-11-09) +==================== + +✨ Features +----------- + +* **cmd-version**: Add automatic repository un-shallowing to version workflow (`PR#1366`_, + `90a1ffa`_) + +* **cmd-version**: Add functionality to create & update partial version tags (`PR#1115`_, + `a28f940`_) + +* **cmd-version**: Adds c-macro style version definition support to ``version_variables``, closes + `#1348`_ (`PR#1349`_, `4ce1fca`_) + +* **cmd-version**: Adds upstream check into workflow to prevent commit push collisions (`PR#1360`_, + `d77193e`_) + +🪲 Bug Fixes +------------ + +* **cmd-version**: Prevent regular expression errors on ``tag_format`` (`PR#1367`_, `e7d7aa7`_) + +📖 Documentation +---------------- + +* **commands**: Add description of automated upstream version checking upon version creation + (`PR#1360`_, `d77193e`_) + +* **configuration**: Add description for ``add_partial_tags`` setting & usage examples (`PR#1115`_, + `a28f940`_) + +* **configuration**: Fix ``tag_format`` definition (`PR#1367`_, `e7d7aa7`_) + +* **configuration**: Update ``version_variables`` examples with a c-macro style replacement + (`PR#1349`_, `4ce1fca`_) + +* **github-actions**: Adds release job outputs definition to example (`PR#1344`_, `0fb4875`_) + +* **github-actions**: Removed verify upstream status step from example workflow (`PR#1360`_, + `d77193e`_) + +* **github-actions**: Update example to remove need to specify repo checkout's fetch depth + (`PR#1366`_, `90a1ffa`_) + +* **uv-integration**: Remove verify upstream check from uv integration example (`PR#1360`_, + `d77193e`_) + +* **uv-integration**: Update example to remove need to specify repo checkout's fetch depth + (`PR#1366`_, `90a1ffa`_) + +⚙️ Build System +---------------- + +* **deps**: Bump ``tomlkit`` dependency from ~=0.11.0 to ~=0.13.0 (`PR#1355`_, `55c94ec`_) + +* **deps**: Change github-actions container image to ``python:3.14-slim-trixie`` (`PR#1346`_, + `1a23712`_) + +💡 Additional Release Information +--------------------------------- + +* **cmd-version**: If you were previously handling the unshallowing of a repository clone in your + CI/CD pipelines, you may now remove that step from your workflow. PSR will now detect a shallow + repository and unshallow it before evaluating the commit history. + +.. _#1348: https://github.com/python-semantic-release/python-semantic-release/issues/1348 +.. _0fb4875: https://github.com/python-semantic-release/python-semantic-release/commit/0fb4875fa24ed283ed2d97ff6ab1879669a787ca +.. _1a23712: https://github.com/python-semantic-release/python-semantic-release/commit/1a237125badcb597ae7a92db4e01c2ff3293bce8 +.. _4ce1fca: https://github.com/python-semantic-release/python-semantic-release/commit/4ce1fcac60ac73657a4aaaaa3cb7c4afc7eac2c1 +.. _55c94ec: https://github.com/python-semantic-release/python-semantic-release/commit/55c94ecde1aec47b88aa172d031ab33afa7f795d +.. _90a1ffa: https://github.com/python-semantic-release/python-semantic-release/commit/90a1ffa55c5a1605c59cb26a1797f9a37fdfa784 +.. _a28f940: https://github.com/python-semantic-release/python-semantic-release/commit/a28f9401c4b285aa1007b72eb051d42567f33f93 +.. _d77193e: https://github.com/python-semantic-release/python-semantic-release/commit/d77193e30807968ba6a26bd356a868db62dc1098 +.. _e7d7aa7: https://github.com/python-semantic-release/python-semantic-release/commit/e7d7aa74a216cd2fdd78afc1e0e8b6b8044954ec +.. _PR#1115: https://github.com/python-semantic-release/python-semantic-release/pull/1115 +.. _PR#1344: https://github.com/python-semantic-release/python-semantic-release/pull/1344 +.. _PR#1346: https://github.com/python-semantic-release/python-semantic-release/pull/1346 +.. _PR#1349: https://github.com/python-semantic-release/python-semantic-release/pull/1349 +.. _PR#1355: https://github.com/python-semantic-release/python-semantic-release/pull/1355 +.. _PR#1360: https://github.com/python-semantic-release/python-semantic-release/pull/1360 +.. _PR#1366: https://github.com/python-semantic-release/python-semantic-release/pull/1366 +.. _PR#1367: https://github.com/python-semantic-release/python-semantic-release/pull/1367 + + .. _changelog-v10.4.1: v10.4.1 (2025-09-13) diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index cf8610c10..ed9c2d3e0 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -893,14 +893,14 @@ to the GitHub Release Assets as well. - name: Action | Semantic Version Release id: release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v10.4.1 + uses: python-semantic-release/python-semantic-release@v10.5.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} git_committer_name: "github-actions" git_committer_email: "actions@users.noreply.github.com" - name: Publish | Upload to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.4.1 + uses: python-semantic-release/publish-action@v10.5.0 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -969,7 +969,7 @@ to the GitHub Release Assets as well. the ``token`` input) in order to gain push access. .. note:: - As of $NEW_RELEASE_TAG, Python Semantic Release automatically detects and converts + As of v10.5.0, Python Semantic Release automatically detects and converts shallow clones to full clones when needed. While you can still use ``fetch-depth: 0`` with ``actions/checkout@v4`` to fetch the full history upfront, it is no longer required. If you use the default shallow clone, Python Semantic Release will @@ -977,7 +977,7 @@ to the GitHub Release Assets as well. an older version of PSR, you will need to unshallow the repository prior to use. .. note:: - As of $NEW_RELEASE_TAG, the verify upstream step is no longer required as it has been + As of v10.5.0, the verify upstream step is no longer required as it has been integrated into PSR directly. If you are using an older version of PSR, you will need to review the older documentation for that step. See Issue `#1201`_ for more details. @@ -1005,7 +1005,7 @@ The equivalent GitHub Action configuration would be: - name: Action | Semantic Version Release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v10.4.1 + uses: python-semantic-release/python-semantic-release@v10.5.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} force: patch @@ -1064,14 +1064,14 @@ Publish Action. - name: Release submodule 1 id: release-submod-1 - uses: python-semantic-release/python-semantic-release@v10.4.1 + uses: python-semantic-release/python-semantic-release@v10.5.0 with: directory: ${{ env.SUBMODULE_1_DIR }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Release submodule 2 id: release-submod-2 - uses: python-semantic-release/python-semantic-release@v10.4.1 + uses: python-semantic-release/python-semantic-release@v10.5.0 with: directory: ${{ env.SUBMODULE_2_DIR }} github_token: ${{ secrets.GITHUB_TOKEN }} @@ -1083,7 +1083,7 @@ Publish Action. # ------------------------------------------------------------------- # - name: Publish | Upload package 1 to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.4.1 + uses: python-semantic-release/publish-action@v10.5.0 if: steps.release-submod-1.outputs.released == 'true' with: directory: ${{ env.SUBMODULE_1_DIR }} @@ -1091,7 +1091,7 @@ Publish Action. tag: ${{ steps.release-submod-1.outputs.tag }} - name: Publish | Upload package 2 to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.4.1 + uses: python-semantic-release/publish-action@v10.5.0 if: steps.release-submod-2.outputs.released == 'true' with: directory: ${{ env.SUBMODULE_2_DIR }} diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index cdd3604fb..690a3e4c3 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`` @@ -1170,17 +1216,8 @@ from the :ref:`remote.name ` location of your git repository **Type:** ``str`` Specify the format to be used for the Git tag that will be added to the repo during -a release invoked via :ref:`cmd-version`. The format string is a regular expression, -which also must include the format keys below, otherwise an exception will be thrown. -It *may* include any of the optional format keys, in which case the contents -described will be formatted into the specified location in the Git tag that is created. - -For example, ``"(dev|stg|prod)-v{version}"`` is a valid ``tag_format`` matching tags such -as: - -- ``dev-v1.2.3`` -- ``stg-v0.1.0-rc.1`` -- ``prod-v2.0.0+20230701`` +a release invoked via :ref:`cmd-version`. The string is used as a template for the tag +name, and must include the ``{version}`` format key. This format will also be used for parsing tags already present in the repository into semantic versions; therefore if the tag format changes at some point in the @@ -1196,6 +1233,13 @@ Format Key Mandatory Contents Tags which do not match this format will not be considered as versions of your project. +This is critical for Monorepo projects where the tag format defines which package the +version tag belongs to. Generally, the tag format for each package of the monorepo will +include the package name as the prefix of the tag format. For example, if the package +is named ``pkg1``, the tag format would be ``pkg1-v{version}`` and in the other package +``pkg2``, the tag format would be ``pkg2-v{version}``. This allows PSR to determine +which tags to use to determine the version for each package. + **Default:** ``"v{version}"`` ---- @@ -1354,7 +1398,7 @@ The regular expression generated from the ``version_variables`` definition will: 2. The variable name defined by ``variable`` and the version must be separated by an operand symbol (``=``, ``:``, ``:=``, or ``@``). Whitespace is optional around the symbol. As of v10.0.0, a double-equals (``==``) operator is also supported - as a valid operand symbol. As of $NEW_RELEASE_TAG, PSR can omit all operands as long + as a valid operand symbol. As of v10.5.0, PSR can omit all operands as long as there is at least one whitespace character between the variable name and the version. 3. The value of the variable must match a `SemVer`_ regular expression and can be diff --git a/pyproject.toml b/pyproject.toml index 259975c08..be6f4ac91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-semantic-release" -version = "10.4.1" +version = "10.5.0" description = "Automatic Semantic Versioning for Python projects" requires-python = "~= 3.8" license = { text = "MIT" } diff --git a/src/gh_action/requirements.txt b/src/gh_action/requirements.txt index c624d98e8..65e627774 100644 --- a/src/gh_action/requirements.txt +++ b/src/gh_action/requirements.txt @@ -1 +1 @@ -python-semantic-release == 10.4.1 +python-semantic-release == 10.5.0 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 6340701da..5026fd089 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -1,12 +1,16 @@ from __future__ import annotations -import re +from re import VERBOSE, compile as regexp, escape as regex_escape +from typing import TYPE_CHECKING from semantic_release.const import SEMVER_REGEX from semantic_release.globals import logger from semantic_release.helpers import check_tag_format from semantic_release.version.version import Version +if TYPE_CHECKING: + from re import Pattern + class VersionTranslator: """ @@ -17,7 +21,7 @@ class VersionTranslator: _VERSION_REGEX = SEMVER_REGEX @classmethod - def _invert_tag_format_to_re(cls, tag_format: str) -> re.Pattern[str]: + def _invert_tag_format_to_re(cls, tag_format: str) -> Pattern[str]: r""" Unpick the "tag_format" format string and create a regex which can be used to convert a tag to a version string. @@ -31,9 +35,11 @@ def _invert_tag_format_to_re(cls, tag_format: str) -> re.Pattern[str]: >>> assert m is not None >>> assert m.expand(r"\g") == version """ - pat = re.compile( - tag_format.replace(r"{version}", r"(?P.*)"), - flags=re.VERBOSE, + pat = regexp( + regex_escape(tag_format).replace( + regex_escape(r"{version}"), r"(?P.+)" + ), + flags=VERBOSE, ) logger.debug("inverted tag_format %r to %r", tag_format, pat.pattern) return pat @@ -42,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: """ @@ -69,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}" 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 diff --git a/tests/unit/semantic_release/version/test_algorithm.py b/tests/unit/semantic_release/version/test_algorithm.py index a7fded2ef..f470e78ce 100644 --- a/tests/unit/semantic_release/version/test_algorithm.py +++ b/tests/unit/semantic_release/version/test_algorithm.py @@ -155,56 +155,49 @@ def test_sorted_repo_tags_and_versions(tags: list[str], sorted_tags: list[str]): @pytest.mark.parametrize( "tag_format, invalid_tags, valid_tags", [ - ( - "v{version}", - ("test-v1.1.0", "v1.1.0-test-test"), - [ - "v1.0.0-rc.1", - "v1.0.0-beta.2", - "v1.0.0-beta.11", - "v1.0.0-alpha.1", - "v1.0.0-alpha.beta.1", - "v1.0.0", - ], - ), - ( - "v{version}", - ("0.3", "0.4"), - [ - "v1.0.0-rc.1", - "v1.0.0-beta.2", - "v1.0.0-beta.11", - "v1.0.0-alpha.1", - "v1.0.0-alpha.beta.1", - "v1.0.0", - ], - ), - ( - r"(\w+--)?v{version}", - ("v1.1.0-test-test", "test_v1.1.0"), - [ - "v1.0.0-rc.1", - "test--v1.1.0", - "v1.0.0-beta.2", - "v1.0.0-beta.11", - "v1.0.0-alpha.1", - "v1.0.0-alpha.beta.1", - "v1.0.0", - ], - ), - ( - r"(?Pfeature|fix)/v{version}--(?Pdev|stg|prod)", - ("v1.1.0--test", "test_v1.1.0", "docs/v1.2.0--dev"), - [ - "feature/v1.0.0-rc.1--dev", - "fix/v1.1.0--stg", - "feature/v1.0.0-beta.2--stg", - "fix/v1.0.0-beta.11--dev", - "fix/v1.0.0-alpha.1--dev", - "feature/v1.0.0-alpha.beta.1--dev", - "feature/v1.0.0--prod", - ], - ), + pytest.param( + tag_format, + invalid_tags, + valid_tags, + id=test_id, + ) + for test_id, tag_format, invalid_tags, valid_tags in [ + ( + "traditional-v-prefixed-versions", + "v{version}", + ( + "0.3", # no v-prefix + "test-v1.1.0", # extra prefix + "v1.1.0-test-test", # bad suffix + ), + [ + "v1.0.0-rc.1", + "v1.0.0-beta.2", + "v1.0.0-beta.11", + "v1.0.0-alpha.1", + "v1.0.0-alpha.beta.1", + "v1.0.0", + ], + ), + ( + "monorepo-style-versions", + "pkg1-v{version}", + ( + "0.3", # no pkg or version prefix + "v1.1.0", # no pkg prefix + "pkg1-v1.1.0-test-test", # bad suffix + "pkg2-v1.1.0", # wrong package prefix + ), + [ + "pkg1-v1.0.0-rc.1", + "pkg1-v1.0.0-beta.2", + "pkg1-v1.0.0-beta.11", + "pkg1-v1.0.0-alpha.1", + "pkg1-v1.0.0-alpha.beta.1", + "pkg1-v1.0.0", + ], + ), + ] ], ) def test_tags_and_versions_ignores_invalid_tags_as_versions(