Created
August 6, 2026 01:10
-
-
Save Stwissel/1186dae6832f438a0d533e5770cbcced to your computer and use it in GitHub Desktop.
macOS script to setup GPG code signing for git
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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