From 66e739baf7d7d7fad7b0aa9fb74f84e4fb875346 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 9 Nov 2025 20:39:43 -0700 Subject: [PATCH 1/3] test(cmd-version): add CI simulated upstream verification of non-tracked branch --- .../test_version_upstream_check.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/e2e/cmd_version/test_version_upstream_check.py b/tests/e2e/cmd_version/test_version_upstream_check.py index 24fd82d81..646eeded0 100644 --- a/tests/e2e/cmd_version/test_version_upstream_check.py +++ b/tests/e2e/cmd_version/test_version_upstream_check.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +from pathlib import PureWindowsPath from typing import TYPE_CHECKING, cast import pytest @@ -159,6 +160,137 @@ def test_version_upstream_check_success_no_changes( 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_success_no_changes_untracked_branch( + 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, +): + """Test that PSR succeeds when the upstream branch is untracked but unchanged.""" + 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}" + ) + + # Simulate CI environment after someone pushes to the repo + ci_commit_sha = target_git_repo.head.commit.hexsha + ci_branch = target_git_repo.active_branch.name + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Simulate a CI environment by fetching the repo to a new location + test_repo = Repo.init(str(example_project_dir / "ci_repo")) + 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) + + # Configure and retrieve the repository (see GitHub actions/checkout@v5) + test_repo.git.remote( + "add", + remote_name, + f"file:///{PureWindowsPath(local_origin.working_dir).as_posix()}", + ) + test_repo.git.fetch("--depth=1", remote_name, ci_commit_sha) + + # Simulate CI environment and recommended workflow (in docs) + # NOTE: this could be done in 1 step, but most CI pipelines are doing it in 2 steps + # 1. Checkout the commit sha (detached head) + test_repo.git.checkout(ci_commit_sha, force=True) + # 2. Forcefully set the branch to the current detached head + test_repo.git.checkout("-B", ci_branch) + + # 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 ci_commit_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", [ From 8578ff2e6c4f7ee612f242e6cc1f6f1d82608412 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 9 Nov 2025 18:56:13 -0700 Subject: [PATCH 2/3] fix(cmd-version): fix upstream change detection to succeed without branch tracking --- src/semantic_release/cli/commands/version.py | 6 +++- src/semantic_release/gitproject.py | 38 ++++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index ac7a8e374..f02e9a505 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -749,7 +749,11 @@ def version( # noqa: C901 # 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) + project.verify_upstream_unchanged( + local_ref="HEAD~1", + upstream_ref=config.remote.name, + noop=opts.noop, + ) except UpstreamBranchChangedError as exc: click.echo(str(exc), err=True) click.echo( diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index 9ea156da7..a5e4e4e19 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -335,17 +335,23 @@ def git_push_tag( 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 + def verify_upstream_unchanged( # noqa: C901 + self, local_ref: str = "HEAD", upstream_ref: str = "origin", 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 upstream_ref: The name of the upstream remote or specific remote branch (default: origin) :param noop: Whether to skip the actual verification (for dry-run mode) :raises UpstreamBranchChangedError: If the upstream branch has changed """ + if not local_ref.strip(): + raise ValueError("Local reference cannot be empty") + if not upstream_ref.strip(): + raise ValueError("Upstream reference cannot be empty") + if noop: noop_report( indented( @@ -368,12 +374,30 @@ def verify_upstream_unchanged( 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) + if (tracking_branch := active_branch.tracking_branch()) is not None: + upstream_full_ref_name = tracking_branch.name + self.logger.info("Upstream branch name: %s", upstream_full_ref_name) + else: + # If no tracking branch is set, derive it + upstream_name = ( + upstream_ref.strip() + if upstream_ref.find("/") == -1 + else upstream_ref.strip().split("/", maxsplit=1)[0] + ) + + if not repo.remotes or upstream_name not in repo.remotes: + err_msg = "No remote found; cannot verify upstream state!" + raise UnknownUpstreamBranchError(err_msg) + + upstream_full_ref_name = ( + f"{upstream_name}/{active_branch.name}" + if upstream_ref.find("/") == -1 + else upstream_ref.strip() + ) - upstream_full_ref_name = tracking_branch.name - self.logger.info("Upstream branch name: %s", upstream_full_ref_name) + if upstream_full_ref_name not in repo.refs: + err_msg = f"No upstream branch found for '{active_branch.name}'; cannot verify upstream state!" + raise UnknownUpstreamBranchError(err_msg) # Extract the remote name from the tracking branch # tracking_branch.name is in the format "remote/branch" From f0a0b254f9674d0826a7eb41fcd230bdd41a8248 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 9 Nov 2025 19:22:38 -0700 Subject: [PATCH 3/3] test(gitproject): update unit tests to exercise non-tracking branch derivation errors --- .../unit/semantic_release/test_gitproject.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/unit/semantic_release/test_gitproject.py b/tests/unit/semantic_release/test_gitproject.py index 7b37a9756..09193d317 100644 --- a/tests/unit/semantic_release/test_gitproject.py +++ b/tests/unit/semantic_release/test_gitproject.py @@ -38,6 +38,7 @@ class RepoMock(MagicMock): git: MockGit git_dir: str commit: MagicMock + refs: dict[str, MagicMock] @pytest.fixture @@ -70,6 +71,7 @@ def mock_repo(tmp_path: Path) -> RepoMock: remote_obj.refs = {"main": ref_obj} repo.remotes = {"origin": remote_obj} + repo.refs = {"origin/main": ref_obj} # Mock git.rev_parse repo.git = MagicMock() @@ -146,16 +148,38 @@ def test_verify_upstream_unchanged_noop( mock_repo.assert_not_called() -def test_verify_upstream_unchanged_no_tracking_branch( +def test_verify_upstream_unchanged_no_remote( mock_gitproject: GitProject, mock_repo: RepoMock ): - """Test that verify_upstream_unchanged raises error when no tracking branch exists.""" - # Mock no tracking branch + """Test that verify_upstream_unchanged raises error when no remote exists.""" + # Mock no remote + mock_repo.remotes = {} + # Simulate no tracking branch mock_repo.active_branch.tracking_branch = MagicMock(return_value=None) + # Should raise UnknownUpstreamBranchError + with pytest.raises( + UnknownUpstreamBranchError, + match="No remote found; cannot verify upstream state!", + ): + mock_gitproject.verify_upstream_unchanged( + local_ref="HEAD", upstream_ref="upstream", noop=False + ) + + +def test_verify_upstream_unchanged_no_upstream_ref( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged raises error when no upstream ref exists.""" + # Simulate no tracking branch + mock_repo.active_branch.tracking_branch = MagicMock(return_value=None) + mock_repo.refs = {} # No refs available + # Should raise UnknownUpstreamBranchError with pytest.raises(UnknownUpstreamBranchError, match="No upstream branch found"): - mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) + mock_gitproject.verify_upstream_unchanged( + local_ref="HEAD", upstream_ref="origin", noop=False + ) def test_verify_upstream_unchanged_detached_head(