diff --git a/src/gh_action/Dockerfile b/src/gh_action/Dockerfile index 7166042ab..7ccbd3d40 100644 --- a/src/gh_action/Dockerfile +++ b/src/gh_action/Dockerfile @@ -16,6 +16,8 @@ RUN \ apt update && apt install -y --no-install-recommends \ # install git with git-lfs support git git-lfs \ + # install ssh client for git signing + openssh-client \ # install python cmodule / binary module build utilities python3-dev gcc make cmake cargo \ # Configure global pip diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index d96e0d8ab..7a6fa26ef 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -753,6 +753,7 @@ def version( # noqa: C901 project.verify_upstream_unchanged( local_ref="HEAD~1", upstream_ref=config.remote.name, + remote_url=remote_url, noop=opts.noop, ) except UpstreamBranchChangedError as exc: diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index a5e4e4e19..cc86d33b5 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -336,13 +336,18 @@ def git_push_tag( raise GitPushError(f"Failed to push tag ({tag}) to remote") from err def verify_upstream_unchanged( # noqa: C901 - self, local_ref: str = "HEAD", upstream_ref: str = "origin", noop: bool = False + self, + local_ref: str = "HEAD", + upstream_ref: str = "origin", + remote_url: str | None = None, + 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 remote_url: Optional authenticated remote URL to use for fetching (default: None, uses configured remote) :param noop: Whether to skip the actual verification (for dry-run mode) :raises UpstreamBranchChangedError: If the upstream branch has changed @@ -409,7 +414,46 @@ def verify_upstream_unchanged( # noqa: C901 # Fetch the latest changes from the remote self.logger.info("Fetching latest changes from remote '%s'", remote_name) try: - remote_ref_obj.fetch() + # Check if we should use authenticated URL for fetch + # Only use remote_url if: + # 1. It's provided and different from the configured remote URL + # 2. It contains authentication credentials (@ symbol) + # 3. The configured remote is NOT a local path, file:// URL, or test URL (example.com) + # This ensures we don't break tests or local development + configured_url = remote_ref_obj.url + is_local_or_test_remote = ( + configured_url.startswith(("file://", "/", "C:/", "H:/")) + or "example.com" in configured_url + or not configured_url.startswith( + ( + "https://", + "http://", + "git://", + "git@", + "ssh://", + "git+ssh://", + ) + ) + ) + + use_authenticated_fetch = ( + remote_url + and "@" in remote_url + and remote_url != configured_url + and not is_local_or_test_remote + ) + + if use_authenticated_fetch: + # Use authenticated remote URL for fetch + # Fetch the remote branch and update the local tracking ref + repo.git.fetch( + remote_url, + f"refs/heads/{remote_branch_name}:refs/remotes/{upstream_full_ref_name}", + ) + else: + # Use the default remote configuration for local paths, + # file:// URLs, test URLs, or when no authentication is needed + remote_ref_obj.fetch() except GitCommandError as err: self.logger.exception(str(err)) err_msg = f"Failed to fetch from remote '{remote_name}'" diff --git a/tests/gh_action/suite/test_version_ssh_signing.sh b/tests/gh_action/suite/test_version_ssh_signing.sh new file mode 100644 index 000000000..0f0c82c1a --- /dev/null +++ b/tests/gh_action/suite/test_version_ssh_signing.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +__file__="$(realpath "${BASH_SOURCE[0]}")" +__directory__="$(dirname "${__file__}")" + +if ! [ "${UTILS_LOADED}" = "true" ]; then + # shellcheck source=tests/utils.sh + source "$__directory__/../utils.sh" +fi + +test_version_ssh_signing() { + # Test that SSH signing keys are correctly configured in the action + # We will generate an SSH key pair and pass it to the action to ensure + # the ssh-agent and ssh-add commands work correctly + local index="${1:?Index not provided}" + local test_name="${FUNCNAME[0]}" + + # Generate a temporary SSH key pair for testing + local ssh_key_dir + ssh_key_dir="$(mktemp -d)" + local ssh_private_key_file="$ssh_key_dir/signing_key" + local ssh_public_key_file="$ssh_key_dir/signing_key.pub" + + # Generate SSH key pair (Ed25519 for faster generation and smaller keys) + # Note: Using empty passphrase (-N "") for test purposes only + if ! ssh-keygen -t ed25519 -N "" -f "$ssh_private_key_file" -C "test@example.com" >/dev/null 2>&1; then + error "Failed to generate SSH key pair!" + rm -rf "$ssh_key_dir" + return 1 + fi + + # Read the generated keys + local ssh_public_key + local ssh_private_key + ssh_public_key="$(cat "$ssh_public_key_file")" + ssh_private_key="$(cat "$ssh_private_key_file")" + + # Clean up the temporary key files + rm -rf "$ssh_key_dir" + + # Create expectations & set env variables that will be passed in for Docker command + local WITH_VAR_GITHUB_TOKEN="ghp_1x2x3x4x5x6x7x8x9x0x1x2x3x4x5x6x7x8x9x0" + local WITH_VAR_NO_OPERATION_MODE="true" + local WITH_VAR_VERBOSITY="2" + local WITH_VAR_GIT_COMMITTER_NAME="Test User" + local WITH_VAR_GIT_COMMITTER_EMAIL="test@example.com" + local WITH_VAR_SSH_PUBLIC_SIGNING_KEY="$ssh_public_key" + local WITH_VAR_SSH_PRIVATE_SIGNING_KEY="$ssh_private_key" + + # Expected messages in output + local expected_ssh_setup_msg="SSH Key pair found, configuring signing..." + local expected_psr_cmd=".*/bin/semantic-release -vv --noop version" + + # Execute the test & capture output + local output="" + if ! output="$(run_test "$index. $test_name" 2>&1)"; then + # Log the output for debugging purposes + log "$output" + error "fatal error occurred!" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure SSH setup message is present + if ! printf '%s' "$output" | grep -q "$expected_ssh_setup_msg"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find SSH setup message in the output!" + error "\tExpected Message: $expected_ssh_setup_msg" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure ssh-agent was started successfully + if ! printf '%s' "$output" | grep -q "Agent pid"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find ssh-agent start message in the output!" + error "\tExpected Message pattern: 'Agent pid'" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure ssh-add was successful + if ! printf '%s' "$output" | grep -q "Identity added"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find ssh-add success message in the output!" + error "\tExpected Message pattern: 'Identity added'" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure the expected command is present + if ! printf '%s' "$output" | grep -q -E "$expected_psr_cmd"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find the expected command in the output!" + error "\tExpected Command: $expected_psr_cmd" + error "::error:: $test_name failed!" + return 1 + fi + + log "\n$index. $test_name: PASSED!" +} diff --git a/tests/unit/semantic_release/test_gitproject.py b/tests/unit/semantic_release/test_gitproject.py index 09193d317..58bfedf81 100644 --- a/tests/unit/semantic_release/test_gitproject.py +++ b/tests/unit/semantic_release/test_gitproject.py @@ -62,6 +62,7 @@ def mock_repo(tmp_path: Path) -> RepoMock: # Mock remotes remote_obj = MagicMock() remote_obj.fetch = MagicMock() + remote_obj.url = "https://github.com/owner/repo.git" # Set a non-test URL # Mock refs for the remote ref_obj = MagicMock() @@ -249,6 +250,25 @@ def test_verify_upstream_unchanged_with_custom_ref( mock_repo.git.rev_parse.assert_called_once_with("HEAD~1") +def test_verify_upstream_unchanged_with_remote_url( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged uses remote_url when provided.""" + remote_url = "https://token:x-oauth-basic@github.com/owner/repo.git" + + # Should not raise an exception + mock_gitproject.verify_upstream_unchanged( + local_ref="HEAD", remote_url=remote_url, noop=False + ) + + # Verify git.fetch was called with the remote_url and proper refspec instead of remote_ref_obj.fetch() + mock_repo.git.fetch.assert_called_once_with( + remote_url, "refs/heads/main:refs/remotes/origin/main" + ) + # Verify that remote_ref_obj.fetch() was NOT called + mock_repo.remotes["origin"].fetch.assert_not_called() + + def test_is_shallow_clone_true(mock_gitproject: GitProject, tmp_path: Path) -> None: """Test is_shallow_clone returns True when shallow file exists.""" # Create a shallow file