From ea103544fb9771d5189ed58b1174c8313ab6f3e9 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 15 Dec 2024 13:58:07 -0700 Subject: [PATCH 1/7] ci(pr-wkflow): add pipeline concurrency limit for branch updates of PRs Adds an automatic cancelation of in-progress pipelines to limit action minutes used (especially for e2e testing) when developer has already pushed a new update --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) 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: From 7dba338448f13038d9e8baa078380533e49f049e Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 15 Dec 2024 13:22:20 -0700 Subject: [PATCH 2/7] test(fixtures): improve changelog generator to filter by max version --- tests/fixtures/git_repo.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) 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"] From 9bdb4a1a765fc536921102decc544f11e9762177 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 15 Dec 2024 13:23:35 -0700 Subject: [PATCH 3/7] test(fixtures): add repo fixture of a trunk only repo w/ dual version support --- .../repo_w_dual_version_support.py | 460 ++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py 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(), + } From 93a52dd29160a3537a0111db870632cbe416ca3a Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 15 Dec 2024 13:24:03 -0700 Subject: [PATCH 4/7] test(fixtures): add repo fixture of a trunk only repo w/ dual version support & prereleases --- ...po_w_dual_version_support_w_prereleases.py | 545 ++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py 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(), + } From 900452b60a896a86683dffcdf7db4850fe3c9835 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 15 Dec 2024 13:25:10 -0700 Subject: [PATCH 5/7] test(cmd-version): add rebuild repo tests for new dual version support repos --- .../test_repo_trunk_dual_version_support.py | 185 ++++++++++++++++ ...runk_dual_version_support_w_prereleases.py | 205 ++++++++++++++++++ .../repos/trunk_based_dev/__init__.py | 2 + 3 files changed, 392 insertions(+) create mode 100644 tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py create mode 100644 tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py 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/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 * From aaaedec1826b1f26f8721386fd8c02ec06bea133 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 17 Mar 2024 20:56:54 -0400 Subject: [PATCH 6/7] test(algorithm): adjust unit tests of increment_version logic This clarifies repeated function calls and pytest parameter names included the unclear assert diff. Adds additional tests to check bad states for failures and refactored to match new function signature. --- .../version/test_algorithm.py | 74 ++++++++++++++++++- 1 file changed, 70 insertions(+), 4 deletions(-) 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, + ) From c85d520b150705eab1ebb0dc0ccefca6319a89d1 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 17 Mar 2024 12:57:12 -0400 Subject: [PATCH 7/7] fix(increment-version): increment based on history only refactor duplicate logging messages and flow to process out odd cases in a fail fast methodology. This removes the reliance on any last full release that is not within the history of the current branch. Resolves: #861 --- src/semantic_release/version/algorithm.py | 135 ++++++++++++---------- 1 file changed, 75 insertions(+), 60 deletions(-) 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(