From 7086257b641e241dc9a8d742bd62e3698a8b8173 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 9 Nov 2025 23:49:52 -0700 Subject: [PATCH 1/2] fix(cmd-version): fix upstream change detection to succeed without branch tracking (#1369) * test(cmd-version): add CI simulated upstream verification of non-tracked branch * test(gitproject): update unit tests to exercise non-tracking branch derivation errors --- src/semantic_release/cli/commands/version.py | 6 +- src/semantic_release/gitproject.py | 38 ++++- .../test_version_upstream_check.py | 132 ++++++++++++++++++ .../unit/semantic_release/test_gitproject.py | 32 ++++- 4 files changed, 196 insertions(+), 12 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" 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", [ 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( From bd8f84cb4e967bb634d20b6080cf2d402966bccb Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 10 Nov 2025 07:08:41 +0000 Subject: [PATCH 2/2] chore: release v10.5.1 Automatically generated by python-semantic-release --- CHANGELOG.rst | 15 +++++++++++++++ .../automatic-releases/github-actions.rst | 14 +++++++------- pyproject.toml | 2 +- src/gh_action/requirements.txt | 2 +- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f175879c6..ee676dc19 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,21 @@ CHANGELOG ========= +.. _changelog-v10.5.1: + +v10.5.1 (2025-11-10) +==================== + +🪲 Bug Fixes +------------ + +* **cmd-version**: Fix upstream change detection to succeed without branch tracking (`PR#1369`_, + `7086257`_) + +.. _7086257: https://github.com/python-semantic-release/python-semantic-release/commit/7086257b641e241dc9a8d742bd62e3698a8b8173 +.. _PR#1369: https://github.com/python-semantic-release/python-semantic-release/pull/1369 + + .. _changelog-v10.5.0: v10.5.0 (2025-11-09) diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index ed9c2d3e0..578bd3dc8 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -893,14 +893,14 @@ to the GitHub Release Assets as well. - name: Action | Semantic Version Release id: release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v10.5.0 + uses: python-semantic-release/python-semantic-release@v10.5.1 with: github_token: ${{ secrets.GITHUB_TOKEN }} git_committer_name: "github-actions" git_committer_email: "actions@users.noreply.github.com" - name: Publish | Upload to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.5.0 + uses: python-semantic-release/publish-action@v10.5.1 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -1005,7 +1005,7 @@ The equivalent GitHub Action configuration would be: - name: Action | Semantic Version Release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v10.5.0 + uses: python-semantic-release/python-semantic-release@v10.5.1 with: github_token: ${{ secrets.GITHUB_TOKEN }} force: patch @@ -1064,14 +1064,14 @@ Publish Action. - name: Release submodule 1 id: release-submod-1 - uses: python-semantic-release/python-semantic-release@v10.5.0 + uses: python-semantic-release/python-semantic-release@v10.5.1 with: directory: ${{ env.SUBMODULE_1_DIR }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Release submodule 2 id: release-submod-2 - uses: python-semantic-release/python-semantic-release@v10.5.0 + uses: python-semantic-release/python-semantic-release@v10.5.1 with: directory: ${{ env.SUBMODULE_2_DIR }} github_token: ${{ secrets.GITHUB_TOKEN }} @@ -1083,7 +1083,7 @@ Publish Action. # ------------------------------------------------------------------- # - name: Publish | Upload package 1 to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.5.0 + uses: python-semantic-release/publish-action@v10.5.1 if: steps.release-submod-1.outputs.released == 'true' with: directory: ${{ env.SUBMODULE_1_DIR }} @@ -1091,7 +1091,7 @@ Publish Action. tag: ${{ steps.release-submod-1.outputs.tag }} - name: Publish | Upload package 2 to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.5.0 + uses: python-semantic-release/publish-action@v10.5.1 if: steps.release-submod-2.outputs.released == 'true' with: directory: ${{ env.SUBMODULE_2_DIR }} diff --git a/pyproject.toml b/pyproject.toml index be6f4ac91..4fc233d69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-semantic-release" -version = "10.5.0" +version = "10.5.1" description = "Automatic Semantic Versioning for Python projects" requires-python = "~= 3.8" license = { text = "MIT" } diff --git a/src/gh_action/requirements.txt b/src/gh_action/requirements.txt index 65e627774..f0ae366af 100644 --- a/src/gh_action/requirements.txt +++ b/src/gh_action/requirements.txt @@ -1 +1 @@ -python-semantic-release == 10.5.0 +python-semantic-release == 10.5.1