Skip to content

Instantly share code, notes, and snippets.

@Stwissel
Created August 6, 2026 01:10
Show Gist options
  • Select an option

  • Save Stwissel/1186dae6832f438a0d533e5770cbcced to your computer and use it in GitHub Desktop.

Select an option

Save Stwissel/1186dae6832f438a0d533e5770cbcced to your computer and use it in GitHub Desktop.
macOS script to setup GPG code signing for git
#!/usr/bin/env bash
# =============================================================================
# gpg-signing-setup.sh
#
# Creates a modern Ed25519 (EdDSA) OpenPGP key, configures git to sign commits
# and tags with it (repository-local or global), and uploads the public key to
# GitHub.com or a GitHub Enterprise (GHE) server via the `gh` CLI.
#
# Target platform: macOS. Written for bash 3.2 (the system bash) so it runs
# without Homebrew's newer bash.
# =============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
# --- defaults ----------------------------------------------------------------
SCOPE="" # local | global
FULL_NAME=""
EMAIL=""
COMMENT=""
EXPIRY="2y"
GH_HOSTNAME="github.com"
SIGN_TAGS="true"
SET_IDENTITY="" # yes | no (also write user.name / user.email)
UPLOAD="yes"
NO_PASSPHRASE="false"
ASSUME_YES="false"
# --- output helpers ----------------------------------------------------------
if [ -t 1 ]; then
C_BOLD="$(printf '\033[1m')"; C_RED="$(printf '\033[31m')"
C_GRN="$(printf '\033[32m')"; C_YEL="$(printf '\033[33m')"
C_OFF="$(printf '\033[0m')"
else
C_BOLD=""; C_RED=""; C_GRN=""; C_YEL=""; C_OFF=""
fi
info() { printf '%s\n' "${C_BOLD}==>${C_OFF} $*"; }
warn() { printf '%s\n' "${C_YEL}warning:${C_OFF} $*" >&2; }
ok() { printf '%s\n' "${C_GRN}ok:${C_OFF} $*"; }
die() { printf '%s\n' "${C_RED}error:${C_OFF} $*" >&2; exit 1; }
usage() {
cat <<EOF
${SCRIPT_NAME} — create an Ed25519 GPG signing key, wire it into git, upload it to GitHub/GHE.
Usage: ${SCRIPT_NAME} [options]
-s, --scope <local|global> Where to write the git configuration.
'local' = current repository only.
-n, --name <name> Real name for the key user ID (UID).
-e, --email <address> E-mail address for the key UID. Must be a
verified address on the GitHub/GHE account.
-c, --comment <text> Optional comment inside the key UID.
-x, --expiry <period> Key validity, e.g. 1y, 2y, 18m, 0 (never).
Default: ${EXPIRY}
-H, --host <hostname> GitHub host. Default: ${GH_HOSTNAME}
Use your GHE hostname, e.g. ghe.example.com
--no-tag-signing Do not enable tag signing.
--set-identity Also write user.name / user.email in that scope.
--no-upload Skip the upload to GitHub/GHE.
--no-passphrase Create the key without a passphrase (NOT advised).
-y, --yes Skip the final confirmation prompt.
-h, --help Show this help.
Anything not supplied on the command line is prompted for.
EOF
}
# --- argument parsing --------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
-s|--scope) SCOPE="${2:-}"; shift 2 ;;
-n|--name) FULL_NAME="${2:-}"; shift 2 ;;
-e|--email) EMAIL="${2:-}"; shift 2 ;;
-c|--comment) COMMENT="${2:-}"; shift 2 ;;
-x|--expiry) EXPIRY="${2:-}"; shift 2 ;;
-H|--host) GH_HOSTNAME="${2:-}"; shift 2 ;;
--no-tag-signing) SIGN_TAGS="false"; shift ;;
--set-identity) SET_IDENTITY="yes"; shift ;;
--no-upload) UPLOAD="no"; shift ;;
--no-passphrase) NO_PASSPHRASE="true"; shift ;;
-y|--yes) ASSUME_YES="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "unknown option: $1" ;;
esac
done
# --- prerequisite checks -----------------------------------------------------
info "Checking prerequisites"
command -v git >/dev/null 2>&1 || die "git not found."
command -v gpg >/dev/null 2>&1 || die "gpg not found. Install it with: brew install gnupg"
if [ "$UPLOAD" = "yes" ]; then
command -v gh >/dev/null 2>&1 || die "gh not found. Install it with: brew install gh"
fi
GPG_BIN="$(command -v gpg)"
GPG_VERSION="$("$GPG_BIN" --version | head -n1 | awk '{print $NF}')"
GPG_MAJOR="${GPG_VERSION%%.*}"
GPG_REST="${GPG_VERSION#*.}"
GPG_MINOR="${GPG_REST%%.*}"
case "$GPG_MAJOR$GPG_MINOR" in
''|*[!0-9]*) warn "Could not parse the GnuPG version (${GPG_VERSION}); continuing." ;;
*)
if [ "$GPG_MAJOR" -lt 2 ] || { [ "$GPG_MAJOR" -eq 2 ] && [ "$GPG_MINOR" -lt 1 ]; }; then
die "GnuPG ${GPG_VERSION} is too old for Ed25519 keys. Need 2.1 or newer: brew upgrade gnupg"
fi ;;
esac
ok "GnuPG ${GPG_VERSION} at ${GPG_BIN}"
# GPG_TTY must be set or pinentry cannot prompt from a terminal session.
if [ -z "${GPG_TTY:-}" ] && [ -t 0 ]; then
GPG_TTY="$(tty)"; export GPG_TTY
warn "GPG_TTY was unset; set for this run. Add 'export GPG_TTY=\$(tty)' to your shell profile."
fi
# --- prompt helpers ----------------------------------------------------------
ask() { # ask <prompt> <default> -> prints the answer on stdout
local msg="$1" def="${2:-}" ans=""
if [ -n "$def" ]; then
read -r -p "$msg [$def]: " ans || true
printf '%s' "${ans:-$def}"
else
read -r -p "$msg: " ans || true
printf '%s' "$ans"
fi
}
ask_yn() { # ask_yn <prompt> <y|n default> -> returns 0 for yes
local msg="$1" def="$2" ans="" hint="[y/N]"
[ "$def" = "y" ] && hint="[Y/n]"
read -r -p "$msg $hint: " ans || true
ans="$(printf '%s' "${ans:-$def}" | tr '[:upper:]' '[:lower:]')"
[ "$ans" = "y" ] || [ "$ans" = "yes" ]
}
# --- gather input ------------------------------------------------------------
info "Collecting settings"
# Scope
while [ "$SCOPE" != "local" ] && [ "$SCOPE" != "global" ]; do
SCOPE="$(ask 'Configuration scope (local = this repository, global = your user)' 'global')"
SCOPE="$(printf '%s' "$SCOPE" | tr '[:upper:]' '[:lower:]')"
done
REPO_ROOT=""
if [ "$SCOPE" = "local" ]; then
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
[ -n "$REPO_ROOT" ] || die "Scope 'local' requires the working directory to be inside a git repository."
fi
# Identity
[ -n "$FULL_NAME" ] || FULL_NAME="$(ask 'Full name for the key' "$(git config --get user.name || true)")"
[ -n "$FULL_NAME" ] || die "A name is required."
[ -n "$EMAIL" ] || EMAIL="$(ask 'E-mail address (must be verified on the GitHub account)' "$(git config --get user.email || true)")"
case "$EMAIL" in
*@*.*) : ;;
*) die "'$EMAIL' does not look like an e-mail address." ;;
esac
[ -n "$COMMENT" ] || COMMENT="$(ask 'Key comment (optional, press Enter to skip)' '')"
EXPIRY="$(ask 'Key expiry (1y, 2y, 18m, or 0 for never)' "$EXPIRY")"
# GitHub host
if [ "$UPLOAD" = "yes" ]; then
GH_HOSTNAME="$(ask 'GitHub host (github.com or your GHE hostname)' "$GH_HOSTNAME")"
fi
# Whether to also write user.name / user.email
if [ -z "$SET_IDENTITY" ]; then
CUR_EMAIL="$(git config --get user.email 2>/dev/null || true)"
if [ "$CUR_EMAIL" != "$EMAIL" ]; then
if ask_yn "git's current user.email is '${CUR_EMAIL:-unset}'. Also set user.name/user.email in the ${SCOPE} scope?" 'y'; then
SET_IDENTITY="yes"
else
SET_IDENTITY="no"
warn "Signatures will show as unverified if the committer e-mail does not match a key UID."
fi
else
SET_IDENTITY="no"
fi
fi
# Build the user ID string
KEY_UID="$FULL_NAME"
[ -n "$COMMENT" ] && KEY_UID="$KEY_UID ($COMMENT)"
KEY_UID="$KEY_UID <$EMAIL>"
# --- confirmation ------------------------------------------------------------
GIT_TARGET="global git configuration (~/.gitconfig)"
[ "$SCOPE" = "local" ] && GIT_TARGET="${REPO_ROOT}/.git/config"
cat <<EOF
${C_BOLD}Summary — nothing has been changed yet${C_OFF}
------------------------------------------------------------------
Key algorithm : Ed25519 (EdDSA), sign capability
Key user ID : ${KEY_UID}
Expiry : ${EXPIRY}
Passphrase : $( [ "$NO_PASSPHRASE" = "true" ] && echo "none (insecure)" || echo "prompted via pinentry" )
git scope : ${SCOPE}
git config file : ${GIT_TARGET}
user.signingkey = <new key fingerprint>
gpg.format = openpgp
gpg.program = ${GPG_BIN}
commit.gpgsign = true
tag.gpgsign = ${SIGN_TAGS}
$( [ "$SET_IDENTITY" = "yes" ] && printf ' user.name = %s\n user.email = %s\n' "$FULL_NAME" "$EMAIL" )
Upload public key : $( [ "$UPLOAD" = "yes" ] && echo "yes, to ${GH_HOSTNAME} via gh" || echo "no" )
------------------------------------------------------------------
EOF
if [ "$ASSUME_YES" != "true" ]; then
ask_yn "Proceed?" 'n' || { info "Aborted; nothing changed."; exit 0; }
fi
# --- 1. generate the key -----------------------------------------------------
info "Generating the Ed25519 key (a pinentry dialogue may appear)"
STATUS_FILE="$(mktemp -t gpgstatus)"
trap 'rm -f "$STATUS_FILE"' EXIT
if [ "$NO_PASSPHRASE" = "true" ]; then
"$GPG_BIN" --batch --passphrase '' --status-file "$STATUS_FILE" \
--quick-generate-key "$KEY_UID" ed25519 sign "$EXPIRY"
else
"$GPG_BIN" --status-file "$STATUS_FILE" \
--quick-generate-key "$KEY_UID" ed25519 sign "$EXPIRY"
fi
FPR="$(awk '/KEY_CREATED/ {print $4; exit}' "$STATUS_FILE" 2>/dev/null || true)"
if [ -z "$FPR" ]; then
FPR="$("$GPG_BIN" --list-secret-keys --with-colons "$EMAIL" | awk -F: '$1=="fpr"{print $10; exit}')"
fi
[ -n "$FPR" ] || die "Key generation appeared to succeed but the fingerprint could not be determined."
ok "Key created: ${FPR}"
# --- 2. configure git --------------------------------------------------------
info "Writing git configuration (${SCOPE})"
if [ "$SCOPE" = "local" ]; then
GIT_SCOPE_FLAG="--local"
cd "$REPO_ROOT"
else
GIT_SCOPE_FLAG="--global"
fi
git config "$GIT_SCOPE_FLAG" user.signingkey "$FPR"
git config "$GIT_SCOPE_FLAG" gpg.format openpgp
git config "$GIT_SCOPE_FLAG" gpg.program "$GPG_BIN"
git config "$GIT_SCOPE_FLAG" commit.gpgsign true
git config "$GIT_SCOPE_FLAG" tag.gpgsign "$SIGN_TAGS"
if [ "$SET_IDENTITY" = "yes" ]; then
git config "$GIT_SCOPE_FLAG" user.name "$FULL_NAME"
git config "$GIT_SCOPE_FLAG" user.email "$EMAIL"
fi
ok "git will now sign commits with ${FPR}"
# --- 3. export the public key ------------------------------------------------
PUBKEY_FILE="${HOME}/.gnupg/${FPR}.pub.asc"
"$GPG_BIN" --armor --export "$FPR" > "$PUBKEY_FILE"
chmod 600 "$PUBKEY_FILE"
ok "Public key exported to ${PUBKEY_FILE}"
# --- 4. upload to GitHub / GHE ----------------------------------------------
if [ "$UPLOAD" = "yes" ]; then
info "Uploading the public key to ${GH_HOSTNAME}"
export GH_HOST="$GH_HOSTNAME"
if ! gh auth status >/dev/null 2>&1; then
warn "Not authenticated against ${GH_HOSTNAME}; starting login."
gh auth login --hostname "$GH_HOSTNAME"
fi
# Uploading a GPG key needs the admin:gpg_key OAuth scope.
if ! gh auth status 2>&1 | grep -q 'admin:gpg_key'; then
info "Requesting the admin:gpg_key scope"
gh auth refresh --hostname "$GH_HOSTNAME" --scopes admin:gpg_key
fi
if gh gpg-key add "$PUBKEY_FILE"; then
ok "Public key added to your ${GH_HOSTNAME} account"
else
warn "Upload failed. Add ${PUBKEY_FILE} manually under Settings > SSH and GPG keys."
fi
fi
# --- 5. macOS pinentry hint --------------------------------------------------
if [ "$NO_PASSPHRASE" != "true" ] && command -v pinentry-mac >/dev/null 2>&1; then
AGENT_CONF="${HOME}/.gnupg/gpg-agent.conf"
if ! grep -q '^pinentry-program' "$AGENT_CONF" 2>/dev/null; then
if ask_yn "pinentry-mac is installed but not configured. Use it for passphrase dialogues?" 'y'; then
mkdir -p "${HOME}/.gnupg"; chmod 700 "${HOME}/.gnupg"
printf 'pinentry-program %s\n' "$(command -v pinentry-mac)" >> "$AGENT_CONF"
gpgconf --kill gpg-agent || true
ok "pinentry-mac configured (Keychain storage of the passphrase now possible)."
fi
fi
fi
# --- done --------------------------------------------------------------------
cat <<EOF
${C_GRN}${C_BOLD}Done.${C_OFF}
Fingerprint : ${FPR}
Public key : ${PUBKEY_FILE}
Verify with:
git commit --allow-empty -m "test signature" && git log --show-signature -1
Notes:
* The commit e-mail must match a UID on the key AND be a verified address on
${GH_HOSTNAME}, otherwise the commit shows as "Unverified".
* Back up the secret key now:
gpg --armor --export-secret-keys ${FPR} > private-key-backup.asc
* Before the key expires, extend it with:
gpg --quick-set-expire ${FPR} 2y
EOF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment