Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

fix(cmd-version): fix upstream change detection to succeed w/o branch tracking#1369

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
Show file tree
Hide file tree
Changes fromall 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 deletionsrc/semantic_release/cli/commands/version.py
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssrc/semantic_release/gitproject.py
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionstests/e2e/cmd_version/test_version_upstream_check.py
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 deletionstests/unit/semantic_release/test_gitproject.py
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -146,16 +148,38 @@ def test_verify_upstream_unchanged_noop(
mock_repo.assert_not_called()


deftest_verify_upstream_unchanged_no_tracking_branch(
deftest_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

[8]ページ先頭

©2009-2025 Movatter.jp