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..f5f71406f --- /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 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 +verify_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]}" + + verify_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]}" + + verify_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]}" + + verify_mutual_exclusivity "$index" "$test_name" \ + "" \ + "$TEST_SSH_PRIVATE_KEY" \ + "SSH private key and GPG key are provided" +}