diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cca7e057a..0bc1d46a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,11 @@ on: # default token permissions = none permissions: {} +# If a new push is made to the branch, cancel the previous run +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: commitlint: diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index 10ca24bd5..e9a254978 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -127,15 +127,11 @@ def _increment_version( Using the given versions, along with a given `level_bump`, increment to the next version according to whether or not this is a prerelease. - `latest_version`, `latest_full_version` and `latest_full_version_in_history` - can be the same, but aren't necessarily. - `latest_version` is the most recent version released from this branch's history. - `latest_full_version` is the most recent full release (i.e. not a prerelease) - anywhere in the repository's history, including commits which aren't present on - this branch. - `latest_full_version_in_history`, correspondingly, is the latest full release which - is in this branch's history. + `latest_full_version`, the most recent full release (i.e. not a prerelease) + in this branch's history. + + `latest_version` and `latest_full_version` can be the same, but aren't necessarily. """ local_vars = list(locals().items()) logger.log( @@ -165,74 +161,93 @@ def _increment_version( level_bump = min(level_bump, LevelBump.MINOR) - # Determine the difference between the latest version and the latest full release - diff_with_last_released_version = latest_version - latest_full_version logger.debug( - "diff between the latest version %s and the latest full release version %s is: %s", + "prerelease=%s and the latest version %s %s prerelease", + prerelease, latest_version, - latest_full_version, - diff_with_last_released_version, + "is a" if latest_version.is_prerelease else "is not a", ) - # Handle prerelease version bumps - if prerelease: - # 6a i) if the level_bump > the level bump introduced by any prerelease tag - # before e.g. 1.2.4-rc.3 -> 1.3.0-rc.1 - if level_bump > diff_with_last_released_version: - logger.debug( - "this release has a greater bump than any change since the last full release, %s", - latest_full_version, - ) - return ( - latest_full_version.finalize_version() - .bump(level_bump) - .to_prerelease(token=prerelease_token) - ) + if level_bump == LevelBump.NO_RELEASE: + raise ValueError("level_bump must be at least PRERELEASE_REVISION") - # 6a ii) if level_bump <= the level bump introduced by prerelease tag - logger.debug( - "there has already been at least a %s release since the last full release %s", - level_bump, - latest_full_version, - ) - logger.debug("this release will increment the prerelease revision") - return latest_version.to_prerelease( - token=prerelease_token, - revision=( - 1 - if latest_version.prerelease_token != prerelease_token - else (latest_version.prerelease_revision or 0) + 1 - ), + if level_bump == LevelBump.PRERELEASE_REVISION and not latest_version.is_prerelease: + raise ValueError( + "Cannot increment a non-prerelease version with a prerelease level bump" ) - # 6b. if not prerelease - # NOTE: These can actually be condensed down to the single line - # 6b. i) if there's been a prerelease + # assume we always want to increment the version that is the latest in the branch's history + base_version = latest_version + + # if the current version is a prerelease & we want a new prerelease, then + # figure out if we need to bump the prerelease revision or start a new prerelease if latest_version.is_prerelease: + # find the change since the last full release because if the current version is a prerelease + # then we need to predict properly the next full version + diff_with_last_released_version = latest_version - latest_full_version logger.debug( - "prerelease=false and the latest version %s is a prerelease", latest_version + "the diff b/w the latest version '%s' and the latest full release version '%s' is: %s", + latest_version, + latest_full_version, + diff_with_last_released_version, ) - if level_bump > diff_with_last_released_version: - logger.debug( - "this release has a greater bump than any change since the last full release, %s", - latest_full_version, - ) - return latest_version.bump(level_bump).finalize_version() + # Since the difference is less than or equal to the level bump and we want a new prerelease, + # we can abort early and just increment the revision + if level_bump <= diff_with_last_released_version: + # 6a ii) if level_bump <= the level bump introduced by the previous tag (latest_version) + if prerelease: + logger.debug( + "there has already been at least a %s release since the last full release %s", + level_bump, + latest_full_version, + ) + logger.debug("Incrementing the prerelease revision...") + new_revision = base_version.to_prerelease( + token=prerelease_token, + revision=( + 1 + if latest_version.prerelease_token != prerelease_token + else (latest_version.prerelease_revision or 0) + 1 + ), + ) + logger.debug("Incremented %s to %s", base_version, new_revision) + return new_revision + + # When we don't want a prerelease, but the previous version is a prerelease that + # had a greater bump than we currently are applying, choose the larger bump instead + # as it consumes this bump + logger.debug("Finalizing the prerelease version...") + return base_version.finalize_version() + + # Fallthrough to handle all larger level bumps logger.debug( - "there has already been at least a %s release since the last full release %s", - level_bump, + "this release has a greater bump than any change since the last full release, %s", latest_full_version, ) - return latest_version.finalize_version() - # 6b. ii) If there's been no prerelease - logger.debug( - "prerelease=false and %s is not a prerelease; bumping with a %s release", - latest_version, - level_bump, + # Fallthrough, if we don't want a prerelease, or if we do but the level bump is greater + # + # because the current version is a prerelease, we must start from the last full version + # Case 1: we identified that the level bump is greater than the change since + # the last full release, this will also reset the prerelease revision + # Case 2: we don't want a prerelease, so consider only the last full version in history + base_version = latest_full_version + + # From the base version, we can now increment the version according to the level bump + # regardless of the prerelease status as bump() handles the reset and pass through + logger.debug("Bumping %s with a %s bump", base_version, level_bump) + target_next_version = base_version.bump(level_bump) + + # Converting to/from a prerelease if necessary + target_next_version = ( + target_next_version.to_prerelease(token=prerelease_token) + if prerelease + else target_next_version.finalize_version() ) - return latest_version.bump(level_bump) + + logger.debug("Incremented %s to %s", base_version, target_next_version) + return target_next_version def next_version( diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py new file mode 100644 index 000000000..236b22209 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + DEFAULT_BRANCH_NAME, + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.trunk_based_dev import ( + repo_w_trunk_only_dual_version_spt_angular_commits, + repo_w_trunk_only_dual_version_spt_emoji_commits, + repo_w_trunk_only_dual_version_spt_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_trunk_only_dual_version_spt_angular_commits.__name__, + *[ + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_trunk_only_dual_version_spt_emoji_commits.__name__, + repo_w_trunk_only_dual_version_spt_scipy_commits.__name__, + ] + ], + ], +) +def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( + repo_fixture_name: str, + cli_runner: CliRunner, + build_trunk_only_repo_w_dual_version_support: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_trunk_only_repo_w_dual_version_support( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + head_reference_name = ( + curr_release_tag + if curr_release_tag != "Unreleased" + else DEFAULT_BRANCH_NAME + ) + target_git_repo.git.checkout(head_reference_name, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py new file mode 100644 index 000000000..40981b8fe --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + DEFAULT_BRANCH_NAME, + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.trunk_based_dev import ( + repo_w_trunk_only_dual_version_spt_w_prereleases_angular_commits, + repo_w_trunk_only_dual_version_spt_w_prereleases_emoji_commits, + repo_w_trunk_only_dual_version_spt_w_prereleases_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_trunk_only_dual_version_spt_w_prereleases_angular_commits.__name__, + *[ + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_trunk_only_dual_version_spt_w_prereleases_emoji_commits.__name__, + repo_w_trunk_only_dual_version_spt_w_prereleases_scipy_commits.__name__, + ] + ], + ], +) +def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( + repo_fixture_name: str, + cli_runner: CliRunner, + build_trunk_only_repo_w_dual_version_spt_w_prereleases: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_trunk_only_repo_w_dual_version_spt_w_prereleases( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + head_reference_name = ( + curr_release_tag + if curr_release_tag != "Unreleased" + else DEFAULT_BRANCH_NAME + ) + target_git_repo.git.checkout(head_reference_name, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + prerelease_args = ( + [ + "--as-prerelease", + "--prerelease-token", + ( + release_action_step["details"]["version"] + .split("-", maxsplit=1)[-1] + .split(".", maxsplit=1)[0] + ), + ] + if len(release_action_step["details"]["version"].split("-", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [ + MAIN_PROG_NAME, + "--strict", + VERSION_SUBCMD, + *build_metadata_args, + *prerelease_args, + ] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index b5c872007..3f3fab0f6 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -13,6 +13,7 @@ from git import Actor, Repo from semantic_release.cli.config import ChangelogOutputFormat +from semantic_release.version.version import Version import tests.conftest import tests.const @@ -85,8 +86,7 @@ class RepoVersionDef(TypedDict): commits: list[CommitDef] class BaseAccumulatorVersionReduction(TypedDict): - limit_value: str - limit_found: bool + version_limit: Version repo_def: RepoDefinition class ChangelogTypeHeadingDef(TypedDict): @@ -305,6 +305,7 @@ class RepoActionWriteChangelogs(TypedDict): class RepoActionWriteChangelogsDetails(DetailsBase): new_version: str + max_version: NotRequired[str] dest_files: Sequence[RepoActionWriteChangelogsDestFile] class RepoActionWriteChangelogsDestFile(TypedDict): @@ -1138,6 +1139,7 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c dest_file=repo_dir.joinpath(changelog_file_def["path"]), output_format=changelog_file_def["format"], mask_initial_release=mask_initial_release, + max_version=w_chlgs_def.get("max_version", None), ) elif action == RepoActionStep.RELEASE: @@ -1311,12 +1313,22 @@ def _split_repo_actions_by_release_tags( curr_release_tag = "Unreleased" releasetags_2_steps[curr_release_tag] = [] + # Run filter on any non-action steps of Unreleased + releasetags_2_steps["Unreleased"] = list( + filter( + lambda step: step["action"] != RepoActionStep.GIT_CHECKOUT, + releasetags_2_steps["Unreleased"], + ) + ) + + # Remove Unreleased if there are no steps in an Unreleased section if ( "Unreleased" in releasetags_2_steps and not releasetags_2_steps["Unreleased"] ): del releasetags_2_steps["Unreleased"] + # Return all actions split up by release tags return releasetags_2_steps return _split_repo_actions_by_release_tags @@ -1331,13 +1343,11 @@ def simulate_default_changelog_creation( # noqa: C901 def reduce_repo_def( acc: BaseAccumulatorVersionReduction, ver_2_def: tuple[str, RepoVersionDef] ) -> BaseAccumulatorVersionReduction: - if acc["limit_found"]: - return acc + version_str, version_def = ver_2_def - if ver_2_def[0] == acc["limit_value"]: - acc["limit_found"] = True + if Version.parse(version_str) <= acc["version_limit"]: + acc["repo_def"][version_str] = version_def - acc["repo_def"][ver_2_def[0]] = ver_2_def[1] return acc def build_version_entry_markdown( @@ -1673,8 +1683,7 @@ def _mimic_semantic_release_default_changelog( reduce_repo_def, # type: ignore[arg-type] repo_definition.items(), { - "limit_value": max_version, - "limit_found": False, + "version_limit": Version.parse(max_version), "repo_def": {}, }, )["repo_def"] diff --git a/tests/fixtures/repos/trunk_based_dev/__init__.py b/tests/fixtures/repos/trunk_based_dev/__init__.py index 9e40a0157..ac64e2dbe 100644 --- a/tests/fixtures/repos/trunk_based_dev/__init__.py +++ b/tests/fixtures/repos/trunk_based_dev/__init__.py @@ -1,3 +1,5 @@ +from tests.fixtures.repos.trunk_based_dev.repo_w_dual_version_support import * +from tests.fixtures.repos.trunk_based_dev.repo_w_dual_version_support_w_prereleases import * from tests.fixtures.repos.trunk_based_dev.repo_w_no_tags import * from tests.fixtures.repos.trunk_based_dev.repo_w_prereleases import * from tests.fixtures.repos.trunk_based_dev.repo_w_tags import * diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py new file mode 100644 index 000000000..3353024c0 --- /dev/null +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +from datetime import timedelta +from itertools import count +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from semantic_release.cli.config import ChangelogOutputFormat + +import tests.conftest +import tests.const +import tests.util +from tests.const import ( + DEFAULT_BRANCH_NAME, + EXAMPLE_HVCS_DOMAIN, + INITIAL_COMMIT_MESSAGE, + RepoActionStep, +) + +if TYPE_CHECKING: + from typing import Sequence + + from tests.conftest import ( + GetCachedRepoDataFn, + GetMd5ForSetOfFilesFn, + GetStableDateNowFn, + ) + from tests.fixtures.example_project import ( + ExProjectDir, + ) + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildRepoOrCopyCacheFn, + BuildSpecificRepoFn, + BuiltRepoResult, + CommitConvention, + ConvertCommitSpecsToCommitDefsFn, + ExProjectGitRepoFn, + GetRepoDefinitionFn, + RepoActions, + RepoActionWriteChangelogsDestFile, + TomlSerializableTypes, + ) + + +MAINTENANCE_BRANCH_NAME = "v1.x" + + +@pytest.fixture(scope="session") +def deps_files_4_repo_w_dual_version_support( + deps_files_4_example_git_project: list[Path], +) -> list[Path]: + return [ + *deps_files_4_example_git_project, + # This file + Path(__file__).absolute(), + # because of imports + Path(tests.const.__file__).absolute(), + Path(tests.util.__file__).absolute(), + # because of the fixtures + Path(tests.conftest.__file__).absolute(), + ] + + +@pytest.fixture(scope="session") +def build_spec_hash_4_repo_w_dual_version_support( + get_md5_for_set_of_files: GetMd5ForSetOfFilesFn, + deps_files_4_repo_w_dual_version_support: list[Path], +) -> str: + # Generates a hash of the build spec to set when to invalidate the cache + return get_md5_for_set_of_files(deps_files_4_repo_w_dual_version_support) + + +@pytest.fixture(scope="session") +def get_repo_definition_4_trunk_only_repo_w_dual_version_support( + convert_commit_specs_to_commit_defs: ConvertCommitSpecsToCommitDefsFn, + changelog_md_file: Path, + changelog_rst_file: Path, + stable_now_date: GetStableDateNowFn, +) -> GetRepoDefinitionFn: + """ + Builds a repository with trunk-only committing (no-branching) strategy with + only official releases with latest and previous version support. + """ + + def _get_repo_from_defintion( + commit_type: CommitConvention, + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + mask_initial_release: bool = False, + ) -> Sequence[RepoActions]: + stable_now_datetime = stable_now_date() + commit_timestamp_gen = ( + (stable_now_datetime + timedelta(seconds=i)).isoformat(timespec="seconds") + for i in count(step=1) + ) + + changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + { + "path": changelog_md_file, + "format": ChangelogOutputFormat.MARKDOWN, + }, + { + "path": changelog_rst_file, + "format": ChangelogOutputFormat.RESTRUCTURED_TEXT, + }, + ] + + repo_construction_steps: list[RepoActions] = [] + + repo_construction_steps.append( + { + "action": RepoActionStep.CONFIGURE, + "details": { + "commit_type": commit_type, + "hvcs_client_name": hvcs_client_name, + "hvcs_domain": hvcs_domain, + "tag_format_str": tag_format_str, + "mask_initial_release": mask_initial_release, + "extra_configs": { + # Set the default release branch + "tool.semantic_release.branches.latest": { + "match": r"^(main|master)$", + "prerelease": False, + }, + "tool.semantic_release.branches.maintenance": { + "match": r"^v1\.x$", + "prerelease": False, + }, + "tool.semantic_release.allow_zero_version": False, + **(extra_configs or {}), + }, + }, + } + ) + + # Make initial release + new_version = "1.0.0" + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "angular": INITIAL_COMMIT_MESSAGE, + "emoji": INITIAL_COMMIT_MESSAGE, + "scipy": INITIAL_COMMIT_MESSAGE, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": bool( + commit_type == "emoji" + ), + }, + { + "angular": "feat: add new feature", + "emoji": ":sparkles: add new feature", + "scipy": "ENH: add new feature", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # Make a fix and officially release it + new_version = "1.0.1" + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "angular": "fix: correct some text", + "emoji": ":bug: correct some text", + "scipy": "MAINT: correct some text", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # Make a breaking change and officially release it + new_version = "2.0.0" + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": { + "create_branch": { + "name": MAINTENANCE_BRANCH_NAME, + "start_branch": DEFAULT_BRANCH_NAME, + } + }, + }, + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": DEFAULT_BRANCH_NAME}, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "angular": str.join( + "\n\n", + [ + "feat: add revolutionary feature", + "BREAKING CHANGE: this is a breaking change", + ], + ), + "emoji": str.join( + "\n\n", + [ + ":boom: add revolutionary feature", + "This change is a breaking change", + ], + ), + "scipy": str.join( + "\n\n", + [ + "API: add revolutionary feature", + "BREAKING CHANGE: this is a breaking change", + ], + ), + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # Fix a critical bug in the previous version and officially release it + new_version = "1.0.2" + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": MAINTENANCE_BRANCH_NAME}, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "angular": "fix: correct critical bug", + "emoji": ":bug: correct critical bug", + "scipy": "MAINT: correct critical bug", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "max_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # Return to the latest release variant + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": DEFAULT_BRANCH_NAME}, + }, + # TODO: return and make another release on the latest version + # currently test variant of the changelog generator can't support this + ] + ) + + return repo_construction_steps + + return _get_repo_from_defintion + + +@pytest.fixture(scope="session") +def build_trunk_only_repo_w_dual_version_support( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_trunk_only_repo_w_dual_version_support: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_repo_w_dual_version_support: str, +) -> BuildSpecificRepoFn: + def _build_specific_repo_type( + repo_name: str, commit_type: CommitConvention, dest_dir: Path + ) -> Sequence[RepoActions]: + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = ( + get_repo_definition_4_trunk_only_repo_w_dual_version_support( + commit_type=commit_type, + ) + ) + return build_repo_from_definition(cached_repo_path, repo_construction_steps) + + build_repo_or_copy_cache( + repo_name=repo_name, + build_spec_hash=build_spec_hash_4_repo_w_dual_version_support, + build_repo_func=_build_repo, + dest_dir=dest_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return cached_repo_data["build_definition"] + + return _build_specific_repo_type + + +# --------------------------------------------------------------------------- # +# Test-level fixtures that will cache the built directory & set up test case # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def repo_w_trunk_only_dual_version_spt_angular_commits( + build_trunk_only_repo_w_dual_version_support: BuildSpecificRepoFn, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = repo_w_trunk_only_dual_version_spt_angular_commits.__name__ + commit_type: CommitConvention = repo_name.split("_")[-2] # type: ignore[assignment] + + return { + "definition": build_trunk_only_repo_w_dual_version_support( + repo_name=repo_name, + commit_type=commit_type, + dest_dir=example_project_dir, + ), + "repo": example_project_git_repo(), + } + + +@pytest.fixture +def repo_w_trunk_only_dual_version_spt_emoji_commits( + build_trunk_only_repo_w_dual_version_support: BuildSpecificRepoFn, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = repo_w_trunk_only_dual_version_spt_emoji_commits.__name__ + commit_type: CommitConvention = repo_name.split("_")[-2] # type: ignore[assignment] + + return { + "definition": build_trunk_only_repo_w_dual_version_support( + repo_name=repo_name, + commit_type=commit_type, + dest_dir=example_project_dir, + ), + "repo": example_project_git_repo(), + } + + +@pytest.fixture +def repo_w_trunk_only_dual_version_spt_scipy_commits( + build_trunk_only_repo_w_dual_version_support: BuildSpecificRepoFn, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = repo_w_trunk_only_dual_version_spt_scipy_commits.__name__ + commit_type: CommitConvention = repo_name.split("_")[-2] # type: ignore[assignment] + + return { + "definition": build_trunk_only_repo_w_dual_version_support( + repo_name=repo_name, + commit_type=commit_type, + dest_dir=example_project_dir, + ), + "repo": example_project_git_repo(), + } diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py new file mode 100644 index 000000000..33a827dab --- /dev/null +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py @@ -0,0 +1,545 @@ +from __future__ import annotations + +from datetime import timedelta +from itertools import count +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from semantic_release.cli.config import ChangelogOutputFormat + +import tests.conftest +import tests.const +import tests.util +from tests.const import ( + DEFAULT_BRANCH_NAME, + EXAMPLE_HVCS_DOMAIN, + INITIAL_COMMIT_MESSAGE, + RepoActionStep, +) + +if TYPE_CHECKING: + from typing import Sequence + + from tests.conftest import ( + GetCachedRepoDataFn, + GetMd5ForSetOfFilesFn, + GetStableDateNowFn, + ) + from tests.fixtures.example_project import ( + ExProjectDir, + ) + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildRepoOrCopyCacheFn, + BuildSpecificRepoFn, + BuiltRepoResult, + CommitConvention, + ConvertCommitSpecsToCommitDefsFn, + ExProjectGitRepoFn, + GetRepoDefinitionFn, + RepoActions, + RepoActionWriteChangelogsDestFile, + TomlSerializableTypes, + ) + + +MAINTENANCE_BRANCH_NAME = "v1.x" + + +@pytest.fixture(scope="session") +def deps_files_4_repo_w_dual_version_spt_w_prereleases( + deps_files_4_example_git_project: list[Path], +) -> list[Path]: + return [ + *deps_files_4_example_git_project, + # This file + Path(__file__).absolute(), + # because of imports + Path(tests.const.__file__).absolute(), + Path(tests.util.__file__).absolute(), + # because of the fixtures + Path(tests.conftest.__file__).absolute(), + ] + + +@pytest.fixture(scope="session") +def build_spec_hash_4_repo_w_dual_version_spt_w_prereleases( + get_md5_for_set_of_files: GetMd5ForSetOfFilesFn, + deps_files_4_repo_w_dual_version_spt_w_prereleases: list[Path], +) -> str: + # Generates a hash of the build spec to set when to invalidate the cache + return get_md5_for_set_of_files(deps_files_4_repo_w_dual_version_spt_w_prereleases) + + +@pytest.fixture(scope="session") +def get_repo_definition_4_trunk_only_repo_w_dual_version_spt_w_prereleases( + convert_commit_specs_to_commit_defs: ConvertCommitSpecsToCommitDefsFn, + changelog_md_file: Path, + changelog_rst_file: Path, + stable_now_date: GetStableDateNowFn, +) -> GetRepoDefinitionFn: + """ + Builds a repository with trunk-only committing (no-branching) strategy with + only official releases with latest and previous version support. + """ + + def _get_repo_from_defintion( + commit_type: CommitConvention, + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + mask_initial_release: bool = False, + ) -> Sequence[RepoActions]: + stable_now_datetime = stable_now_date() + commit_timestamp_gen = ( + (stable_now_datetime + timedelta(seconds=i)).isoformat(timespec="seconds") + for i in count(step=1) + ) + + changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + { + "path": changelog_md_file, + "format": ChangelogOutputFormat.MARKDOWN, + }, + { + "path": changelog_rst_file, + "format": ChangelogOutputFormat.RESTRUCTURED_TEXT, + }, + ] + + repo_construction_steps: list[RepoActions] = [] + + repo_construction_steps.append( + { + "action": RepoActionStep.CONFIGURE, + "details": { + "commit_type": commit_type, + "hvcs_client_name": hvcs_client_name, + "hvcs_domain": hvcs_domain, + "tag_format_str": tag_format_str, + "mask_initial_release": mask_initial_release, + "extra_configs": { + # Set the default release branch + "tool.semantic_release.branches.latest": { + "match": r"^(main|master)$", + "prerelease": False, + }, + "tool.semantic_release.branches.maintenance": { + "match": r"^v1\.x$", + "prerelease": False, + }, + "tool.semantic_release.allow_zero_version": False, + **(extra_configs or {}), + }, + }, + } + ) + + # Make initial release + new_version = "1.0.0" + + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "angular": INITIAL_COMMIT_MESSAGE, + "emoji": INITIAL_COMMIT_MESSAGE, + "scipy": INITIAL_COMMIT_MESSAGE, + "datetime": next(commit_timestamp_gen), + "include_in_changelog": bool( + commit_type == "emoji" + ), + }, + { + "angular": "feat: add new feature", + "emoji": ":sparkles: add new feature", + "scipy": "ENH: add new feature", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # Make a fix and officially release it + new_version = "1.0.1" + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "angular": "fix: correct some text", + "emoji": ":bug: correct some text", + "scipy": "MAINT: correct some text", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # Make a breaking change and officially release it + new_version = "2.0.0" + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": { + "create_branch": { + "name": MAINTENANCE_BRANCH_NAME, + "start_branch": DEFAULT_BRANCH_NAME, + } + }, + }, + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": DEFAULT_BRANCH_NAME}, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "angular": str.join( + "\n\n", + [ + "feat: add revolutionary feature", + "BREAKING CHANGE: this is a breaking change", + ], + ), + "emoji": str.join( + "\n\n", + [ + ":boom: add revolutionary feature", + "This change is a breaking change", + ], + ), + "scipy": str.join( + "\n\n", + [ + "API: add revolutionary feature", + "BREAKING CHANGE: this is a breaking change", + ], + ), + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # Attempt to fix a critical bug in the previous version and release it as a prerelease version + # This is based on https://github.com/python-semantic-release/python-semantic-release/issues/861 + new_version = "1.0.2-hotfix.1" + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": MAINTENANCE_BRANCH_NAME}, + }, + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "angular": "fix: correct critical bug", + "emoji": ":bug: correct critical bug", + "scipy": "MAINT: correct critical bug", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "max_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # The Hotfix didn't work, so correct it and try again + new_version = "1.0.2-hotfix.2" + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.MAKE_COMMITS, + "details": { + "commits": convert_commit_specs_to_commit_defs( + [ + { + "angular": "fix: resolve critical bug", + "emoji": ":bug: resolve critical bug", + "scipy": "MAINT: resolve critical bug", + "datetime": next(commit_timestamp_gen), + "include_in_changelog": True, + }, + ], + commit_type, + ), + }, + }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "max_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # It finally was resolved so release it officially + new_version = "1.0.2" + repo_construction_steps.extend( + [ + # { + # "action": RepoActionStep.MAKE_COMMITS, + # "details": { + # "commits": convert_commit_specs_to_commit_defs( + # [ + # { + # "angular": "docs: update documentation regarding critical bug", + # "emoji": ":books: update documentation regarding critical bug", + # "scipy": "DOC: update documentation regarding critical bug", + # "datetime": next(commit_timestamp_gen), + # "include_in_changelog": True, + # }, + # ], + # commit_type, + # ), + # }, + # }, + { + "action": RepoActionStep.RELEASE, + "details": { + "version": new_version, + "datetime": next(commit_timestamp_gen), + "pre_actions": [ + { + "action": RepoActionStep.WRITE_CHANGELOGS, + "details": { + "new_version": new_version, + "max_version": new_version, + "dest_files": changelog_file_definitons, + }, + }, + ], + }, + }, + ] + ) + + # Return to the latest release variant + repo_construction_steps.extend( + [ + { + "action": RepoActionStep.GIT_CHECKOUT, + "details": {"branch": DEFAULT_BRANCH_NAME}, + }, + # TODO: return and make another release on the latest version + # currently test variant of the changelog generator can't support this + ] + ) + + return repo_construction_steps + + return _get_repo_from_defintion + + +@pytest.fixture(scope="session") +def build_trunk_only_repo_w_dual_version_spt_w_prereleases( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_trunk_only_repo_w_dual_version_spt_w_prereleases: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_repo_w_dual_version_spt_w_prereleases: str, +) -> BuildSpecificRepoFn: + def _build_specific_repo_type( + repo_name: str, commit_type: CommitConvention, dest_dir: Path + ) -> Sequence[RepoActions]: + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = ( + get_repo_definition_4_trunk_only_repo_w_dual_version_spt_w_prereleases( + commit_type=commit_type, + ) + ) + return build_repo_from_definition(cached_repo_path, repo_construction_steps) + + build_repo_or_copy_cache( + repo_name=repo_name, + build_spec_hash=build_spec_hash_4_repo_w_dual_version_spt_w_prereleases, + build_repo_func=_build_repo, + dest_dir=dest_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return cached_repo_data["build_definition"] + + return _build_specific_repo_type + + +# --------------------------------------------------------------------------- # +# Test-level fixtures that will cache the built directory & set up test case # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def repo_w_trunk_only_dual_version_spt_w_prereleases_angular_commits( + build_trunk_only_repo_w_dual_version_spt_w_prereleases: BuildSpecificRepoFn, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = ( + repo_w_trunk_only_dual_version_spt_w_prereleases_angular_commits.__name__ + ) + commit_type: CommitConvention = repo_name.split("_")[-2] # type: ignore[assignment] + + return { + "definition": build_trunk_only_repo_w_dual_version_spt_w_prereleases( + repo_name=repo_name, + commit_type=commit_type, + dest_dir=example_project_dir, + ), + "repo": example_project_git_repo(), + } + + +@pytest.fixture +def repo_w_trunk_only_dual_version_spt_w_prereleases_emoji_commits( + build_trunk_only_repo_w_dual_version_spt_w_prereleases: BuildSpecificRepoFn, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = repo_w_trunk_only_dual_version_spt_w_prereleases_emoji_commits.__name__ + commit_type: CommitConvention = repo_name.split("_")[-2] # type: ignore[assignment] + + return { + "definition": build_trunk_only_repo_w_dual_version_spt_w_prereleases( + repo_name=repo_name, + commit_type=commit_type, + dest_dir=example_project_dir, + ), + "repo": example_project_git_repo(), + } + + +@pytest.fixture +def repo_w_trunk_only_dual_version_spt_w_prereleases_scipy_commits( + build_trunk_only_repo_w_dual_version_spt_w_prereleases: BuildSpecificRepoFn, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = repo_w_trunk_only_dual_version_spt_w_prereleases_scipy_commits.__name__ + commit_type: CommitConvention = repo_name.split("_")[-2] # type: ignore[assignment] + + return { + "definition": build_trunk_only_repo_w_dual_version_spt_w_prereleases( + repo_name=repo_name, + commit_type=commit_type, + dest_dir=example_project_dir, + ), + "repo": example_project_git_repo(), + } diff --git a/tests/unit/semantic_release/version/test_algorithm.py b/tests/unit/semantic_release/version/test_algorithm.py index 431f7796b..a7fded2ef 100644 --- a/tests/unit/semantic_release/version/test_algorithm.py +++ b/tests/unit/semantic_release/version/test_algorithm.py @@ -149,7 +149,7 @@ def test_sorted_repo_tags_and_versions(tags: list[str], sorted_tags: list[str]): translator = VersionTranslator() tagrefs = [repo.tag(tag) for tag in tags] actual = [t.name for t, _ in tags_and_versions(tagrefs, translator)] - assert actual == sorted_tags + assert sorted_tags == actual @pytest.mark.parametrize( @@ -216,7 +216,7 @@ def test_tags_and_versions_ignores_invalid_tags_as_versions( translator = VersionTranslator(tag_format=tag_format) tagrefs = [repo.tag(tag) for tag in (*valid_tags, *invalid_tags)] actual = [t.name for t, _ in tags_and_versions(tagrefs, translator)] - assert set(actual) == set(valid_tags) + assert set(valid_tags) == set(actual) @pytest.mark.parametrize( @@ -234,6 +234,16 @@ def test_tags_and_versions_ignores_invalid_tags_as_versions( [ # NOTE: level_bump != LevelBump.NO_RELEASE, we return early in the # algorithm to discount this case + # NOTE: you can only perform a PRERELEASE_REVISION bump on a previously + # prerelease version and if you are requesting a prerelease + ( + "1.0.1-rc.1", + "1.0.0", + LevelBump.PRERELEASE_REVISION, + True, + "rc", + "1.0.1-rc.2", + ), *[ ( "1.0.0", @@ -244,8 +254,6 @@ def test_tags_and_versions_ignores_invalid_tags_as_versions( expected_version, ) for bump_level, prerelease, expected_version in [ - (LevelBump.PRERELEASE_REVISION, False, "1.0.0-rc.1"), - (LevelBump.PRERELEASE_REVISION, True, "1.0.0-rc.1"), (LevelBump.PATCH, False, "1.0.1"), (LevelBump.PATCH, True, "1.0.1-rc.1"), (LevelBump.MINOR, False, "1.1.0"), @@ -254,6 +262,14 @@ def test_tags_and_versions_ignores_invalid_tags_as_versions( (LevelBump.MAJOR, True, "2.0.0-rc.1"), ] ], + ( + "1.2.4-rc.1", + "1.2.3", + LevelBump.PRERELEASE_REVISION, + True, + "rc", + "1.2.4-rc.2", + ), *[ ( "1.2.4-rc.1", @@ -272,6 +288,14 @@ def test_tags_and_versions_ignores_invalid_tags_as_versions( (LevelBump.MAJOR, True, "2.0.0-rc.1"), ] ], + ( + "2.0.0-rc.1", + "1.19.3", + LevelBump.PRERELEASE_REVISION, + True, + "rc", + "2.0.0-rc.2", + ), *[ ( "2.0.0-rc.1", @@ -310,3 +334,45 @@ def test_increment_version_no_major_on_zero( allow_zero_version=True, ) assert expected_version == str(actual) + + +@pytest.mark.parametrize( + "latest_version, latest_full_version, level_bump, prerelease, prerelease_token", + [ + # NOTE: level_bump != LevelBump.NO_RELEASE, we return early in the + # algorithm to discount this case + # NOTE: you can only perform a PRERELEASE_REVISION bump on a previously + # prerelease version and if you are requesting a prerelease + ( + "1.0.0", + "1.0.0", + LevelBump.PRERELEASE_REVISION, + False, + "rc", + ), + ( + "1.0.0", + "1.0.0", + LevelBump.PRERELEASE_REVISION, + True, + "rc", + ), + ], +) +def test_increment_version_invalid_operation( + latest_version: str, + latest_full_version: str, + level_bump: LevelBump, + prerelease: bool, + prerelease_token: str, +): + with pytest.raises(ValueError): + _increment_version( + latest_version=Version.parse(latest_version), + latest_full_version=Version.parse(latest_full_version), + level_bump=level_bump, + prerelease=prerelease, + prerelease_token=prerelease_token, + major_on_zero=False, + allow_zero_version=True, + )