From c6baf08c02e68d6d8f594c0aba633682e9824bf2 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 10 Jan 2025 00:30:37 -0700 Subject: [PATCH 1/4] test(fixtures): add new trunk repo that has a different tag format --- .../repos/trunk_based_dev/repo_w_tags.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py index fe5e509a8..a79bd11dc 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py @@ -262,6 +262,45 @@ def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: # --------------------------------------------------------------------------- # +@pytest.fixture +def repo_w_trunk_only_angular_commits_using_tag_format( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_trunk_only_repo_w_tags: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_repo_w_tags: str, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = repo_w_trunk_only_angular_commits_using_tag_format.__name__ + commit_type: CommitConvention = ( + repo_name.split("_commits", maxsplit=1)[0].split("_")[-1] # type: ignore[assignment] + ) + + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = get_repo_definition_4_trunk_only_repo_w_tags( + commit_type=commit_type, + tag_format_str="submod-v{version}", + ) + 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_tags, + build_repo_func=_build_repo, + dest_dir=example_project_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return { + "definition": cached_repo_data["build_definition"], + "repo": example_project_git_repo(), + } + + @pytest.fixture def repo_w_trunk_only_angular_commits( build_trunk_only_repo_w_tags: BuildSpecificRepoFn, From dedfafa163d5fd8679ae3c725ae5e4f37214a4e7 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 10 Jan 2025 00:31:51 -0700 Subject: [PATCH 2/4] test(fixtures): add helper to extract config settings from repo action definition --- tests/fixtures/git_repo.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 3f3fab0f6..0656ec3e2 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -34,7 +34,7 @@ ) if TYPE_CHECKING: - from typing import Generator, Literal, Protocol, Sequence, TypedDict, Union + from typing import Any, Generator, Literal, Protocol, Sequence, TypedDict, Union from tests.fixtures.example_project import UpdateVersionPyFileFn @@ -370,6 +370,11 @@ def __call__( self, repo_definition: Sequence[RepoActions], tag_format_str: str ) -> dict[str, list[RepoActions]]: ... + class GetCfgValueFromDefFn(Protocol): + def __call__( + self, build_definition: Sequence[RepoActions], key: str + ) -> Any: ... + @pytest.fixture(scope="session") def deps_files_4_example_git_project( @@ -1083,7 +1088,11 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c action = step["action"] if action == RepoActionStep.CONFIGURE: - cfg_def: RepoActionConfigureDetails = step["details"] # type: ignore[assignment] + cfg_def: RepoActionConfigureDetails = step_result["details"] # type: ignore[assignment] + + # Make sure the resulting build definition is complete with the default + tag_format_str = cfg_def["tag_format_str"] or default_tag_format_str + cfg_def["tag_format_str"] = tag_format_str _, hvcs = build_configured_base_repo( # type: ignore[assignment] # TODO: fix the type error dest_dir, @@ -1101,7 +1110,6 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c ) # Save configuration details for later steps mask_initial_release = cfg_def["mask_initial_release"] - tag_format_str = cfg_def["tag_format_str"] or default_tag_format_str elif action == RepoActionStep.MAKE_COMMITS: mk_cmts_def: RepoActionMakeCommitsDetails = step_result["details"] # type: ignore[assignment] @@ -1222,6 +1230,25 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c return _build_repo_from_definition +@pytest.fixture(scope="session") +def get_cfg_value_from_def() -> GetCfgValueFromDefFn: + def _get_cfg_value_from_def( + build_definition: Sequence[RepoActions], key: str + ) -> Any: + configure_steps = [ + step + for step in build_definition + if step["action"] == RepoActionStep.CONFIGURE + ] + for step in configure_steps[::-1]: + if key in step["details"]: + return step["details"][key] # type: ignore[literal-required] + + raise ValueError(f"Unable to find configuration key: {key}") + + return _get_cfg_value_from_def + + @pytest.fixture(scope="session") def get_versions_from_repo_build_def() -> GetVersionsFromRepoBuildDefFn: def _get_versions(repo_def: Sequence[RepoActions]) -> Sequence[str]: From 02c2c738e00b30051d9579ed803ae684595e0b74 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 10 Jan 2025 00:34:14 -0700 Subject: [PATCH 3/4] test(cmd-version): expand testing of `--print-tag` & `--print-last-released-tag` PSR did not have enough testing to demonstrate testing of the tag generation when the tag format was configured differently than normal. This commit adds a significant portion of testing to exercise the print tag functionality which must match the configured tag format. --- tests/e2e/cmd_version/test_version_print.py | 375 +++++++++++++++++++- 1 file changed, 366 insertions(+), 9 deletions(-) diff --git a/tests/e2e/cmd_version/test_version_print.py b/tests/e2e/cmd_version/test_version_print.py index 8d22cbf23..922160358 100644 --- a/tests/e2e/cmd_version/test_version_print.py +++ b/tests/e2e/cmd_version/test_version_print.py @@ -14,8 +14,10 @@ from tests.fixtures.commit_parsers import angular_minor_commits from tests.fixtures.git_repo import get_commit_def_of_angular_commit from tests.fixtures.repos import ( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format, repo_w_no_tags_angular_commits, repo_w_trunk_only_angular_commits, + repo_w_trunk_only_angular_commits_using_tag_format, ) from tests.util import ( add_text_to_file, @@ -31,6 +33,7 @@ from tests.fixtures.git_repo import ( BuiltRepoResult, + GetCfgValueFromDefFn, GetCommitDefFn, GetVersionsFromRepoBuildDefFn, SimulateChangeCommitsNReturnChangelogEntryFn, @@ -71,7 +74,6 @@ # Forced version bump with --as-prerelease and modified --prerelease-token # and --build-metadata ( - # TODO: Error, our current implementation does not support this [ "--patch", "--as-prerelease", @@ -144,6 +146,120 @@ def test_version_print_next_version( assert post_mocker.call_count == 0 +@pytest.mark.parametrize( + "repo_result, commits, force_args, next_release_version", + [ + ( + lazy_fixture(repo_fixture_name), + lazy_fixture(angular_minor_commits.__name__), + cli_args, + next_release_version, + ) + for repo_fixture_name in ( + repo_w_trunk_only_angular_commits.__name__, + repo_w_trunk_only_angular_commits_using_tag_format.__name__, + ) + for cli_args, next_release_version in ( + # Dynamic version bump determination (based on commits) + ([], "0.2.0"), + # Dynamic version bump determination (based on commits) with build metadata + (["--build-metadata", "build.12345"], "0.2.0+build.12345"), + # Forced version bump + (["--prerelease"], "0.1.1-rc.1"), + (["--patch"], "0.1.2"), + (["--minor"], "0.2.0"), + (["--major"], "1.0.0"), + # Forced version bump with --build-metadata + (["--patch", "--build-metadata", "build.12345"], "0.1.2+build.12345"), + # Forced version bump with --as-prerelease + (["--prerelease", "--as-prerelease"], "0.1.1-rc.1"), + (["--patch", "--as-prerelease"], "0.1.2-rc.1"), + (["--minor", "--as-prerelease"], "0.2.0-rc.1"), + (["--major", "--as-prerelease"], "1.0.0-rc.1"), + # Forced version bump with --as-prerelease and modified --prerelease-token + ( + ["--patch", "--as-prerelease", "--prerelease-token", "beta"], + "0.1.2-beta.1", + ), + # Forced version bump with --as-prerelease and modified --prerelease-token + # and --build-metadata + ( + [ + "--patch", + "--as-prerelease", + "--prerelease-token", + "beta", + "--build-metadata", + "build.12345", + ], + "0.1.2-beta.1+build.12345", + ), + ) + ], +) +def test_version_print_tag_prints_next_tag( + repo_result: BuiltRepoResult, + commits: list[str], + force_args: list[str], + next_release_version: str, + get_cfg_value_from_def: GetCfgValueFromDefFn, + file_in_repo: str, + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, +): + """ + Given a generic repository at the latest release version and a subsequent commit, + When running the version command with the --print-tag flag, + Then the expected next release tag should be printed and exit without + making any changes to the repository. + + Note: The point of this test is to only verify that the `--print-tag` flag does not + make any changes to the repository--not to validate if the next version is calculated + correctly per the repository structure (see test_version_release & + test_version_force_level for correctness). + + However, we do validate that --print-tag & a force option and/or --as-prerelease options + work together to print the next release tag correctly but not make a change to the repo. + """ + repo = repo_result["repo"] + repo_def = repo_result["definition"] + tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] + next_release_tag = tag_format_str.format(version=next_release_version) + + # Make a commit to ensure we have something to release + # otherwise the "no release will be made" logic will kick in first + add_text_to_file(repo, file_in_repo) + repo.git.commit(m=commits[-1], a=True) + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-tag", *force_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit.hexsha + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + assert not result.stderr + assert f"{next_release_tag}\n" == result.stdout + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_before == head_after + assert not tags_set_difference + assert mocked_git_push.call_count == 0 + assert post_mocker.call_count == 0 + + @pytest.mark.parametrize( "repo_result", [lazy_fixture(repo_w_trunk_only_angular_commits.__name__)], @@ -383,19 +499,191 @@ def test_version_print_last_released_on_nonrelease_branch( @pytest.mark.parametrize( "repo_result", - [lazy_fixture(repo_w_trunk_only_angular_commits.__name__)], + [ + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + pytest.param( + lazy_fixture( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__ + ), + marks=pytest.mark.comprehensive, + ), + ], +) +def test_version_print_last_released_tag_prints_correct_tag( + repo_result: BuiltRepoResult, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, +): + repo = repo_result["repo"] + repo_def = repo_result["definition"] + tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] + latest_release_version = get_versions_from_repo_build_def(repo_def)[-1] + latest_release_tag = tag_format_str.format(version=latest_release_version) + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit.hexsha + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + assert not result.stderr + assert f"{latest_release_tag}\n" == result.stdout + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_before == head_after + assert not tags_set_difference + assert mocked_git_push.call_count == 0 + assert post_mocker.call_count == 0 + + +@pytest.mark.parametrize( + "repo_result, commits", + [ + ( + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + lazy_fixture(angular_minor_commits.__name__), + ), + pytest.param( + lazy_fixture( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__ + ), + lazy_fixture(angular_minor_commits.__name__), + marks=pytest.mark.comprehensive, + ), + ], +) +def test_version_print_last_released_tag_prints_released_if_commits( + repo_result: BuiltRepoResult, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + commits: list[str], + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, + file_in_repo: str, +): + repo = repo_result["repo"] + repo_def = repo_result["definition"] + tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] + latest_release_version = get_versions_from_repo_build_def(repo_def)[-1] + latest_release_tag = tag_format_str.format(version=latest_release_version) + + # Make a commit so the head is not on the last release + add_text_to_file(repo, file_in_repo) + repo.git.commit(m=commits[0], a=True) + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit.hexsha + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + assert not result.stderr + assert f"{latest_release_tag}\n" == result.stdout + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_before == head_after + assert not tags_set_difference + assert mocked_git_push.call_count == 0 + assert post_mocker.call_count == 0 + + +@pytest.mark.parametrize( + "repo_result", + [lazy_fixture(repo_w_no_tags_angular_commits.__name__)], +) +def test_version_print_last_released_tag_prints_nothing_if_no_tags( + repo_result: BuiltRepoResult, + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, + caplog: pytest.LogCaptureFixture, +): + repo = repo_result["repo"] + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_sha_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate (no release actions should have occurred on print) + assert_successful_exit_code(result, cli_cmd) + assert result.stdout == "" + + # must use capture log to see this, because we use the logger to print this message + # not click's output + assert "No release tags found." in caplog.text + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_sha_before == head_after.hexsha # No commit has been made + assert not tags_set_difference # No tag created + assert mocked_git_push.call_count == 0 # no git push of tag or commit + assert post_mocker.call_count == 0 # no vcs release + + +@pytest.mark.parametrize( + "repo_result", + [ + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + pytest.param( + lazy_fixture( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__ + ), + marks=pytest.mark.comprehensive, + ), + ], ) def test_version_print_last_released_tag_on_detached_head( repo_result: BuiltRepoResult, + get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, cli_runner: CliRunner, mocked_git_push: MagicMock, post_mocker: Mocker, ): repo = repo_result["repo"] - latest_release_tag = ( - f"v{get_versions_from_repo_build_def(repo_result['definition'])[-1]}" - ) + repo_def = repo_result["definition"] + tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] + latest_release_version = get_versions_from_repo_build_def(repo_def)[-1] + latest_release_tag = tag_format_str.format(version=latest_release_version) # Setup: put the repo in a detached head state repo.git.checkout("HEAD", detach=True) @@ -430,19 +718,29 @@ def test_version_print_last_released_tag_on_detached_head( @pytest.mark.parametrize( "repo_result", - [lazy_fixture(repo_w_trunk_only_angular_commits.__name__)], + [ + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + pytest.param( + lazy_fixture( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__ + ), + marks=pytest.mark.comprehensive, + ), + ], ) def test_version_print_last_released_tag_on_nonrelease_branch( repo_result: BuiltRepoResult, + get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, cli_runner: CliRunner, mocked_git_push: MagicMock, post_mocker: Mocker, ): repo = repo_result["repo"] - last_release_tag = ( - f"v{get_versions_from_repo_build_def(repo_result['definition'])[-1]}" - ) + repo_def = repo_result["definition"] + tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] + latest_release_version = get_versions_from_repo_build_def(repo_def)[-1] + last_release_tag = tag_format_str.format(version=latest_release_version) # Setup: put the repo on a non-release branch repo.create_head("next").checkout() @@ -532,3 +830,62 @@ def test_version_print_next_version_fails_on_detached_head( assert not tags_set_difference assert mocked_git_push.call_count == 0 assert post_mocker.call_count == 0 + + +@pytest.mark.parametrize( + "repo_result, get_commit_def_fn", + [ + ( + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + lazy_fixture(get_commit_def_of_angular_commit.__name__), + ) + ], +) +def test_version_print_next_tag_fails_on_detached_head( + repo_result: BuiltRepoResult, + cli_runner: CliRunner, + simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, + get_commit_def_fn: GetCommitDefFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, +): + repo = repo_result["repo"] + expected_error_msg = ( + "Detached HEAD state cannot match any release groups; no release will be made" + ) + + # Setup: put the repo in a detached head state + repo.git.checkout("HEAD", detach=True) + + # Setup: make a commit to ensure we have something to release + simulate_change_commits_n_rtn_changelog_entry( + repo, + [get_commit_def_fn("fix: make a patch fix to codebase")], + ) + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-tag"] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit.hexsha + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate (expected -> actual) + assert_exit_code(1, result, cli_cmd) + assert not result.stdout + assert f"{expected_error_msg}\n" == result.stderr + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_before == head_after + assert not tags_set_difference + assert mocked_git_push.call_count == 0 + assert post_mocker.call_count == 0 From b63b24bb22abf4e715cf50bea8585a639224eb86 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Tue, 7 Jan 2025 22:29:01 -0700 Subject: [PATCH 4/4] fix(cmd-version): fix `--print-tag` result to match configured tag format --- src/semantic_release/cli/commands/version.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 737d13d8d..2342fe6f7 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -516,12 +516,8 @@ def version( # noqa: C901 gha_output.version = new_version ctx.call_on_close(gha_output.write_if_possible) - # Make string variant of version && Translate to tag if necessary - version_to_print = ( - str(new_version) - if not print_only_tag - else translator.str_to_tag(str(new_version)) - ) + # Make string variant of version or appropriate tag as necessary + version_to_print = str(new_version) if not print_only_tag else new_version.as_tag() # Print the new version so that command-line output capture will work click.echo(version_to_print)