Skip to content
Merged
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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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. |
Expand Down Expand Up @@ -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:

Expand Down
12 changes: 12 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions alpine-packages.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
bash~=5.3
git~=2.52
git-lfs~=3.7
gnupg~=2.4
openssh-keygen~=10.2
137 changes: 127 additions & 10 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash

set -e
set -eo pipefail

# Return code
RET_CODE=0
Expand All @@ -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}"
Expand Down Expand Up @@ -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}" <<EOF
$*
EOF
chmod 700 "${target_path}"
}

# shellcheck disable=SC2329
cleanup() {
rm -f "${GIT_CONFIG_GLOBAL:-}"
if [[ -n "${ACTION_TMP_DIR:-}" && -d "${ACTION_TMP_DIR}" ]]; then
rm -rf "${ACTION_TMP_DIR}"
fi
}

setup_gpg_signing() {
local key_file fingerprint wrapper_path

echo "[INFO] Enabling GPG commit signing."
export GNUPGHOME="${ACTION_TMP_DIR}/gnupg"
mkdir -p "${GNUPGHOME}"
chmod 700 "${GNUPGHOME}"

key_file="${ACTION_TMP_DIR}/gpg-private-key.asc"
printf '%s\n' "${INPUT_SIGNING_KEY}" > "${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
Expand All @@ -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}"
Expand All @@ -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}"

Expand All @@ -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)
Expand Down
88 changes: 87 additions & 1 deletion tests/docker/local-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down