Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 100 additions & 2 deletions docs/configuration/automatic-releases/github-actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,12 @@ before the :ref:`version <cmd-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``

Expand All @@ -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``

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/gh_action/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions src/gh_action/action.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."

Expand Down Expand Up @@ -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}"

Expand Down
101 changes: 101 additions & 0 deletions tests/gh_action/suite/test_gpg_signing.sh
Original file line number Diff line number Diff line change
@@ -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"
}
Loading