Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/semantic_release/cli/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
38 changes: 31 additions & 7 deletions src/semantic_release/gitproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"
Expand Down
132 changes: 132 additions & 0 deletions tests/e2e/cmd_version/test_version_upstream_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import contextlib
from pathlib import PureWindowsPath
from typing import TYPE_CHECKING, cast

import pytest
Expand Down Expand Up @@ -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",
[
Expand Down
32 changes: 28 additions & 4 deletions tests/unit/semantic_release/test_gitproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class RepoMock(MagicMock):
git: MockGit
git_dir: str
commit: MagicMock
refs: dict[str, MagicMock]


@pytest.fixture
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
Loading