From 18b7edadd7e7dfe42ec43110acf5e1bd8bcd7eb3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 10:13:06 -0800 Subject: [PATCH 1/2] fix(github-action): fix failed signing issue when ssh was missing from action environment (#1389) Install openssh-client in the slim container image Resolves: #1376 * test(gh-action): add SSH signing test case Add test to verify SSH signing key configuration in the GitHub Action. The test generates an SSH key pair and validates that ssh-agent and ssh-add commands execute successfully when SSH signing keys are provided. --- src/gh_action/Dockerfile | 2 + .../suite/test_version_ssh_signing.sh | 105 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/gh_action/suite/test_version_ssh_signing.sh 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/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!" +} From e164f682bfa4ca1e7cbe77aa068202fd8094eec7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 10:43:58 -0800 Subject: [PATCH 2/2] fix(cmd-version): resolve unauthenticated git repo issues for upstream verification (#1388) This change updates verify_upstream_unchanged to accept and use an authenticated remote_url parameter when fetching from the remote, mirroring the approach used for git push operations. This resolves authentication issues when verifying upstream state in repositories that require token authentication for fetch operations. Resolves: #1373 * test(gitproject): add another unit test for verify upstream unchanged with authed url --- src/semantic_release/cli/commands/version.py | 1 + src/semantic_release/gitproject.py | 48 ++++++++++++++++++- .../unit/semantic_release/test_gitproject.py | 20 ++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) 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/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