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