From 726cf74ad8f23b77e44aac8f34013a965a71e695 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 08:09:45 +0000 Subject: [PATCH 1/2] feat(github-actions): add GPG signing support Add support for GPG signing in GitHub Action with the following changes: - Add gpg_private_signing_key and gpg_passphrase inputs to action.yml - Implement GPG signing configuration in action.sh - Add validation to prevent both SSH and GPG keys from being set - Install gnupg package in Dockerfile for GPG support - Document new GPG signing inputs and usage examples - Add test case for mutual exclusivity validation NOTICE: This release adds the ability to sign semantic-release commits and tags with a GPG key instead of an SSH key pair. The two configurations are not compatible with each other so you can either have GPG configured or SSH configured, NOT BOTH! The GitHub Action will fail at runtime if you attempt to provide both sets of options --- action.yml | 8 ++ .../automatic-releases/github-actions.rst | 102 +++++++++++++++++- src/gh_action/Dockerfile | 2 + src/gh_action/action.sh | 86 +++++++++++++++ tests/gh_action/suite/test_gpg_signing.sh | 101 +++++++++++++++++ 5 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 tests/gh_action/suite/test_gpg_signing.sh diff --git a/action.yml b/action.yml index 0b9137bdf..c5ec6b840 100644 --- a/action.yml +++ b/action.yml @@ -51,6 +51,14 @@ inputs: required: false description: The ssh private key used to sign commits + gpg_private_signing_key: + required: false + description: The GPG private key used to sign commits and tags + + gpg_passphrase: + required: false + description: The passphrase for the GPG private key (if encrypted) + strict: default: "false" required: false diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index d1469ffb3..de0c15d20 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -413,7 +413,12 @@ before the :ref:`version ` subcommand. ``ssh_public_signing_key`` """""""""""""""""""""""""" -The public key associated with the private key used in signing a commit and tag. +The public key associated with the private key used in signing a commit and tag +using SSH signing. + +.. note:: + SSH and GPG signing are mutually exclusive. You can only use one signing method + at a time. If both SSH and GPG signing keys are provided, the action will fail. **Required:** ``false`` @@ -424,7 +429,53 @@ The public key associated with the private key used in signing a commit and tag. ``ssh_private_signing_key`` """"""""""""""""""""""""""" -The private key used to sign a commit and tag. +The private key used to sign a commit and tag using SSH signing. + +.. note:: + SSH and GPG signing are mutually exclusive. You can only use one signing method + at a time. If both SSH and GPG signing keys are provided, the action will fail. + +**Required:** ``false`` + +---- + +.. _gh_actions-psr-inputs-gpg_private_signing_key: + +``gpg_private_signing_key`` +""""""""""""""""""""""""""" + +The GPG private key used to sign commits and tags. This should be the ASCII-armored +private key exported from GPG. + +To export your GPG private key in the correct format: + +.. code:: shell + + gpg --armor --export-secret-keys YOUR_KEY_ID + +.. note:: + GPG and SSH signing are mutually exclusive. You can only use one signing method + at a time. If both GPG and SSH signing keys are provided, the action will fail. + +.. warning:: + Store the GPG private key as a GitHub secret. Never commit it directly to your + repository. + +**Required:** ``false`` + +---- + +.. _gh_actions-psr-inputs-gpg_passphrase: + +``gpg_passphrase`` +"""""""""""""""""" + +The passphrase for the GPG private key, if the key is encrypted. If your GPG key +does not have a passphrase, you can omit this input. + +.. warning:: + Store the GPG passphrase as a GitHub secret. Never commit it directly to your + repository. **Required:** ``false`` @@ -1021,6 +1072,53 @@ The equivalent GitHub Action configuration would be: .. _Publish Action Manual Release Workflow: https://github.com/python-semantic-release/publish-action/blob/main/.github/workflows/release.yml +GPG Signing Example +------------------- + +If you want to sign your commits and tags with GPG instead of SSH, you can provide +your GPG private key and optionally a passphrase. First, you'll need to export your +GPG private key and store it as a GitHub secret. + +**Exporting your GPG key:** + +.. code:: shell + + # List your GPG keys to find the key ID + gpg --list-secret-keys --keyid-format LONG + + # Export the private key (replace YOUR_KEY_ID with your actual key ID) + gpg --armor --export-secret-keys YOUR_KEY_ID + +Copy the output (including the ``-----BEGIN PGP PRIVATE KEY BLOCK-----`` and +``-----END PGP PRIVATE KEY BLOCK-----`` lines) and store it as a GitHub secret, +for example ``GPG_PRIVATE_KEY``. + +If your key has a passphrase, store that as a separate secret, for example +``GPG_PASSPHRASE``. + +**Using GPG signing in the workflow:** + +.. code:: yaml + + - name: Action | Semantic Version Release with GPG Signing + # Adjust tag with desired version if applicable. + uses: python-semantic-release/python-semantic-release@v10.5.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + git_committer_name: "github-actions" + git_committer_email: "actions@users.noreply.github.com" + gpg_private_signing_key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} # Optional, only if key is encrypted + +.. important:: + GPG and SSH signing are mutually exclusive. You cannot use both at the same time. + If you provide both ``gpg_private_signing_key`` and ``ssh_private_signing_key`` + (or ``ssh_public_signing_key``), the action will fail with an error. + +.. note:: + If your GPG key does not have a passphrase, you can omit the ``gpg_passphrase`` + input. + .. _gh_actions-monorepo: Actions with Monorepos diff --git a/src/gh_action/Dockerfile b/src/gh_action/Dockerfile index 7ccbd3d40..a598678dc 100644 --- a/src/gh_action/Dockerfile +++ b/src/gh_action/Dockerfile @@ -18,6 +18,8 @@ RUN \ git git-lfs \ # install ssh client for git signing openssh-client \ + # install gnupg for GPG signing support + gnupg \ # install python cmodule / binary module build utilities python3-dev gcc make cmake cargo \ # Configure global pip diff --git a/src/gh_action/action.sh b/src/gh_action/action.sh index 7ae58f68d..b99c4db4a 100644 --- a/src/gh_action/action.sh +++ b/src/gh_action/action.sh @@ -142,6 +142,13 @@ fi # and https://github.com/actions/runner-images/issues/6775#issuecomment-1410270956 git config --system --add safe.directory "*" +# Check for conflicting signing key configurations +if [[ -n "$INPUT_SSH_PUBLIC_SIGNING_KEY" || -n "$INPUT_SSH_PRIVATE_SIGNING_KEY" ]] && [[ -n "$INPUT_GPG_PRIVATE_SIGNING_KEY" ]]; then + echo >&2 "Error: Both SSH and GPG signing keys are provided. Please use only one signing method." + exit 1 +fi + +# SSH Signing Configuration if [[ -n "$INPUT_SSH_PUBLIC_SIGNING_KEY" && -n "$INPUT_SSH_PRIVATE_SIGNING_KEY" ]]; then echo "SSH Key pair found, configuring signing..." @@ -175,6 +182,85 @@ if [[ -n "$INPUT_SSH_PUBLIC_SIGNING_KEY" && -n "$INPUT_SSH_PRIVATE_SIGNING_KEY" git config --global tag.gpgsign true fi +# GPG Signing Configuration +if [[ -n "$INPUT_GPG_PRIVATE_SIGNING_KEY" ]]; then + echo "GPG private key found, configuring signing..." + + # Create GPG home directory + mkdir -p ~/.gnupg + chmod 700 ~/.gnupg + + # Import the GPG key + # Use --batch mode to prevent interactive prompts + if [[ -n "$INPUT_GPG_PASSPHRASE" ]]; then + # If passphrase is provided, import with passphrase + echo -e "$INPUT_GPG_PRIVATE_SIGNING_KEY" | gpg --batch --yes --passphrase "$INPUT_GPG_PASSPHRASE" --import 2>&1 + else + # Import without passphrase + echo -e "$INPUT_GPG_PRIVATE_SIGNING_KEY" | gpg --batch --yes --import 2>&1 + fi + + # Get the key ID from the imported key using machine-readable format + GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ {print $5; exit}') + + if [ -z "$GPG_KEY_ID" ]; then + echo >&2 "Error: Failed to import GPG key or extract key ID" + gpg --list-secret-keys 2>&1 || true + exit 1 + fi + + # Validate that the key ID looks correct (should be 16 hex characters for long format) + if ! printf '%s' "$GPG_KEY_ID" | grep -qE '^[0-9A-Fa-f]{16}$'; then + echo >&2 "Warning: Extracted GPG Key ID may not be in expected format: $GPG_KEY_ID" + fi + + echo "GPG Key ID: $GPG_KEY_ID" + + # Configure git to use GPG signing + git config --global user.signingKey "$GPG_KEY_ID" + git config --global commit.gpgsign true + git config --global tag.gpgsign true + git config --global gpg.format openpgp + git config --global gpg.program gpg + + # If passphrase is provided, configure gpg-agent for non-interactive use + if [[ -n "$INPUT_GPG_PASSPHRASE" ]]; then + # Configure gpg-agent to allow preset passphrases and use pinentry-loopback + cat > ~/.gnupg/gpg-agent.conf <<-EOF + allow-preset-passphrase + allow-loopback-pinentry + default-cache-ttl 21600 + max-cache-ttl 21600 + EOF + + # Configure GPG to use loopback pinentry for passphrase + cat > ~/.gnupg/gpg.conf <<-EOF + batch + yes + pinentry-mode loopback + EOF + + # Reload gpg-agent + gpg-connect-agent reloadagent /bye 2>&1 || true + + # Preset the passphrase using gpg-preset-passphrase if available + # Get the keygrip for the main signing key using machine-readable format + KEYGRIP=$(gpg --with-keygrip --with-colons --list-secret-keys "$GPG_KEY_ID" | awk -F: '/^grp:/ {print $10; exit}') + + if [[ -z "$KEYGRIP" ]]; then + echo "Warning: Could not extract keygrip for passphrase preset, will rely on loopback pinentry" + elif [[ -x /usr/lib/gnupg/gpg-preset-passphrase ]]; then + echo "$INPUT_GPG_PASSPHRASE" | /usr/lib/gnupg/gpg-preset-passphrase --preset "$KEYGRIP" 2>&1 || true + else + echo "Warning: gpg-preset-passphrase not found, will rely on loopback pinentry" + fi + + # Set GPG_TTY for compatibility with some GPG configurations + # While loopback pinentry should work without this, some systems may still need it + export GPG_TTY=$(tty 2>/dev/null || echo "/dev/null") + fi +fi + # Copy inputs into correctly-named environment variables export GH_TOKEN="${INPUT_GITHUB_TOKEN}" diff --git a/tests/gh_action/suite/test_gpg_signing.sh b/tests/gh_action/suite/test_gpg_signing.sh new file mode 100644 index 000000000..d04ed229b --- /dev/null +++ b/tests/gh_action/suite/test_gpg_signing.sh @@ -0,0 +1,101 @@ +#!/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 + +# Common test constants +readonly TEST_GITHUB_TOKEN="ghp_1x2x3x4x5x6x7x8x9x0x1x2x3x4x5x6x7x8x9x0" +readonly TEST_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest" +readonly TEST_SSH_PRIVATE_KEY="-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----" +readonly TEST_GPG_PRIVATE_KEY="-----BEGIN PGP PRIVATE KEY BLOCK-----\ntest\n-----END PGP PRIVATE KEY BLOCK-----" + +# Helper function to test mutual exclusivity +# Parameters: +# $1: test index +# $2: test name +# $3: ssh_public_key value (optional) +# $4: ssh_private_key value (optional) +# $5: description for error message +test_mutual_exclusivity() { + local index="${1:?Index not provided}" + local test_name="${2:?Test name not provided}" + local ssh_public="${3:-}" + local ssh_private="${4:-}" + local description="${5:?Description not provided}" + + # Set common env variables + local WITH_VAR_GITHUB_TOKEN="$TEST_GITHUB_TOKEN" + local WITH_VAR_NO_OPERATION_MODE="true" + local WITH_VAR_VERBOSITY="1" + local WITH_VAR_GPG_PRIVATE_SIGNING_KEY="$TEST_GPG_PRIVATE_KEY" + + # Set SSH keys if provided + if [[ -n "$ssh_public" ]]; then + local WITH_VAR_SSH_PUBLIC_SIGNING_KEY="$ssh_public" + fi + if [[ -n "$ssh_private" ]]; then + local WITH_VAR_SSH_PRIVATE_SIGNING_KEY="$ssh_private" + fi + + # Execute the test & capture output + # This test should fail with a specific error message + local output="" + output="$(run_test "$index. $test_name" 2>&1)" && { + # If the command succeeded, that's unexpected - the test should fail + log "$output" + error "Expected the action to fail when $description, but it succeeded!" + error "::error:: $test_name failed!" + return 1 + } + + # Evaluate the output to ensure the expected error message is present + local expected_error="Both SSH and GPG signing keys are provided" + if ! printf '%s' "$output" | grep -q "$expected_error"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find the expected error message in the output!" + error "\tExpected Error: $expected_error" + error "::error:: $test_name failed!" + return 1 + fi + + log "\n$index. $test_name: PASSED!" +} + +test_gpg_signing_error_when_both_ssh_and_gpg() { + # Test that the action fails when both SSH and GPG signing keys are provided + local index="${1:?Index not provided}" + local test_name="${FUNCNAME[0]}" + + test_mutual_exclusivity "$index" "$test_name" \ + "$TEST_SSH_PUBLIC_KEY" \ + "$TEST_SSH_PRIVATE_KEY" \ + "both SSH keys and GPG key are provided" +} + +test_gpg_signing_error_when_ssh_public_and_gpg() { + # Test that the action fails when SSH public key and GPG signing key are provided + local index="${1:?Index not provided}" + local test_name="${FUNCNAME[0]}" + + test_mutual_exclusivity "$index" "$test_name" \ + "$TEST_SSH_PUBLIC_KEY" \ + "" \ + "SSH public key and GPG key are provided" +} + +test_gpg_signing_error_when_ssh_private_and_gpg() { + # Test that the action fails when SSH private key and GPG signing key are provided + local index="${1:?Index not provided}" + local test_name="${FUNCNAME[0]}" + + test_mutual_exclusivity "$index" "$test_name" \ + "" \ + "$TEST_SSH_PRIVATE_KEY" \ + "SSH private key and GPG key are provided" +} From 444ca49cb09913d22a9f5c250069029f96acc2d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:39:30 +0000 Subject: [PATCH 2/2] fix(tests): rename helper function to avoid test runner detection The test_mutual_exclusivity helper function was being detected as a test by the test runner's pattern matching (grep "^test_"), causing it to be executed with insufficient parameters and failing with "Test name not provided". Renamed to verify_mutual_exclusivity to exclude it from automatic test discovery while maintaining clear naming convention for helper functions. Co-authored-by: codejedi365 <17354856+codejedi365@users.noreply.github.com> --- tests/gh_action/suite/test_gpg_signing.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/gh_action/suite/test_gpg_signing.sh b/tests/gh_action/suite/test_gpg_signing.sh index d04ed229b..f5f71406f 100644 --- a/tests/gh_action/suite/test_gpg_signing.sh +++ b/tests/gh_action/suite/test_gpg_signing.sh @@ -14,14 +14,14 @@ readonly TEST_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest" readonly TEST_SSH_PRIVATE_KEY="-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----" readonly TEST_GPG_PRIVATE_KEY="-----BEGIN PGP PRIVATE KEY BLOCK-----\ntest\n-----END PGP PRIVATE KEY BLOCK-----" -# Helper function to test mutual exclusivity +# Helper function to verify mutual exclusivity # Parameters: # $1: test index # $2: test name # $3: ssh_public_key value (optional) # $4: ssh_private_key value (optional) # $5: description for error message -test_mutual_exclusivity() { +verify_mutual_exclusivity() { local index="${1:?Index not provided}" local test_name="${2:?Test name not provided}" local ssh_public="${3:-}" @@ -72,7 +72,7 @@ test_gpg_signing_error_when_both_ssh_and_gpg() { local index="${1:?Index not provided}" local test_name="${FUNCNAME[0]}" - test_mutual_exclusivity "$index" "$test_name" \ + verify_mutual_exclusivity "$index" "$test_name" \ "$TEST_SSH_PUBLIC_KEY" \ "$TEST_SSH_PRIVATE_KEY" \ "both SSH keys and GPG key are provided" @@ -83,7 +83,7 @@ test_gpg_signing_error_when_ssh_public_and_gpg() { local index="${1:?Index not provided}" local test_name="${FUNCNAME[0]}" - test_mutual_exclusivity "$index" "$test_name" \ + verify_mutual_exclusivity "$index" "$test_name" \ "$TEST_SSH_PUBLIC_KEY" \ "" \ "SSH public key and GPG key are provided" @@ -94,7 +94,7 @@ test_gpg_signing_error_when_ssh_private_and_gpg() { local index="${1:?Index not provided}" local test_name="${FUNCNAME[0]}" - test_mutual_exclusivity "$index" "$test_name" \ + verify_mutual_exclusivity "$index" "$test_name" \ "" \ "$TEST_SSH_PRIVATE_KEY" \ "SSH private key and GPG key are provided"