From 67345b0febb9f79d085144f000b9b3c92d9d0db2 Mon Sep 17 00:00:00 2001 From: ChristophShyper <45788587+ChristophShyper@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:22:30 +0200 Subject: [PATCH] feat: add GPG and SSH commit signing support Add signing inputs and runtime setup for GPG and SSH commit signing in the action container. Extend local image tests and documentation to cover signed commit flows, including passphrase-protected GPG keys and SSH signature verification. --- README.md | 49 +++++++++++++ action.yml | 12 +++ alpine-packages.txt | 2 + entrypoint.sh | 137 ++++++++++++++++++++++++++++++++--- tests/docker/local-image.yml | 88 +++++++++++++++++++++- 5 files changed, 277 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e9d9ccc..d12f64f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ ## ✨ Features - **📝 Custom commit messages:** Add custom prefixes and messages to commits +- **🔏 Commit signing:** Sign generated commits with GPG or SSH keys - **🌿 Branch management:** Create new branches automatically with optional timestamps - **⏰ Timestamp support:** Add timestamps to branch names for cron-based updates - **🔄 Integration-ready:** Works seamlessly with other DevOps workflows @@ -59,6 +60,9 @@ This action supports three tag levels for flexible versioning: amend: false commit_prefix: "[AUTO]" commit_message: "Automatic commit" + signing_mode: "" + signing_key: "" + signing_passphrase: "" force: false force_with_lease: false no_edit: false @@ -75,6 +79,9 @@ This action supports three tag levels for flexible versioning: | `amend` | No | `false` | Whether to make an amendment to the previous commit (`--amend`). Can be combined with `commit_message` to change the commit message. | | `commit_prefix` | No | `""` | Prefix added to commit message. Combines with `commit_message`. | | `commit_message` | No | `""` | Commit message to set. Combines with `commit_prefix`. Can be used with `amend` to change the commit message. | +| `signing_mode` | No | `""` | Commit signing mode. Supported values are `gpg` and `ssh`. Leave empty to disable signing. | +| `signing_key` | No | `""` | Signing key material. For `gpg`, provide an ASCII-armored private key export. For `ssh`, provide a private key in OpenSSH or PEM format. | +| `signing_passphrase` | No | `""` | Optional passphrase for the signing key. Passphrase-protected GPG keys are supported. Encrypted SSH signing keys are rejected in the current runtime. | | `force` | No | `false` | Whether to use force push (`--force`). Use only when you need to overwrite remote changes. Potentially dangerous. | | `force_with_lease` | No | `false` | Whether to use force push with lease (`--force-with-lease`). Safer than `force` as it checks for remote changes. Set `fetch-depth: 0` for `actions/checkout`. | | `base_branch` | No | `""` | Base branch used to sync or reset `target_branch`. When empty, the action auto-detects `main`/`master` or origin HEAD. | @@ -215,6 +222,48 @@ jobs: commit_message: "Update README" ``` +## 🔏 Commit Signing + +This action can sign generated commits by configuring repository-local git signing settings at runtime. + +- `signing_mode: gpg` imports an ASCII-armored private OpenPGP key into an isolated temporary `GNUPGHOME`. +- `signing_mode: ssh` uses an SSH private key file and git's SSH signing mode. +- Temporary key material is written outside the repository and removed when the container exits. +- Passphrase-protected GPG keys are supported through non-interactive loopback pinentry. +- Encrypted SSH signing keys are currently rejected explicitly instead of falling back to interactive prompts. + +### 🔐 GPG signing example + +```yaml +- name: Commit and push signed changes + uses: devops-infra/action-commit-push@v1.3.4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + commit_message: "test(commit-push): signed with gpg" + signing_mode: gpg + signing_key: ${{ secrets.GPG_PRIVATE_KEY }} + signing_passphrase: ${{ secrets.GPG_PASSPHRASE }} +``` + +### 🔐 SSH signing example + +```yaml +- name: Commit and push SSH-signed changes + uses: devops-infra/action-commit-push@v1.3.4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + commit_message: "test(commit-push): signed with ssh" + signing_mode: ssh + signing_key: ${{ secrets.SSH_SIGNING_KEY }} +``` + +### 🩺 Signing troubleshooting + +- `Failed to import GPG signing key` usually means the secret is not an ASCII-armored private key export. +- `Failed to read SSH signing key` usually means the secret is not a valid private key. +- `Encrypted SSH signing keys are not supported in this runtime` means the key must be provided without a passphrase. +- If downstream verification fails, confirm your verifier trusts the matching public key and uses git's corresponding `gpg.format`. + ## 📝 Amend Options When using `amend: true`, you have several options for handling the commit message: diff --git a/action.yml b/action.yml index 8e6a85d..93f1633 100644 --- a/action.yml +++ b/action.yml @@ -22,6 +22,18 @@ inputs: description: Commit message to set required: false default: "" + signing_mode: + description: Commit signing mode. Supported values are gpg and ssh. + required: false + default: "" + signing_key: + description: Signing key material. For gpg use an ASCII-armored private key export; for ssh use a private key in OpenSSH or PEM format. + required: false + default: "" + signing_passphrase: + description: Optional passphrase for the signing key. + required: false + default: "" force: description: Whether to use force push (--force). Use only when you need to overwrite remote changes. Potentially dangerous. required: false diff --git a/alpine-packages.txt b/alpine-packages.txt index c8ceb79..33ca9c1 100644 --- a/alpine-packages.txt +++ b/alpine-packages.txt @@ -1,3 +1,5 @@ bash~=5.3 git~=2.52 git-lfs~=3.7 +gnupg~=2.4 +openssh-keygen~=10.2 diff --git a/entrypoint.sh b/entrypoint.sh index 26891fa..ee3e62e 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -e +set -eo pipefail # Return code RET_CODE=0 @@ -10,6 +10,7 @@ echo " add_timestamp: ${INPUT_ADD_TIMESTAMP}" echo " amend: ${INPUT_AMEND}" echo " commit_prefix: ${INPUT_COMMIT_PREFIX}" echo " commit_message: ${INPUT_COMMIT_MESSAGE}" +echo " signing_mode: ${INPUT_SIGNING_MODE}" echo " force: ${INPUT_FORCE}" echo " force_with_lease: ${INPUT_FORCE_WITH_LEASE}" echo " base_branch: ${INPUT_BASE_BRANCH}" @@ -80,6 +81,125 @@ normalize_relative_path() { printf '%s' "${normalized}" } +input_true() { + case "${1:-}" in + true|TRUE|True|1|yes|YES|Yes|on|ON|On) return 0 ;; + *) return 1 ;; + esac +} + +create_executable_file() { + local target_path="$1" + shift + cat > "${target_path}" < "${key_file}" + chmod 600 "${key_file}" + + if ! gpg --batch --import "${key_file}" >/dev/null 2>&1; then + echo "[ERROR] Failed to import GPG signing key." + exit 1 + fi + + fingerprint="$( + gpg --batch --with-colons --list-secret-keys 2>/dev/null \ + | awk -F: '$1 == "fpr" { print $10; exit }' + )" + if [[ -z "${fingerprint}" ]]; then + echo "[ERROR] No secret GPG key available after import." + exit 1 + fi + + if [[ -n "${INPUT_SIGNING_PASSPHRASE:-}" ]]; then + export ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE="${ACTION_TMP_DIR}/gpg-passphrase" + printf '%s' "${INPUT_SIGNING_PASSPHRASE}" > "${ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE}" + chmod 600 "${ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE}" + fi + + wrapper_path="${ACTION_TMP_DIR}/gpg-wrapper" + # shellcheck disable=SC2016 + create_executable_file "${wrapper_path}" '#!/usr/bin/env bash +set -euo pipefail +if [[ -n "${ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE:-}" ]]; then + exec gpg --batch --yes --pinentry-mode loopback --passphrase-file "${ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE}" "$@" +fi +exec gpg --batch --yes --pinentry-mode loopback "$@"' + + git config --global user.signingkey "${fingerprint}" + git config --global commit.gpgsign true + git config --global gpg.program "${wrapper_path}" +} + +setup_ssh_signing() { + local key_path + + echo "[INFO] Enabling SSH commit signing." + key_path="${ACTION_TMP_DIR}/ssh-signing-key" + printf '%s\n' "${INPUT_SIGNING_KEY}" > "${key_path}" + chmod 600 "${key_path}" + + if ! ssh-keygen -y -f "${key_path}" >/dev/null 2>&1; then + if [[ -n "${INPUT_SIGNING_PASSPHRASE:-}" ]]; then + echo "[ERROR] Encrypted SSH signing keys are not supported in this runtime." + else + echo "[ERROR] Failed to read SSH signing key." + fi + exit 1 + fi + + git config --global gpg.format ssh + git config --global user.signingkey "${key_path}" + git config --global commit.gpgsign true +} + +setup_commit_signing() { + local mode + + mode="${INPUT_SIGNING_MODE:-}" + if [[ -z "${mode}" ]]; then + return + fi + + if [[ -z "${INPUT_SIGNING_KEY:-}" ]]; then + echo "[ERROR] Input 'signing_key' is required when signing_mode is set." + exit 1 + fi + + case "${mode}" in + gpg) + setup_gpg_signing + ;; + ssh) + setup_ssh_signing + ;; + *) + echo "[ERROR] Unsupported signing_mode '${mode}'. Supported values: gpg, ssh." + exit 1 + ;; + esac +} + WORKSPACE_DIR="$(cd "${GITHUB_WORKSPACE}" && pwd -P)" NORMALIZED_REPOSITORY_PATH="$(normalize_relative_path "${REPOSITORY_PATH}")" if [[ "${NORMALIZED_REPOSITORY_PATH}" == ".." || "${NORMALIZED_REPOSITORY_PATH}" == ../* ]]; then @@ -101,10 +221,13 @@ if [[ ! -d "${REPO_DIR}" ]]; then exit 1 fi +ACTION_TMP_DIR="$(mktemp -d /tmp/action-commit-push-XXXXXX)" +trap cleanup EXIT + # Keep all global git config isolated to a temp file export GIT_CONFIG_GLOBAL -GIT_CONFIG_GLOBAL="$(mktemp /tmp/action-commit-push-git-config-XXXXXX)" -trap 'rm -f "${GIT_CONFIG_GLOBAL}"' EXIT +GIT_CONFIG_GLOBAL="${ACTION_TMP_DIR}/gitconfig-global" +: > "${GIT_CONFIG_GLOBAL}" # Configure safe directories before git repo validation git config --global safe.directory "${GITHUB_WORKSPACE}" @@ -121,6 +244,7 @@ echo "[INFO] Using repository path: ${REPO_DIR}" git -C "${REPO_DIR}" remote set-url origin "https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@${INPUT_ORGANIZATION_DOMAIN}/${GITHUB_REPOSITORY}" git -C "${REPO_DIR}" config user.name "${GITHUB_ACTOR}" git -C "${REPO_DIR}" config user.email "${GITHUB_ACTOR}@users.noreply.${INPUT_ORGANIZATION_DOMAIN}" +setup_commit_signing cd "${REPO_DIR}" @@ -133,13 +257,6 @@ get_current_branch() { printf '%s' "${branch}" } -input_true() { - case "${1:-}" in - true|TRUE|True|1|yes|YES|Yes|on|ON|On) return 0 ;; - *) return 1 ;; - esac -} - # Get changed files git add -A FILES_CHANGED=$(git diff --staged --name-status) diff --git a/tests/docker/local-image.yml b/tests/docker/local-image.yml index 7c5f3d8..f723910 100644 --- a/tests/docker/local-image.yml +++ b/tests/docker/local-image.yml @@ -10,7 +10,7 @@ commandTests: command: bash args: - -lc - - command -v bash >/dev/null 2>&1 && command -v git >/dev/null 2>&1 && command -v git-lfs >/dev/null 2>&1 + - command -v bash >/dev/null 2>&1 && command -v git >/dev/null 2>&1 && command -v git-lfs >/dev/null 2>&1 && command -v gpg >/dev/null 2>&1 && command -v ssh-keygen >/dev/null 2>&1 - name: Temporary and APK cache cleaned command: bash @@ -48,6 +48,92 @@ commandTests: INPUT_ALLOW_EMPTY_COMMIT=false \ INPUT_TARGET_BRANCH='' \ /entrypoint.sh + + - name: Entrypoint signs empty commit with GPG + command: bash + args: + - -lc + - | + set -euo pipefail + rm -rf /tmp/ws /tmp/remote.git /tmp/gpg-gen /tmp/gpg-verify /tmp/gpg-public.asc /tmp/gpg-private.asc /tmp/github_output.txt + mkdir -p /tmp/ws /tmp/gpg-gen /tmp/gpg-verify + chmod 700 /tmp/gpg-gen /tmp/gpg-verify + export GNUPGHOME=/tmp/gpg-gen + cat > /tmp/gpg-batch <<'EOF' + Key-Type: RSA + Key-Length: 2048 + Name-Real: Local Test + Name-Email: tester@users.noreply.github.com + Passphrase: localpass + Expire-Date: 0 + %commit + EOF + gpg --batch --generate-key /tmp/gpg-batch + gpg --batch --pinentry-mode loopback --passphrase localpass --armor --export-secret-keys tester@users.noreply.github.com > /tmp/gpg-private.asc + gpg --armor --export tester@users.noreply.github.com > /tmp/gpg-public.asc + unset GNUPGHOME + git init /tmp/ws + git -C /tmp/ws config user.name test + git -C /tmp/ws config user.email test@example.com + touch /tmp/ws/.keep + git -C /tmp/ws add . + git -C /tmp/ws commit -m init + git init --bare /tmp/remote.git + git -C /tmp/ws remote add origin /tmp/remote.git + GITHUB_WORKSPACE=/tmp/ws \ + GITHUB_ACTOR=tester \ + GITHUB_REPOSITORY=owner/repo \ + GITHUB_OUTPUT=/tmp/github_output.txt \ + GITHUB_TOKEN=fake \ + INPUT_ORGANIZATION_DOMAIN=github.com \ + INPUT_REPOSITORY_PATH=. \ + INPUT_ALLOW_EMPTY_COMMIT=true \ + INPUT_COMMIT_MESSAGE='gpg signed empty commit' \ + INPUT_AMEND=false \ + INPUT_TARGET_BRANCH='' \ + INPUT_SIGNING_MODE=gpg \ + INPUT_SIGNING_KEY="$(cat /tmp/gpg-private.asc)" \ + INPUT_SIGNING_PASSPHRASE='localpass' \ + /entrypoint.sh + export GNUPGHOME=/tmp/gpg-verify + gpg --import /tmp/gpg-public.asc >/dev/null 2>&1 + git -C /tmp/ws verify-commit HEAD + + - name: Entrypoint signs empty commit with SSH + command: bash + args: + - -lc + - | + set -euo pipefail + rm -rf /tmp/ws /tmp/remote.git /tmp/ssh-signing-key /tmp/ssh-signing-key.pub /tmp/allowed_signers /tmp/github_output.txt + mkdir -p /tmp/ws + ssh-keygen -q -t ed25519 -N '' -C tester@users.noreply.github.com -f /tmp/ssh-signing-key + git init /tmp/ws + git -C /tmp/ws config user.name test + git -C /tmp/ws config user.email test@example.com + touch /tmp/ws/.keep + git -C /tmp/ws add . + git -C /tmp/ws commit -m init + git init --bare /tmp/remote.git + git -C /tmp/ws remote add origin /tmp/remote.git + GITHUB_WORKSPACE=/tmp/ws \ + GITHUB_ACTOR=tester \ + GITHUB_REPOSITORY=owner/repo \ + GITHUB_OUTPUT=/tmp/github_output.txt \ + GITHUB_TOKEN=fake \ + INPUT_ORGANIZATION_DOMAIN=github.com \ + INPUT_REPOSITORY_PATH=. \ + INPUT_ALLOW_EMPTY_COMMIT=true \ + INPUT_COMMIT_MESSAGE='ssh signed empty commit' \ + INPUT_AMEND=false \ + INPUT_TARGET_BRANCH='' \ + INPUT_SIGNING_MODE=ssh \ + INPUT_SIGNING_KEY="$(cat /tmp/ssh-signing-key)" \ + /entrypoint.sh + printf 'tester@users.noreply.github.com %s\n' "$(cat /tmp/ssh-signing-key.pub)" > /tmp/allowed_signers + git -C /tmp/ws config gpg.format ssh + git -C /tmp/ws config gpg.ssh.allowedSignersFile /tmp/allowed_signers + git -C /tmp/ws verify-commit HEAD fileExistenceTests: - name: entrypoint exists path: /entrypoint.sh