Skip to content

Commit 7146aff

Browse files
feat: add GPG and SSH commit signing support (#218)
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. Co-authored-by: ChristophShyper <45788587+ChristophShyper@users.noreply.github.com>
1 parent 8cc4af9 commit 7146aff

5 files changed

Lines changed: 277 additions & 11 deletions

File tree

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
## ✨ Features
1111
- **📝 Custom commit messages:** Add custom prefixes and messages to commits
12+
- **🔏 Commit signing:** Sign generated commits with GPG or SSH keys
1213
- **🌿 Branch management:** Create new branches automatically with optional timestamps
1314
- **⏰ Timestamp support:** Add timestamps to branch names for cron-based updates
1415
- **🔄 Integration-ready:** Works seamlessly with other DevOps workflows
@@ -59,6 +60,9 @@ This action supports three tag levels for flexible versioning:
5960
amend: false
6061
commit_prefix: "[AUTO]"
6162
commit_message: "Automatic commit"
63+
signing_mode: ""
64+
signing_key: ""
65+
signing_passphrase: ""
6266
force: false
6367
force_with_lease: false
6468
no_edit: false
@@ -75,6 +79,9 @@ This action supports three tag levels for flexible versioning:
7579
| `amend` | No | `false` | Whether to make an amendment to the previous commit (`--amend`). Can be combined with `commit_message` to change the commit message. |
7680
| `commit_prefix` | No | `""` | Prefix added to commit message. Combines with `commit_message`. |
7781
| `commit_message` | No | `""` | Commit message to set. Combines with `commit_prefix`. Can be used with `amend` to change the commit message. |
82+
| `signing_mode` | No | `""` | Commit signing mode. Supported values are `gpg` and `ssh`. Leave empty to disable signing. |
83+
| `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. |
84+
| `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. |
7885
| `force` | No | `false` | Whether to use force push (`--force`). Use only when you need to overwrite remote changes. Potentially dangerous. |
7986
| `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`. |
8087
| `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:
215222
commit_message: "Update README"
216223
```
217224

225+
## 🔏 Commit Signing
226+
227+
This action can sign generated commits by configuring repository-local git signing settings at runtime.
228+
229+
- `signing_mode: gpg` imports an ASCII-armored private OpenPGP key into an isolated temporary `GNUPGHOME`.
230+
- `signing_mode: ssh` uses an SSH private key file and git's SSH signing mode.
231+
- Temporary key material is written outside the repository and removed when the container exits.
232+
- Passphrase-protected GPG keys are supported through non-interactive loopback pinentry.
233+
- Encrypted SSH signing keys are currently rejected explicitly instead of falling back to interactive prompts.
234+
235+
### 🔐 GPG signing example
236+
237+
```yaml
238+
- name: Commit and push signed changes
239+
uses: devops-infra/action-commit-push@v1.3.4
240+
with:
241+
github_token: ${{ secrets.GITHUB_TOKEN }}
242+
commit_message: "test(commit-push): signed with gpg"
243+
signing_mode: gpg
244+
signing_key: ${{ secrets.GPG_PRIVATE_KEY }}
245+
signing_passphrase: ${{ secrets.GPG_PASSPHRASE }}
246+
```
247+
248+
### 🔐 SSH signing example
249+
250+
```yaml
251+
- name: Commit and push SSH-signed changes
252+
uses: devops-infra/action-commit-push@v1.3.4
253+
with:
254+
github_token: ${{ secrets.GITHUB_TOKEN }}
255+
commit_message: "test(commit-push): signed with ssh"
256+
signing_mode: ssh
257+
signing_key: ${{ secrets.SSH_SIGNING_KEY }}
258+
```
259+
260+
### 🩺 Signing troubleshooting
261+
262+
- `Failed to import GPG signing key` usually means the secret is not an ASCII-armored private key export.
263+
- `Failed to read SSH signing key` usually means the secret is not a valid private key.
264+
- `Encrypted SSH signing keys are not supported in this runtime` means the key must be provided without a passphrase.
265+
- If downstream verification fails, confirm your verifier trusts the matching public key and uses git's corresponding `gpg.format`.
266+
218267
## 📝 Amend Options
219268
When using `amend: true`, you have several options for handling the commit message:
220269

action.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ inputs:
2222
description: Commit message to set
2323
required: false
2424
default: ""
25+
signing_mode:
26+
description: Commit signing mode. Supported values are gpg and ssh.
27+
required: false
28+
default: ""
29+
signing_key:
30+
description: Signing key material. For gpg use an ASCII-armored private key export; for ssh use a private key in OpenSSH or PEM format.
31+
required: false
32+
default: ""
33+
signing_passphrase:
34+
description: Optional passphrase for the signing key.
35+
required: false
36+
default: ""
2537
force:
2638
description: Whether to use force push (--force). Use only when you need to overwrite remote changes. Potentially dangerous.
2739
required: false

alpine-packages.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
bash~=5.3
22
git~=2.52
33
git-lfs~=3.7
4+
gnupg~=2.4
5+
openssh-keygen~=10.2

entrypoint.sh

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env bash
22

3-
set -e
3+
set -eo pipefail
44

55
# Return code
66
RET_CODE=0
@@ -10,6 +10,7 @@ echo " add_timestamp: ${INPUT_ADD_TIMESTAMP}"
1010
echo " amend: ${INPUT_AMEND}"
1111
echo " commit_prefix: ${INPUT_COMMIT_PREFIX}"
1212
echo " commit_message: ${INPUT_COMMIT_MESSAGE}"
13+
echo " signing_mode: ${INPUT_SIGNING_MODE}"
1314
echo " force: ${INPUT_FORCE}"
1415
echo " force_with_lease: ${INPUT_FORCE_WITH_LEASE}"
1516
echo " base_branch: ${INPUT_BASE_BRANCH}"
@@ -80,6 +81,125 @@ normalize_relative_path() {
8081
printf '%s' "${normalized}"
8182
}
8283

84+
input_true() {
85+
case "${1:-}" in
86+
true|TRUE|True|1|yes|YES|Yes|on|ON|On) return 0 ;;
87+
*) return 1 ;;
88+
esac
89+
}
90+
91+
create_executable_file() {
92+
local target_path="$1"
93+
shift
94+
cat > "${target_path}" <<EOF
95+
$*
96+
EOF
97+
chmod 700 "${target_path}"
98+
}
99+
100+
# shellcheck disable=SC2329
101+
cleanup() {
102+
rm -f "${GIT_CONFIG_GLOBAL:-}"
103+
if [[ -n "${ACTION_TMP_DIR:-}" && -d "${ACTION_TMP_DIR}" ]]; then
104+
rm -rf "${ACTION_TMP_DIR}"
105+
fi
106+
}
107+
108+
setup_gpg_signing() {
109+
local key_file fingerprint wrapper_path
110+
111+
echo "[INFO] Enabling GPG commit signing."
112+
export GNUPGHOME="${ACTION_TMP_DIR}/gnupg"
113+
mkdir -p "${GNUPGHOME}"
114+
chmod 700 "${GNUPGHOME}"
115+
116+
key_file="${ACTION_TMP_DIR}/gpg-private-key.asc"
117+
printf '%s\n' "${INPUT_SIGNING_KEY}" > "${key_file}"
118+
chmod 600 "${key_file}"
119+
120+
if ! gpg --batch --import "${key_file}" >/dev/null 2>&1; then
121+
echo "[ERROR] Failed to import GPG signing key."
122+
exit 1
123+
fi
124+
125+
fingerprint="$(
126+
gpg --batch --with-colons --list-secret-keys 2>/dev/null \
127+
| awk -F: '$1 == "fpr" { print $10; exit }'
128+
)"
129+
if [[ -z "${fingerprint}" ]]; then
130+
echo "[ERROR] No secret GPG key available after import."
131+
exit 1
132+
fi
133+
134+
if [[ -n "${INPUT_SIGNING_PASSPHRASE:-}" ]]; then
135+
export ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE="${ACTION_TMP_DIR}/gpg-passphrase"
136+
printf '%s' "${INPUT_SIGNING_PASSPHRASE}" > "${ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE}"
137+
chmod 600 "${ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE}"
138+
fi
139+
140+
wrapper_path="${ACTION_TMP_DIR}/gpg-wrapper"
141+
# shellcheck disable=SC2016
142+
create_executable_file "${wrapper_path}" '#!/usr/bin/env bash
143+
set -euo pipefail
144+
if [[ -n "${ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE:-}" ]]; then
145+
exec gpg --batch --yes --pinentry-mode loopback --passphrase-file "${ACTION_COMMIT_PUSH_GPG_PASSPHRASE_FILE}" "$@"
146+
fi
147+
exec gpg --batch --yes --pinentry-mode loopback "$@"'
148+
149+
git config --global user.signingkey "${fingerprint}"
150+
git config --global commit.gpgsign true
151+
git config --global gpg.program "${wrapper_path}"
152+
}
153+
154+
setup_ssh_signing() {
155+
local key_path
156+
157+
echo "[INFO] Enabling SSH commit signing."
158+
key_path="${ACTION_TMP_DIR}/ssh-signing-key"
159+
printf '%s\n' "${INPUT_SIGNING_KEY}" > "${key_path}"
160+
chmod 600 "${key_path}"
161+
162+
if ! ssh-keygen -y -f "${key_path}" >/dev/null 2>&1; then
163+
if [[ -n "${INPUT_SIGNING_PASSPHRASE:-}" ]]; then
164+
echo "[ERROR] Encrypted SSH signing keys are not supported in this runtime."
165+
else
166+
echo "[ERROR] Failed to read SSH signing key."
167+
fi
168+
exit 1
169+
fi
170+
171+
git config --global gpg.format ssh
172+
git config --global user.signingkey "${key_path}"
173+
git config --global commit.gpgsign true
174+
}
175+
176+
setup_commit_signing() {
177+
local mode
178+
179+
mode="${INPUT_SIGNING_MODE:-}"
180+
if [[ -z "${mode}" ]]; then
181+
return
182+
fi
183+
184+
if [[ -z "${INPUT_SIGNING_KEY:-}" ]]; then
185+
echo "[ERROR] Input 'signing_key' is required when signing_mode is set."
186+
exit 1
187+
fi
188+
189+
case "${mode}" in
190+
gpg)
191+
setup_gpg_signing
192+
;;
193+
ssh)
194+
setup_ssh_signing
195+
;;
196+
*)
197+
echo "[ERROR] Unsupported signing_mode '${mode}'. Supported values: gpg, ssh."
198+
exit 1
199+
;;
200+
esac
201+
}
202+
83203
WORKSPACE_DIR="$(cd "${GITHUB_WORKSPACE}" && pwd -P)"
84204
NORMALIZED_REPOSITORY_PATH="$(normalize_relative_path "${REPOSITORY_PATH}")"
85205
if [[ "${NORMALIZED_REPOSITORY_PATH}" == ".." || "${NORMALIZED_REPOSITORY_PATH}" == ../* ]]; then
@@ -101,10 +221,13 @@ if [[ ! -d "${REPO_DIR}" ]]; then
101221
exit 1
102222
fi
103223

224+
ACTION_TMP_DIR="$(mktemp -d /tmp/action-commit-push-XXXXXX)"
225+
trap cleanup EXIT
226+
104227
# Keep all global git config isolated to a temp file
105228
export GIT_CONFIG_GLOBAL
106-
GIT_CONFIG_GLOBAL="$(mktemp /tmp/action-commit-push-git-config-XXXXXX)"
107-
trap 'rm -f "${GIT_CONFIG_GLOBAL}"' EXIT
229+
GIT_CONFIG_GLOBAL="${ACTION_TMP_DIR}/gitconfig-global"
230+
: > "${GIT_CONFIG_GLOBAL}"
108231

109232
# Configure safe directories before git repo validation
110233
git config --global safe.directory "${GITHUB_WORKSPACE}"
@@ -121,6 +244,7 @@ echo "[INFO] Using repository path: ${REPO_DIR}"
121244
git -C "${REPO_DIR}" remote set-url origin "https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@${INPUT_ORGANIZATION_DOMAIN}/${GITHUB_REPOSITORY}"
122245
git -C "${REPO_DIR}" config user.name "${GITHUB_ACTOR}"
123246
git -C "${REPO_DIR}" config user.email "${GITHUB_ACTOR}@users.noreply.${INPUT_ORGANIZATION_DOMAIN}"
247+
setup_commit_signing
124248

125249
cd "${REPO_DIR}"
126250

@@ -133,13 +257,6 @@ get_current_branch() {
133257
printf '%s' "${branch}"
134258
}
135259

136-
input_true() {
137-
case "${1:-}" in
138-
true|TRUE|True|1|yes|YES|Yes|on|ON|On) return 0 ;;
139-
*) return 1 ;;
140-
esac
141-
}
142-
143260
# Get changed files
144261
git add -A
145262
FILES_CHANGED=$(git diff --staged --name-status)

tests/docker/local-image.yml

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ commandTests:
1010
command: bash
1111
args:
1212
- -lc
13-
- command -v bash >/dev/null 2>&1 && command -v git >/dev/null 2>&1 && command -v git-lfs >/dev/null 2>&1
13+
- 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
1414

1515
- name: Temporary and APK cache cleaned
1616
command: bash
@@ -48,6 +48,92 @@ commandTests:
4848
INPUT_ALLOW_EMPTY_COMMIT=false \
4949
INPUT_TARGET_BRANCH='' \
5050
/entrypoint.sh
51+
52+
- name: Entrypoint signs empty commit with GPG
53+
command: bash
54+
args:
55+
- -lc
56+
- |
57+
set -euo pipefail
58+
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
59+
mkdir -p /tmp/ws /tmp/gpg-gen /tmp/gpg-verify
60+
chmod 700 /tmp/gpg-gen /tmp/gpg-verify
61+
export GNUPGHOME=/tmp/gpg-gen
62+
cat > /tmp/gpg-batch <<'EOF'
63+
Key-Type: RSA
64+
Key-Length: 2048
65+
Name-Real: Local Test
66+
Name-Email: tester@users.noreply.github.com
67+
Passphrase: localpass
68+
Expire-Date: 0
69+
%commit
70+
EOF
71+
gpg --batch --generate-key /tmp/gpg-batch
72+
gpg --batch --pinentry-mode loopback --passphrase localpass --armor --export-secret-keys tester@users.noreply.github.com > /tmp/gpg-private.asc
73+
gpg --armor --export tester@users.noreply.github.com > /tmp/gpg-public.asc
74+
unset GNUPGHOME
75+
git init /tmp/ws
76+
git -C /tmp/ws config user.name test
77+
git -C /tmp/ws config user.email test@example.com
78+
touch /tmp/ws/.keep
79+
git -C /tmp/ws add .
80+
git -C /tmp/ws commit -m init
81+
git init --bare /tmp/remote.git
82+
git -C /tmp/ws remote add origin /tmp/remote.git
83+
GITHUB_WORKSPACE=/tmp/ws \
84+
GITHUB_ACTOR=tester \
85+
GITHUB_REPOSITORY=owner/repo \
86+
GITHUB_OUTPUT=/tmp/github_output.txt \
87+
GITHUB_TOKEN=fake \
88+
INPUT_ORGANIZATION_DOMAIN=github.com \
89+
INPUT_REPOSITORY_PATH=. \
90+
INPUT_ALLOW_EMPTY_COMMIT=true \
91+
INPUT_COMMIT_MESSAGE='gpg signed empty commit' \
92+
INPUT_AMEND=false \
93+
INPUT_TARGET_BRANCH='' \
94+
INPUT_SIGNING_MODE=gpg \
95+
INPUT_SIGNING_KEY="$(cat /tmp/gpg-private.asc)" \
96+
INPUT_SIGNING_PASSPHRASE='localpass' \
97+
/entrypoint.sh
98+
export GNUPGHOME=/tmp/gpg-verify
99+
gpg --import /tmp/gpg-public.asc >/dev/null 2>&1
100+
git -C /tmp/ws verify-commit HEAD
101+
102+
- name: Entrypoint signs empty commit with SSH
103+
command: bash
104+
args:
105+
- -lc
106+
- |
107+
set -euo pipefail
108+
rm -rf /tmp/ws /tmp/remote.git /tmp/ssh-signing-key /tmp/ssh-signing-key.pub /tmp/allowed_signers /tmp/github_output.txt
109+
mkdir -p /tmp/ws
110+
ssh-keygen -q -t ed25519 -N '' -C tester@users.noreply.github.com -f /tmp/ssh-signing-key
111+
git init /tmp/ws
112+
git -C /tmp/ws config user.name test
113+
git -C /tmp/ws config user.email test@example.com
114+
touch /tmp/ws/.keep
115+
git -C /tmp/ws add .
116+
git -C /tmp/ws commit -m init
117+
git init --bare /tmp/remote.git
118+
git -C /tmp/ws remote add origin /tmp/remote.git
119+
GITHUB_WORKSPACE=/tmp/ws \
120+
GITHUB_ACTOR=tester \
121+
GITHUB_REPOSITORY=owner/repo \
122+
GITHUB_OUTPUT=/tmp/github_output.txt \
123+
GITHUB_TOKEN=fake \
124+
INPUT_ORGANIZATION_DOMAIN=github.com \
125+
INPUT_REPOSITORY_PATH=. \
126+
INPUT_ALLOW_EMPTY_COMMIT=true \
127+
INPUT_COMMIT_MESSAGE='ssh signed empty commit' \
128+
INPUT_AMEND=false \
129+
INPUT_TARGET_BRANCH='' \
130+
INPUT_SIGNING_MODE=ssh \
131+
INPUT_SIGNING_KEY="$(cat /tmp/ssh-signing-key)" \
132+
/entrypoint.sh
133+
printf 'tester@users.noreply.github.com %s\n' "$(cat /tmp/ssh-signing-key.pub)" > /tmp/allowed_signers
134+
git -C /tmp/ws config gpg.format ssh
135+
git -C /tmp/ws config gpg.ssh.allowedSignersFile /tmp/allowed_signers
136+
git -C /tmp/ws verify-commit HEAD
51137
fileExistenceTests:
52138
- name: entrypoint exists
53139
path: /entrypoint.sh

0 commit comments

Comments
 (0)