diff --git a/docs/api/commands.rst b/docs/api/commands.rst index 3dca77474..cdf9be45c 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -118,6 +118,18 @@ By default (in order): #. Create a release in the remote VCS for this tag (if supported) +.. note:: + + Before pushing changes to the remote (step 6), Python Semantic Release automatically + verifies that the upstream branch has not changed since the commit that triggered + the release. This prevents push conflicts when another commit was made to the + upstream branch while the release was being prepared. If the upstream branch has + changed, the command will exit with an error, and you will need to pull the latest + changes and run the command again. + + This verification only occurs when committing changes (``--commit``). If you are + running with ``--no-commit``, the verification will not be performed. + All of these steps can be toggled on or off using the command line options described below. Some of the steps rely on others, so some options may implicitly disable others. diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index fbf5110e8..c515ed474 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -891,45 +891,6 @@ to the GitHub Release Assets as well. run: | git reset --hard ${{ github.sha }} - - name: Evaluate | Verify upstream has NOT changed - # Last chance to abort before causing an error as another PR/push was applied to - # the upstream branch while this workflow was running. This is important - # because we are committing a version change (--commit). You may omit this step - # if you have 'commit: false' in your configuration. - # - # You may consider moving this to a repo script and call it from this step instead - # of writing it in-line. - shell: bash - run: | - set +o pipefail - - UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | awk -F '\\.\\.\\.' '{print $2}' | cut -d ' ' -f1)" - printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME" - - set -o pipefail - - if [ -z "$UPSTREAM_BRANCH_NAME" ]; then - printf >&2 '%s\n' "::error::Unable to determine upstream branch name!" - exit 1 - fi - - git fetch "${UPSTREAM_BRANCH_NAME%%/*}" - - if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then - printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" - exit 1 - fi - - HEAD_SHA="$(git rev-parse HEAD)" - - if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then - printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" - printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." - exit 1 - fi - - printf '%s\n' "Verified upstream branch has not changed, continuing with release..." - - name: Action | Semantic Version Release id: release # Adjust tag with desired version if applicable. @@ -998,11 +959,6 @@ to the GitHub Release Assets as well. one release job in the case if there are multiple pushes to ``main`` in a short period of time. - Secondly the *Evaluate | Verify upstream has NOT changed* step is used to ensure that the - upstream branch has not changed while the workflow was running. This is important because - we are committing a version change (``commit: true``) and there might be a push collision - that would cause undesired behavior. Review Issue `#1201`_ for more detailed information. - .. warning:: You must set ``fetch-depth`` to 0 when using ``actions/checkout@v4``, since Python Semantic Release needs access to the full history to build a changelog @@ -1018,6 +974,11 @@ to the GitHub Release Assets as well. case, you will also need to pass the new token to ``actions/checkout`` (as the ``token`` input) in order to gain push access. +.. note:: + As of $NEW_RELEASE_TAG, the verify upstream step is no longer required as it has been + integrated into PSR directly. If you are using an older version of PSR, you will need + to review the older documentation for that step. See Issue `#1201`_ for more details. + .. _#1201: https://github.com/python-semantic-release/python-semantic-release/issues/1201 .. _concurrency: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idconcurrency diff --git a/docs/configuration/configuration-guides/uv_integration.rst b/docs/configuration/configuration-guides/uv_integration.rst index 79039477e..bc794832e 100644 --- a/docs/configuration/configuration-guides/uv_integration.rst +++ b/docs/configuration/configuration-guides/uv_integration.rst @@ -291,7 +291,6 @@ look like this: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - bash .github/workflows/verify_upstream.sh uv run semantic-release -v --strict version --skip-build uv run semantic-release publish diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 717f55278..627cc1fc6 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -10,7 +10,7 @@ import click import shellingham # type: ignore[import] from click_option_group import MutuallyExclusiveOptionGroup, optgroup -from git import Repo +from git import GitCommandError, Repo from requests import HTTPError from semantic_release.changelog.release_history import ReleaseHistory @@ -27,9 +27,14 @@ from semantic_release.enums import LevelBump from semantic_release.errors import ( BuildDistributionsError, + DetachedHeadGitError, GitCommitEmptyIndexError, + GitFetchError, InternalError, + LocalGitError, UnexpectedResponse, + UnknownUpstreamBranchError, + UpstreamBranchChangedError, ) from semantic_release.gitproject import GitProject from semantic_release.globals import logger @@ -727,6 +732,29 @@ def version( # noqa: C901 ) if commit_changes: + # Verify that the upstream branch has not changed before pushing + # This prevents conflicts if another commit was pushed while we were preparing the release + # We check HEAD~1 because we just made a release commit + try: + project.verify_upstream_unchanged(local_ref="HEAD~1", noop=opts.noop) + except UpstreamBranchChangedError as exc: + click.echo(str(exc), err=True) + click.echo( + "Upstream branch has changed. Please pull the latest changes and try again.", + err=True, + ) + ctx.exit(1) + except ( + DetachedHeadGitError, + GitCommandError, + UnknownUpstreamBranchError, + GitFetchError, + LocalGitError, + ) as exc: + click.echo(str(exc), err=True) + click.echo("Unable to verify upstream due to error!", err=True) + ctx.exit(1) + # TODO: integrate into push branch with Repo(str(runtime.repo_dir)) as git_repo: active_branch = git_repo.active_branch.name diff --git a/src/semantic_release/errors.py b/src/semantic_release/errors.py index 954e85d20..600106906 100644 --- a/src/semantic_release/errors.py +++ b/src/semantic_release/errors.py @@ -106,3 +106,19 @@ class GitTagError(SemanticReleaseBaseError): class GitPushError(SemanticReleaseBaseError): """Raised when there is a failure to push to the git remote.""" + + +class GitFetchError(SemanticReleaseBaseError): + """Raised when there is a failure to fetch from the git remote.""" + + +class LocalGitError(SemanticReleaseBaseError): + """Raised when there is a failure with local git operations.""" + + +class UnknownUpstreamBranchError(SemanticReleaseBaseError): + """Raised when the upstream branch cannot be determined.""" + + +class UpstreamBranchChangedError(SemanticReleaseBaseError): + """Raised when the upstream branch has changed before pushing.""" diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index 0e4592599..05c4b1015 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -12,11 +12,16 @@ from semantic_release.cli.masking_filter import MaskingFilter from semantic_release.cli.util import indented, noop_report from semantic_release.errors import ( + DetachedHeadGitError, GitAddError, GitCommitEmptyIndexError, GitCommitError, + GitFetchError, GitPushError, GitTagError, + LocalGitError, + UnknownUpstreamBranchError, + UpstreamBranchChangedError, ) from semantic_release.globals import logger @@ -282,3 +287,94 @@ def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None: except GitCommandError as err: self.logger.exception(str(err)) raise GitPushError(f"Failed to push tag ({tag}) to remote") from err + + def verify_upstream_unchanged( + self, local_ref: str = "HEAD", noop: bool = False + ) -> None: + """ + Verify that the upstream branch has not changed since the given local reference. + + :param local_ref: The local reference to compare against upstream (default: HEAD) + :param noop: Whether to skip the actual verification (for dry-run mode) + + :raises UpstreamBranchChangedError: If the upstream branch has changed + """ + if noop: + noop_report( + indented( + """\ + would have verified that upstream branch has not changed + """ + ) + ) + return + + with Repo(str(self.project_root)) as repo: + # Get the current active branch + try: + active_branch = repo.active_branch + except TypeError: + # When in detached HEAD state, active_branch raises TypeError + err_msg = ( + "Repository is in detached HEAD state, cannot verify upstream state" + ) + raise DetachedHeadGitError(err_msg) from None + + # Get the tracking branch (upstream branch) + if (tracking_branch := active_branch.tracking_branch()) is None: + err_msg = f"No upstream branch found for '{active_branch.name}'; cannot verify upstream state!" + raise UnknownUpstreamBranchError(err_msg) + + upstream_full_ref_name = tracking_branch.name + self.logger.info("Upstream branch name: %s", upstream_full_ref_name) + + # Extract the remote name from the tracking branch + # tracking_branch.name is in the format "remote/branch" + remote_name, remote_branch_name = upstream_full_ref_name.split( + "/", maxsplit=1 + ) + remote_ref_obj = repo.remotes[remote_name] + + # Fetch the latest changes from the remote + self.logger.info("Fetching latest changes from remote '%s'", remote_name) + try: + remote_ref_obj.fetch() + except GitCommandError as err: + self.logger.exception(str(err)) + err_msg = f"Failed to fetch from remote '{remote_name}'" + raise GitFetchError(err_msg) from err + + # Get the SHA of the upstream branch + try: + upstream_commit_ref = remote_ref_obj.refs[remote_branch_name].commit + upstream_sha = upstream_commit_ref.hexsha + except AttributeError as err: + self.logger.exception(str(err)) + err_msg = f"Unable to determine upstream branch SHA for '{upstream_full_ref_name}'" + raise GitFetchError(err_msg) from err + + # Get the SHA of the specified ref (default: HEAD) + try: + local_commit = repo.commit(repo.git.rev_parse(local_ref)) + except GitCommandError as err: + self.logger.exception(str(err)) + err_msg = f"Unable to determine the SHA for local ref '{local_ref}'" + raise LocalGitError(err_msg) from err + + # Compare the two SHAs + if local_commit.hexsha != upstream_sha and not any( + commit.hexsha == upstream_sha for commit in local_commit.iter_parents() + ): + err_msg = str.join( + "\n", + ( + f"[LOCAL SHA] {local_commit.hexsha} != {upstream_sha} [UPSTREAM SHA].", + f"Upstream branch '{upstream_full_ref_name}' has changed!", + ), + ) + raise UpstreamBranchChangedError(err_msg) + + self.logger.info( + "Verified upstream branch '%s' has not changed", + upstream_full_ref_name, + ) diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py index ad720baf7..79d94b37b 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_1_channel( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_1_channel( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py index c87a3f2db..2b8c046e1 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_2_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_2_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_2_channels( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py index cadba665a..a26988646 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py @@ -59,6 +59,7 @@ def test_gitflow_repo_rebuild_3_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -105,6 +106,7 @@ def test_gitflow_repo_rebuild_3_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -162,5 +164,8 @@ def test_gitflow_repo_rebuild_3_channels( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py index 734aeceba..0adde1fa3 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_4_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_4_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_4_channels( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py index 093b80cb3..edeb485ce 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py @@ -57,6 +57,7 @@ def test_githubflow_repo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_githubflow_repo_rebuild_1_channel( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_githubflow_repo_rebuild_1_channel( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py index 36d156077..1d54aa459 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py @@ -63,6 +63,7 @@ def test_github_flow_repo_w_default_release_n_branch_update_merge( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -111,6 +112,7 @@ def test_github_flow_repo_w_default_release_n_branch_update_merge( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -174,5 +176,8 @@ def test_github_flow_repo_w_default_release_n_branch_update_merge( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py index 90b6ea16d..ab6906eea 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py @@ -57,6 +57,7 @@ def test_githubflow_repo_rebuild_2_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_githubflow_repo_rebuild_2_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_githubflow_repo_rebuild_2_channels( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py index fd5fb5ff2..1d82138f2 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py @@ -58,6 +58,7 @@ def test_githubflow_monorepo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, @@ -114,6 +115,7 @@ def test_githubflow_monorepo_rebuild_1_channel( ) # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -247,5 +249,8 @@ def test_githubflow_monorepo_rebuild_1_channel( assert curr_release_str in [tag.name for tag in mirror_git_repo.tags] # Make sure publishing actions occurred + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py index 57d6cd3fb..1352995b9 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py @@ -58,6 +58,7 @@ def test_githubflow_monorepo_rebuild_2_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, @@ -113,6 +114,7 @@ def test_githubflow_monorepo_rebuild_2_channels( ) # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -246,5 +248,8 @@ def test_githubflow_monorepo_rebuild_2_channels( assert curr_release_str in [tag.name for tag in mirror_git_repo.tags] # Make sure publishing actions occurred + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py index 8a68c20de..c57c6d58c 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py @@ -59,6 +59,7 @@ def test_trunk_repo_rebuild_only_official_releases( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -105,6 +106,7 @@ def test_trunk_repo_rebuild_only_official_releases( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -163,5 +165,8 @@ def test_trunk_repo_rebuild_only_official_releases( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 index 6236f5b55..6ea7e1f54 100644 --- 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 @@ -60,6 +60,7 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -106,6 +107,7 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -169,5 +171,8 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 index 020a4e6ac..89819abe9 100644 --- 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 @@ -61,6 +61,7 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -107,6 +108,7 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -170,5 +172,8 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py index 4473f56e9..78eca0bd6 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py @@ -57,6 +57,7 @@ def test_trunk_repo_rebuild_w_prereleases( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_trunk_repo_rebuild_w_prereleases( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_trunk_repo_rebuild_w_prereleases( 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_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py index ec4ccd60a..c68a35f5d 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py @@ -58,6 +58,7 @@ def test_trunk_monorepo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, @@ -114,6 +115,7 @@ def test_trunk_monorepo_rebuild_1_channel( ) # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -247,5 +249,8 @@ def test_trunk_monorepo_rebuild_1_channel( assert curr_release_str in [tag.name for tag in mirror_git_repo.tags] # Make sure publishing actions occurred + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/test_version.py b/tests/e2e/cmd_version/test_version.py index 0af19f8a3..2bf952647 100644 --- a/tests/e2e/cmd_version/test_version.py +++ b/tests/e2e/cmd_version/test_version.py @@ -42,6 +42,7 @@ def test_version_noop_is_noop( repo_result: BuiltRepoResult, next_release_version: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, get_wheel_file: GetWheelFileFn, @@ -91,6 +92,7 @@ def test_version_no_git_verify( repo_result: BuiltRepoResult, run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -101,6 +103,11 @@ def test_version_no_git_verify( repo.git.commit( m="chore: adjust project configuration for --no-verify release commits", a=True ) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # setup: create executable pre-commit script precommit_hook = Path(repo.git_dir, "hooks", "pre-commit") @@ -140,6 +147,7 @@ def test_version_no_git_verify( # A commit has been made (regardless of precommit) assert [head_sha_before] == [head.hexsha for head in head_after.parents] assert len(tags_set_difference) == 1 # A tag has been created + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -150,6 +158,7 @@ def test_version_no_git_verify( def test_version_on_nonrelease_branch( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -196,6 +205,7 @@ def test_version_on_last_release( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -248,6 +258,7 @@ def test_version_on_last_release( def test_version_only_tag_push( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ) -> None: diff --git a/tests/e2e/cmd_version/test_version_build.py b/tests/e2e/cmd_version/test_version_build.py index c9380683c..51022da87 100644 --- a/tests/e2e/cmd_version/test_version_build.py +++ b/tests/e2e/cmd_version/test_version_build.py @@ -68,6 +68,7 @@ def test_version_runs_build_command( shell: str, get_wheel_file: GetWheelFileFn, example_pyproject_toml: Path, + mocked_git_fetch: mock.MagicMock, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, ): @@ -125,6 +126,9 @@ def test_version_runs_build_command( ) assert built_wheel_file.exists() + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 assert post_mocker.call_count == 1 @@ -150,6 +154,7 @@ def test_version_runs_build_command_windows( get_wheel_file: GetWheelFileFn, example_pyproject_toml: Path, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: mock.MagicMock, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, clean_os_environment: dict[str, str], @@ -223,6 +228,9 @@ def test_version_runs_build_command_windows( dist_file_exists = built_wheel_file.exists() assert dist_file_exists, f"\n Expected wheel file to be created at {built_wheel_file}, but it does not exist." + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 assert post_mocker.call_count == 1 @@ -338,6 +346,7 @@ def test_version_runs_build_command_w_user_env( @pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) def test_version_skips_build_command_with_skip_build( run_cli: RunCliFn, + mocked_git_fetch: mock.MagicMock, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, ): @@ -354,5 +363,6 @@ def test_version_skips_build_command_with_skip_build( assert_successful_exit_code(result, cli_cmd) patched_subprocess_run.assert_not_called() + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 assert post_mocker.call_count == 1 diff --git a/tests/e2e/cmd_version/test_version_bump.py b/tests/e2e/cmd_version/test_version_bump.py index 5feade30c..5739b8f3b 100644 --- a/tests/e2e/cmd_version/test_version_bump.py +++ b/tests/e2e/cmd_version/test_version_bump.py @@ -321,6 +321,7 @@ def test_version_force_level( example_project_dir: ExProjectDir, example_pyproject_toml: Path, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, pyproject_toml_file: Path, @@ -390,6 +391,7 @@ def test_version_force_level( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -534,6 +536,7 @@ def test_version_next_greater_than_version_one_conventional( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -553,6 +556,11 @@ def test_version_next_greater_than_version_one_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -587,6 +595,7 @@ def test_version_next_greater_than_version_one_conventional( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -673,6 +682,7 @@ def test_version_next_greater_than_version_one_no_bump_conventional( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -692,6 +702,11 @@ def test_version_next_greater_than_version_one_no_bump_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -724,6 +739,7 @@ def test_version_next_greater_than_version_one_no_bump_conventional( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -833,6 +849,7 @@ def test_version_next_greater_than_version_one_emoji( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -852,6 +869,11 @@ def test_version_next_greater_than_version_one_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -886,6 +908,7 @@ def test_version_next_greater_than_version_one_emoji( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -972,6 +995,7 @@ def test_version_next_greater_than_version_one_no_bump_emoji( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -991,6 +1015,11 @@ def test_version_next_greater_than_version_one_no_bump_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1023,6 +1052,7 @@ def test_version_next_greater_than_version_one_no_bump_emoji( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -1132,6 +1162,7 @@ def test_version_next_greater_than_version_one_scipy( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1151,6 +1182,11 @@ def test_version_next_greater_than_version_one_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1185,6 +1221,7 @@ def test_version_next_greater_than_version_one_scipy( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -1271,6 +1308,7 @@ def test_version_next_greater_than_version_one_no_bump_scipy( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1290,6 +1328,11 @@ def test_version_next_greater_than_version_one_no_bump_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1322,6 +1365,7 @@ def test_version_next_greater_than_version_one_no_bump_scipy( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -1612,6 +1656,7 @@ def test_version_next_w_zero_dot_versions_conventional( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1637,6 +1682,11 @@ def test_version_next_w_zero_dot_versions_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1671,6 +1721,7 @@ def test_version_next_w_zero_dot_versions_conventional( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -1766,6 +1817,7 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1791,6 +1843,11 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1823,6 +1880,7 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -2092,6 +2150,7 @@ def test_version_next_w_zero_dot_versions_emoji( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2117,6 +2176,11 @@ def test_version_next_w_zero_dot_versions_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2151,6 +2215,7 @@ def test_version_next_w_zero_dot_versions_emoji( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -2246,6 +2311,7 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2271,6 +2337,11 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2303,6 +2374,7 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -2572,6 +2644,7 @@ def test_version_next_w_zero_dot_versions_scipy( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2597,6 +2670,11 @@ def test_version_next_w_zero_dot_versions_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2631,6 +2709,7 @@ def test_version_next_w_zero_dot_versions_scipy( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -2726,6 +2805,7 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2751,6 +2831,11 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2783,6 +2868,7 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -3115,6 +3201,7 @@ def test_version_next_w_zero_dot_versions_minimums( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -3141,6 +3228,11 @@ def test_version_next_w_zero_dot_versions_minimums( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -3175,5 +3267,6 @@ def test_version_next_w_zero_dot_versions_minimums( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/test_version_print.py b/tests/e2e/cmd_version/test_version_print.py index 9bcc2dea8..c9a9bd034 100644 --- a/tests/e2e/cmd_version/test_version_print.py +++ b/tests/e2e/cmd_version/test_version_print.py @@ -105,6 +105,7 @@ def test_version_print_next_version( next_release_version: str, file_in_repo: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -269,6 +270,7 @@ def test_version_print_tag_prints_next_tag( get_cfg_value_from_def: GetCfgValueFromDefFn, file_in_repo: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -384,6 +386,7 @@ def test_version_print_tag_prints_next_tag_no_zero_versions( get_cfg_value_from_def: GetCfgValueFromDefFn, file_in_repo: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -447,6 +450,7 @@ def test_version_print_last_released_prints_version( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -498,6 +502,7 @@ def test_version_print_last_released_prints_released_if_commits( get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, commits: list[str], run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, file_in_repo: str, @@ -547,6 +552,7 @@ def test_version_print_last_released_prints_released_if_commits( def test_version_print_last_released_prints_nothing_if_no_tags( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, caplog: pytest.LogCaptureFixture, @@ -592,6 +598,7 @@ def test_version_print_last_released_on_detached_head( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -640,6 +647,7 @@ def test_version_print_last_released_on_nonrelease_branch( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -697,6 +705,7 @@ def test_version_print_last_released_tag_prints_correct_tag( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -757,6 +766,7 @@ def test_version_print_last_released_tag_prints_released_if_commits( get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, commits: list[str], run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, file_in_repo: str, @@ -807,6 +817,7 @@ def test_version_print_last_released_tag_prints_released_if_commits( def test_version_print_last_released_tag_prints_nothing_if_no_tags( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, caplog: pytest.LogCaptureFixture, @@ -861,6 +872,7 @@ def test_version_print_last_released_tag_on_detached_head( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -919,6 +931,7 @@ def test_version_print_last_released_tag_on_nonrelease_branch( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -976,6 +989,7 @@ def test_version_print_next_version_fails_on_detached_head( simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, get_commit_def_fn: GetCommitDefFn[CommitParser[ParseResult, ParserOptions]], default_parser: CommitParser[ParseResult, ParserOptions], + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -1038,6 +1052,7 @@ def test_version_print_next_tag_fails_on_detached_head( simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, get_commit_def_fn: GetCommitDefFn[CommitParser[ParseResult, ParserOptions]], default_parser: CommitParser[ParseResult, ParserOptions], + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, diff --git a/tests/e2e/cmd_version/test_version_release_notes.py b/tests/e2e/cmd_version/test_version_release_notes.py index e21059335..08032332d 100644 --- a/tests/e2e/cmd_version/test_version_release_notes.py +++ b/tests/e2e/cmd_version/test_version_release_notes.py @@ -55,6 +55,7 @@ def test_custom_release_notes_template( run_cli: RunCliFn, use_release_notes_template: UseReleaseNotesTemplateFn, retrieve_runtime_context: RetrieveRuntimeContextFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ) -> None: @@ -89,6 +90,7 @@ def test_custom_release_notes_template( # Assert assert_successful_exit_code(result, cli_cmd) + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 assert post_mocker.last_request is not None @@ -128,6 +130,7 @@ def test_default_release_notes_license_statement( license_setting: str, mask_initial_release: bool, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -175,6 +178,7 @@ def test_default_release_notes_license_statement( # Evaluate assert_successful_exit_code(result, cli_cmd) + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 assert post_mocker.last_request is not None diff --git a/tests/e2e/cmd_version/test_version_stamp.py b/tests/e2e/cmd_version/test_version_stamp.py index 892ce59d7..2bce55901 100644 --- a/tests/e2e/cmd_version/test_version_stamp.py +++ b/tests/e2e/cmd_version/test_version_stamp.py @@ -57,6 +57,7 @@ def test_version_only_stamp_version( repo_result: BuiltRepoResult, expected_new_version: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: MagicMock, example_pyproject_toml: Path, diff --git a/tests/e2e/cmd_version/test_version_strict.py b/tests/e2e/cmd_version/test_version_strict.py index a41998ded..e90970bd3 100644 --- a/tests/e2e/cmd_version/test_version_strict.py +++ b/tests/e2e/cmd_version/test_version_strict.py @@ -29,6 +29,7 @@ def test_version_already_released_when_strict( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -78,6 +79,7 @@ def test_version_already_released_when_strict( def test_version_on_nonrelease_branch_when_strict( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, diff --git a/tests/e2e/cmd_version/test_version_upstream_check.py b/tests/e2e/cmd_version/test_version_upstream_check.py new file mode 100644 index 000000000..24fd82d81 --- /dev/null +++ b/tests/e2e/cmd_version/test_version_upstream_check.py @@ -0,0 +1,283 @@ +"""E2E tests for upstream verification during version command.""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING, cast + +import pytest +from git import Repo +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.hvcs.github import Github + +from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD +from tests.fixtures.example_project import change_to_ex_proj_dir +from tests.fixtures.repos.trunk_based_dev import ( + repo_w_trunk_only_conventional_commits, +) +from tests.fixtures.repos.trunk_based_dev.repo_w_tags import ( + build_trunk_only_repo_w_tags, +) +from tests.util import ( + add_text_to_file, + assert_exit_code, + assert_successful_exit_code, + temporary_working_directory, +) + +if TYPE_CHECKING: + from pathlib import Path + + from requests_mock import Mocker + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn + from tests.fixtures.git_repo import ( + BuildSpecificRepoFn, + CommitConvention, + GetCfgValueFromDefFn, + GetGitRepo4DirFn, + GetVersionsFromRepoBuildDefFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_upstream_check_success_no_changes( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + remote_name = "origin" + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # 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_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with contextlib.suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Clone the repo to simulate a local workspace + test_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "repo_clone"), + no_local=True, + ) + with test_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + current_head_sha = test_repo.head.commit.hexsha + + # Act: run PSR on the cloned repo - it should verify upstream and succeed + with temporary_working_directory(str(test_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Verify release occurred as expected + with test_repo: + assert latest_tag in test_repo.tags, "Expected release tag to be created" + assert current_head_sha in [ + parent.hexsha for parent in test_repo.head.commit.parents + ], "Expected new commit to be created on HEAD" + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + assert latest_tag in different_tags, "Expected new tag to be pushed to remote" + + # Verify VCS release was created + expected_vcs_url_post = 1 + assert expected_vcs_url_post == post_mocker.call_count # one vcs release created + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_upstream_check_fails_when_upstream_changed( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, + file_in_repo: str, +): + remote_name = "origin" + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # 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_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with contextlib.suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, f"file://{local_origin.working_dir}") + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the branch name used in the pushed repo + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Clone the repo to simulate a local workspace + test_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "repo_clone"), + no_local=True, + ) + with test_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + # Apply new commit to the original repo to simulate another developer pushing to upstream + add_text_to_file(target_git_repo, str(target_repo_dir / file_in_repo)) + target_git_repo.index.add([str(file_in_repo)]) + target_git_repo.index.commit("feat: upstream change by another developer") + target_git_repo.git.push(remote_name, target_git_repo.active_branch.name) + + # Act: run PSR - it should detect upstream changed and fail + with temporary_working_directory(str(test_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + + # Evaluate + assert_exit_code(1, result, cli_cmd) + expected_err_msg = ( + f"Upstream branch '{remote_name}/{test_repo.active_branch.name}' has changed!" + ) + # Verify error message mentions upstream + assert ( + expected_err_msg in result.stderr + ), f"Expected '{expected_err_msg}' in error output, got: {result.stderr}" + + assert ( + remote_origin_tags_before == remote_origin_tags_after + ), "Expected no new tags to be pushed to remote" + + # Verify no VCS release was created + expected_vcs_url_post = 0 + assert expected_vcs_url_post == post_mocker.call_count # no vcs release created diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 2cfcd67ea..328718fa0 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from unittest.mock import MagicMock +import git.remote as git_remote import pytest from requests_mock import ANY @@ -77,6 +78,26 @@ def mocked_git_push(monkeypatch: MonkeyPatch) -> MagicMock: return mocked_push +@pytest.fixture +def mocked_git_fetch(monkeypatch: MonkeyPatch) -> MagicMock: + """ + Mock the `Repo.git.fetch()` method in `semantic_release.cli.main` and + `git.Repo.remotes.Remote.fetch()`. + """ + mocked_fetch = MagicMock() + cls = prepare_mocked_git_command_wrapper_type(fetch=mocked_fetch) + monkeypatch.setattr(cli_config_module.Repo, "GitCommandWrapperType", cls) + + # define a small wrapper so the MagicMock does not receive `self` + def _fetch(self, *args, **kwargs): + return mocked_fetch(*args, **kwargs) + + # Replace the method on the Remote class used by GitPython + monkeypatch.setattr(git_remote.Remote, "fetch", _fetch, raising=True) + + return mocked_fetch + + @pytest.fixture def config_path(example_project_dir: ExProjectDir) -> Path: return example_project_dir / DEFAULT_CONFIG_FILE diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index e4051183f..46ebed0d6 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -2,6 +2,7 @@ import os import sys +from contextlib import suppress from copy import deepcopy from datetime import datetime, timedelta from functools import reduce @@ -566,15 +567,28 @@ def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: # the implementation on Windows holds some file descriptors open until close is called. with Repo.init(cached_repo_path) as repo: rmtree(str(Path(repo.git_dir, "hooks"))) + + # set up remote origin (including a refs directory) + git_origin_dir = Path(repo.common_dir, "refs", "remotes", "origin") + repo.create_remote(name=git_origin_dir.name, url=example_git_https_url) + git_origin_dir.mkdir(parents=True, exist_ok=True) + # Without this the global config may set it to "master", we want consistency repo.git.branch("-M", DEFAULT_BRANCH_NAME) + with repo.config_writer("repository") as config: config.set_value("user", "name", commit_author.name) config.set_value("user", "email", commit_author.email) config.set_value("commit", "gpgsign", False) config.set_value("tag", "gpgsign", False) - repo.create_remote(name="origin", url=example_git_https_url) + # set up a remote tracking branch for the default branch + config.set_value(f'branch "{DEFAULT_BRANCH_NAME}"', "remote", "origin") + config.set_value( + f'branch "{DEFAULT_BRANCH_NAME}"', + "merge", + f"refs/heads/{DEFAULT_BRANCH_NAME}", + ) # make sure all base files are in index to enable initial commit repo.index.add(("*", ".gitignore")) @@ -981,6 +995,15 @@ def _create_squash_merge_commit( date=commit_dt.isoformat(timespec="seconds"), ) + # After a merge we need to ensure the remote tracking branch is updated + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + # return the commit definition with the sha & message updated return { **commit_def, @@ -1030,6 +1053,14 @@ def _mimic_semantic_release_commit( date=commit_dt.isoformat(timespec="seconds"), ) + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + # ensure commit timestamps are unique (adding one second even though a nanosecond has gone by) commit_dt += timedelta(seconds=1) @@ -1067,6 +1098,14 @@ def _commit_n_rtn_changelog_entry( date=commit_dt.isoformat(timespec="seconds"), ) + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + # Capture the resulting commit message and sha return { **commit_def, @@ -1680,6 +1719,18 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c create_branch_def["name"], commit=start_head.commit, ) + # set up a remote tracking branch for the new branch + with git_repo.config_writer("repository") as config: + config.set_value( + f'branch "{create_branch_def["name"]}"', + "remote", + "origin", + ) + config.set_value( + f'branch "{create_branch_def["name"]}"', + "merge", + f'refs/heads/{create_branch_def["name"]}', + ) new_branch_head.checkout() elif "branch" in ckout_def: @@ -1761,6 +1812,15 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c } ) + # After a merge we need to ensure the remote tracking branch is updated + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + else: raise ValueError(f"Unknown action: {action}") diff --git a/tests/fixtures/monorepos/git_monorepo.py b/tests/fixtures/monorepos/git_monorepo.py index c88fbdb29..59da65df9 100644 --- a/tests/fixtures/monorepos/git_monorepo.py +++ b/tests/fixtures/monorepos/git_monorepo.py @@ -94,15 +94,28 @@ def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: # the implementation on Windows holds some file descriptors open until close is called. with Repo.init(cached_repo_path) as repo: rmtree(str(Path(repo.git_dir, "hooks"))) + + # set up remote origin (including a refs directory) + git_origin_dir = Path(repo.common_dir, "refs", "remotes", "origin") + repo.create_remote(name=git_origin_dir.name, url=example_git_https_url) + git_origin_dir.mkdir(parents=True, exist_ok=True) + # Without this the global config may set it to "master", we want consistency repo.git.branch("-M", DEFAULT_BRANCH_NAME) + with repo.config_writer("repository") as config: config.set_value("user", "name", commit_author.name) config.set_value("user", "email", commit_author.email) config.set_value("commit", "gpgsign", False) config.set_value("tag", "gpgsign", False) - repo.create_remote(name="origin", url=example_git_https_url) + # set up a remote tracking branch for the default branch + config.set_value(f'branch "{DEFAULT_BRANCH_NAME}"', "remote", "origin") + config.set_value( + f'branch "{DEFAULT_BRANCH_NAME}"', + "merge", + f"refs/heads/{DEFAULT_BRANCH_NAME}", + ) # make sure all base files are in index to enable initial commit repo.index.add(("*", ".gitignore")) diff --git a/tests/unit/semantic_release/test_gitproject.py b/tests/unit/semantic_release/test_gitproject.py new file mode 100644 index 000000000..d5795fff7 --- /dev/null +++ b/tests/unit/semantic_release/test_gitproject.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest +from git import GitCommandError + +from semantic_release.errors import ( + DetachedHeadGitError, + GitFetchError, + LocalGitError, + UnknownUpstreamBranchError, + UpstreamBranchChangedError, +) +from semantic_release.gitproject import GitProject + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def mock_repo(): + """Create a mock Git repository with proper structure for new implementation.""" + repo = MagicMock() + + # Mock active branch + active_branch = MagicMock() + active_branch.name = "main" + + # Mock tracking branch + tracking_branch = MagicMock() + tracking_branch.name = "origin/main" + active_branch.tracking_branch = MagicMock(return_value=tracking_branch) + + repo.active_branch = active_branch + + # Mock remotes + remote_obj = MagicMock() + remote_obj.fetch = MagicMock() + + # Mock refs for the remote + ref_obj = MagicMock() + commit_obj = MagicMock() + commit_obj.hexsha = "abc123" + ref_obj.commit = commit_obj + + remote_obj.refs = {"main": ref_obj} + repo.remotes = {"origin": remote_obj} + + # Mock git.rev_parse + repo.git = MagicMock() + repo.git.rev_parse = MagicMock(return_value="abc123") + + # Ensure repo.commit returns a commit-like object with the expected hexsha + # and no parents so that comparisons in verify_upstream_unchanged succeed. + commit_obj.iter_parents = MagicMock(return_value=[]) + repo.commit = MagicMock(return_value=commit_obj) + + return repo + + +def test_verify_upstream_unchanged_success(tmp_path: Path, mock_repo: MagicMock): + """Test that verify_upstream_unchanged succeeds when upstream has not changed.""" + git_project = GitProject(directory=tmp_path) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should not raise an exception + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + # Verify fetch was called + mock_repo.remotes["origin"].fetch.assert_called_once() + # Verify rev_parse was called for HEAD + mock_repo.git.rev_parse.assert_called_once_with("HEAD") + + +def test_verify_upstream_unchanged_fails_when_changed( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged raises error when upstream has changed.""" + git_project = GitProject(directory=tmp_path) + + # Mock git operations with different SHAs + mock_repo.git.rev_parse = MagicMock( + return_value="def456" # Different from upstream + ) + + # Ensure repo.commit returns a commit-like object with the different hexsha + changed_commit = MagicMock() + changed_commit.hexsha = "def456" + changed_commit.iter_parents = MagicMock(return_value=[]) + mock_repo.commit = MagicMock(return_value=changed_commit) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises( + UpstreamBranchChangedError, match=r"Upstream branch .* has changed" + ): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_noop(tmp_path: Path): + """Test that verify_upstream_unchanged does nothing in noop mode.""" + git_project = GitProject(directory=tmp_path) + + mock_repo = MagicMock() + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should not raise an exception and should not call git operations + git_project.verify_upstream_unchanged(noop=True) + + # Verify Repo was not instantiated at all in noop mode + mock_repo_class.assert_not_called() + + +def test_verify_upstream_unchanged_no_tracking_branch( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged raises error when no tracking branch exists.""" + git_project = GitProject(directory=tmp_path) + + # Mock no tracking branch + mock_repo.active_branch.tracking_branch = MagicMock(return_value=None) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should raise UnknownUpstreamBranchError + with pytest.raises( + UnknownUpstreamBranchError, match="No upstream branch found" + ): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_detached_head(tmp_path: Path): + """Test that verify_upstream_unchanged raises error in detached HEAD state.""" + git_project = GitProject(directory=tmp_path) + + mock_repo = MagicMock() + # Simulate detached HEAD by having active_branch raise TypeError + # This is what GitPython does when in a detached HEAD state + type(mock_repo).active_branch = PropertyMock(side_effect=TypeError("detached HEAD")) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should raise DetachedHeadGitError + with pytest.raises(DetachedHeadGitError, match="detached HEAD state"): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_fetch_fails(tmp_path: Path, mock_repo: MagicMock): + """Test that verify_upstream_unchanged raises GitFetchError when fetch fails.""" + git_project = GitProject(directory=tmp_path) + + # Mock fetch to raise an error + mock_repo.remotes["origin"].fetch = MagicMock( + side_effect=GitCommandError("fetch", "error") + ) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises(GitFetchError, match="Failed to fetch from remote"): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_upstream_sha_fails( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged raises error when upstream SHA cannot be determined.""" + git_project = GitProject(directory=tmp_path) + + # Mock refs to raise AttributeError (simulating missing branch) + mock_repo.remotes["origin"].refs = MagicMock() + mock_repo.remotes["origin"].refs.__getitem__ = MagicMock( + side_effect=AttributeError("No such ref") + ) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises( + GitFetchError, match="Unable to determine upstream branch SHA" + ): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_local_ref_sha_fails( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged raises error when local ref SHA cannot be determined.""" + git_project = GitProject(directory=tmp_path) + + # Mock git operations - rev_parse fails + mock_repo.git.rev_parse = MagicMock( + side_effect=GitCommandError("rev-parse", "error") + ) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises( + LocalGitError, + match="Unable to determine the SHA for local ref", + ): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_with_custom_ref( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged works with a custom ref like HEAD~1.""" + git_project = GitProject(directory=tmp_path) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should not raise an exception + git_project.verify_upstream_unchanged(local_ref="HEAD~1", noop=False) + + # Verify rev_parse was called with custom ref + mock_repo.git.rev_parse.assert_called_once_with("HEAD~1")