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) 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 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]: 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,
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: