|
| 1 | +#!/bin/bash |
| 2 | +# ============================================================================= |
| 3 | +# github-install-enterprise-app.sh |
| 4 | +# |
| 5 | +# Programmatically installs an enterprise-owned "automation" GitHub App into an |
| 6 | +# enterprise-owned organization, using a second enterprise-owned "installer" |
| 7 | +# GitHub App that holds the "Enterprise organization installations" permission. |
| 8 | +# |
| 9 | +# The flow follows GitHub's "Automating app installations" guide: |
| 10 | +# 1. Generate a JWT from the installer app's client ID and private key. |
| 11 | +# 2. Exchange the JWT for an enterprise-scoped installation access token. |
| 12 | +# 3. Use that token to install the automation app in the target organization. |
| 13 | +# 4. (Optional) If the automation app's private key is supplied, exchange a |
| 14 | +# JWT for an organization-scoped installation access token to confirm the |
| 15 | +# new installation works. |
| 16 | +# |
| 17 | +# No GITHUB_TOKEN / PAT is used: authentication is performed entirely with the |
| 18 | +# two GitHub Apps. JWTs and access tokens are never printed. |
| 19 | +# |
| 20 | +# Usage: |
| 21 | +# export ENTERPRISE=my-enterprise |
| 22 | +# export ORG=my-org |
| 23 | +# export INSTALLER_APP_CLIENT_ID=Iv23li... |
| 24 | +# export INSTALLER_APP_PRIVATE_KEY=~/installer-app.private-key.pem |
| 25 | +# export INSTALLER_APP_INSTALL_ID=12345678 |
| 26 | +# export AUTOMATION_APP_CLIENT_ID=Iv23li... |
| 27 | +# ./github-install-enterprise-app.sh [--dry-run] |
| 28 | +# |
| 29 | +# Options: |
| 30 | +# --dry-run Authenticate the installer app but do not install anything |
| 31 | +# |
| 32 | +# Environment variables: |
| 33 | +# ENTERPRISE Required. Enterprise slug |
| 34 | +# ORG Required. Target organization slug |
| 35 | +# INSTALLER_APP_CLIENT_ID Required. Installer app client ID |
| 36 | +# INSTALLER_APP_PRIVATE_KEY Required. Path to installer app .pem private key |
| 37 | +# INSTALLER_APP_INSTALL_ID Required. Installer app's enterprise install ID |
| 38 | +# AUTOMATION_APP_CLIENT_ID Required. Automation app client ID (app to install) |
| 39 | +# AUTOMATION_APP_PRIVATE_KEY Optional. Path to automation app .pem to verify |
| 40 | +# the new install by minting an org-scoped token |
| 41 | +# REPO_SELECTION Optional. all | selected (default: all) |
| 42 | +# API_URL_PREFIX Optional. GitHub API base URL |
| 43 | +# (default: https://api.github.com) |
| 44 | +# |
| 45 | +# Requirements: |
| 46 | +# - curl |
| 47 | +# - jq |
| 48 | +# - openssl |
| 49 | +# ============================================================================= |
| 50 | + |
| 51 | +set -euo pipefail |
| 52 | + |
| 53 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 54 | +# shellcheck source=../../lib/github-common.sh |
| 55 | +source "${SCRIPT_DIR}/../../lib/github-common.sh" |
| 56 | + |
| 57 | +### GLOBAL VARIABLES |
| 58 | +ENTERPRISE=${ENTERPRISE:-''} |
| 59 | +ORG=${ORG:-''} |
| 60 | +INSTALLER_APP_CLIENT_ID=${INSTALLER_APP_CLIENT_ID:-''} |
| 61 | +INSTALLER_APP_PRIVATE_KEY=${INSTALLER_APP_PRIVATE_KEY:-''} |
| 62 | +INSTALLER_APP_INSTALL_ID=${INSTALLER_APP_INSTALL_ID:-''} |
| 63 | +AUTOMATION_APP_CLIENT_ID=${AUTOMATION_APP_CLIENT_ID:-''} |
| 64 | +AUTOMATION_APP_PRIVATE_KEY=${AUTOMATION_APP_PRIVATE_KEY:-''} |
| 65 | +REPO_SELECTION=${REPO_SELECTION:-'all'} |
| 66 | +API_URL_PREFIX=${API_URL_PREFIX:-'https://api.github.com'} |
| 67 | + |
| 68 | +DRY_RUN=false |
| 69 | + |
| 70 | +### ARGUMENT PARSING |
| 71 | +for arg in "$@"; do |
| 72 | + case "${arg}" in |
| 73 | + --dry-run) DRY_RUN=true ;; |
| 74 | + -h|--help) |
| 75 | + grep '^#' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' |
| 76 | + exit 0 |
| 77 | + ;; |
| 78 | + *) |
| 79 | + print_error "Unknown option: ${arg}" |
| 80 | + exit 1 |
| 81 | + ;; |
| 82 | + esac |
| 83 | +done |
| 84 | + |
| 85 | +### VALIDATION |
| 86 | +require_env_var ENTERPRISE "GitHub enterprise" |
| 87 | +require_env_var ORG "GitHub organization" |
| 88 | +require_env_var INSTALLER_APP_CLIENT_ID "Installer app client ID" |
| 89 | +require_env_var INSTALLER_APP_PRIVATE_KEY "Installer app private key path" |
| 90 | +require_env_var INSTALLER_APP_INSTALL_ID "Installer app installation ID" |
| 91 | +require_env_var AUTOMATION_APP_CLIENT_ID "Automation app client ID" |
| 92 | +require_command curl |
| 93 | +require_command jq |
| 94 | +require_command openssl |
| 95 | + |
| 96 | +validate_slug "${ENTERPRISE}" "enterprise slug" |
| 97 | +validate_slug "${ORG}" "organization slug" |
| 98 | +validate_slug "${INSTALLER_APP_INSTALL_ID}" "installer app installation ID" |
| 99 | + |
| 100 | +case "${REPO_SELECTION}" in |
| 101 | + all|selected) ;; |
| 102 | + *) |
| 103 | + print_error "REPO_SELECTION must be 'all' or 'selected' (got '${REPO_SELECTION}')" |
| 104 | + exit 1 |
| 105 | + ;; |
| 106 | +esac |
| 107 | + |
| 108 | +# Expand a leading ~ in private-key paths (env vars are not tilde-expanded). |
| 109 | +INSTALLER_APP_PRIVATE_KEY="${INSTALLER_APP_PRIVATE_KEY/#\~/${HOME}}" |
| 110 | +[ -n "${AUTOMATION_APP_PRIVATE_KEY}" ] && \ |
| 111 | + AUTOMATION_APP_PRIVATE_KEY="${AUTOMATION_APP_PRIVATE_KEY/#\~/${HOME}}" |
| 112 | + |
| 113 | +if [ ! -r "${INSTALLER_APP_PRIVATE_KEY}" ]; then |
| 114 | + print_error "Installer app private key not readable: ${INSTALLER_APP_PRIVATE_KEY}" |
| 115 | + exit 1 |
| 116 | +fi |
| 117 | +if [ -n "${AUTOMATION_APP_PRIVATE_KEY}" ] && [ ! -r "${AUTOMATION_APP_PRIVATE_KEY}" ]; then |
| 118 | + print_error "Automation app private key not readable: ${AUTOMATION_APP_PRIVATE_KEY}" |
| 119 | + exit 1 |
| 120 | +fi |
| 121 | + |
| 122 | +### |
| 123 | +## base64url <stdin> |
| 124 | +## Base64-encodes stdin and converts to URL-safe, unpadded form (for JWTs). |
| 125 | +### |
| 126 | +base64url() { |
| 127 | + openssl base64 -e -A | tr '+/' '-_' | tr -d '=' |
| 128 | +} |
| 129 | + |
| 130 | +### |
| 131 | +## generate_jwt <client_id> <private_key_path> |
| 132 | +## Prints a short-lived (10-minute) RS256 JWT signed with the app private key. |
| 133 | +## The JWT is written to stdout only; callers must not log it. |
| 134 | +### |
| 135 | +generate_jwt() { |
| 136 | + local client_id="$1" |
| 137 | + local pem_path="$2" |
| 138 | + local now iat exp header payload signing_input signature |
| 139 | + |
| 140 | + now=$(date +%s) |
| 141 | + iat=$((now - 60)) # backdate 60s to tolerate clock drift |
| 142 | + exp=$((now + 600)) # GitHub rejects JWTs older than 10 minutes |
| 143 | + |
| 144 | + header=$(printf '{"typ":"JWT","alg":"RS256"}' | base64url) |
| 145 | + payload=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "${iat}" "${exp}" "${client_id}" | base64url) |
| 146 | + signing_input="${header}.${payload}" |
| 147 | + |
| 148 | + signature=$(printf '%s' "${signing_input}" \ |
| 149 | + | openssl dgst -sha256 -sign "${pem_path}" -binary \ |
| 150 | + | base64url) |
| 151 | + |
| 152 | + printf '%s.%s' "${signing_input}" "${signature}" |
| 153 | +} |
| 154 | + |
| 155 | +### |
| 156 | +## mint_installation_token <client_id> <private_key_path> <installation_id> |
| 157 | +## Exchanges an app JWT for an installation access token via the |
| 158 | +## /app/installations/{id}/access_tokens endpoint. Prints the token to stdout. |
| 159 | +### |
| 160 | +mint_installation_token() { |
| 161 | + local client_id="$1" |
| 162 | + local pem_path="$2" |
| 163 | + local install_id="$3" |
| 164 | + local jwt response http_code body token message |
| 165 | + |
| 166 | + jwt=$(generate_jwt "${client_id}" "${pem_path}") |
| 167 | + |
| 168 | + response=$(curl -s -w "\n%{http_code}" -X POST \ |
| 169 | + -H "Authorization: Bearer ${jwt}" \ |
| 170 | + -H "Accept: application/vnd.github+json" \ |
| 171 | + -H "X-GitHub-Api-Version: 2022-11-28" \ |
| 172 | + "${API_URL_PREFIX}/app/installations/${install_id}/access_tokens") |
| 173 | + |
| 174 | + http_code=$(echo "${response}" | tail -1) |
| 175 | + body=$(echo "${response}" | head -n -1) |
| 176 | + |
| 177 | + if [ "${http_code}" != "201" ]; then |
| 178 | + message=$(echo "${body}" | jq -r '.message // empty' 2>/dev/null || true) |
| 179 | + print_error "Failed to mint installation token (HTTP ${http_code}): ${message:-unknown error}" |
| 180 | + return 1 |
| 181 | + fi |
| 182 | + |
| 183 | + token=$(echo "${body}" | jq -r '.token // empty') |
| 184 | + if [ -z "${token}" ]; then |
| 185 | + print_error "Installation token missing from API response" |
| 186 | + return 1 |
| 187 | + fi |
| 188 | + |
| 189 | + printf '%s' "${token}" |
| 190 | +} |
| 191 | + |
| 192 | +### MAIN |
| 193 | +print_status "Authenticating installer app (client ID ${INSTALLER_APP_CLIENT_ID})..." |
| 194 | +INSTALLER_TOKEN=$(mint_installation_token \ |
| 195 | + "${INSTALLER_APP_CLIENT_ID}" \ |
| 196 | + "${INSTALLER_APP_PRIVATE_KEY}" \ |
| 197 | + "${INSTALLER_APP_INSTALL_ID}") |
| 198 | +print_success "Obtained enterprise-scoped installation token for the installer app." |
| 199 | + |
| 200 | +if [ "${DRY_RUN}" = true ]; then |
| 201 | + print_warning "Dry run: would install automation app '${AUTOMATION_APP_CLIENT_ID}' in org '${ORG}' (repository_selection=${REPO_SELECTION})." |
| 202 | + print_warning "Dry run: no changes made." |
| 203 | + exit 0 |
| 204 | +fi |
| 205 | + |
| 206 | +print_status "Installing automation app '${AUTOMATION_APP_CLIENT_ID}' in org '${ORG}'..." |
| 207 | +INSTALL_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ |
| 208 | + -H "Authorization: Bearer ${INSTALLER_TOKEN}" \ |
| 209 | + -H "Accept: application/vnd.github+json" \ |
| 210 | + -H "X-GitHub-Api-Version: 2022-11-28" \ |
| 211 | + "${API_URL_PREFIX}/enterprises/${ENTERPRISE}/apps/organizations/${ORG}/installations" \ |
| 212 | + -d "$(jq -n \ |
| 213 | + --arg client_id "${AUTOMATION_APP_CLIENT_ID}" \ |
| 214 | + --arg repository_selection "${REPO_SELECTION}" \ |
| 215 | + '{client_id: $client_id, repository_selection: $repository_selection}')") |
| 216 | + |
| 217 | +INSTALL_HTTP_CODE=$(echo "${INSTALL_RESPONSE}" | tail -1) |
| 218 | +INSTALL_BODY=$(echo "${INSTALL_RESPONSE}" | head -n -1) |
| 219 | + |
| 220 | +case "${INSTALL_HTTP_CODE}" in |
| 221 | + 200|201) |
| 222 | + AUTOMATION_APP_INSTALL_ID=$(echo "${INSTALL_BODY}" | jq -r '.id // empty') |
| 223 | + print_success "Automation app installed in '${ORG}'. Installation ID: ${AUTOMATION_APP_INSTALL_ID}" |
| 224 | + ;; |
| 225 | + *) |
| 226 | + INSTALL_MESSAGE=$(echo "${INSTALL_BODY}" | jq -r '.message // empty' 2>/dev/null || true) |
| 227 | + print_error "Failed to install automation app (HTTP ${INSTALL_HTTP_CODE}): ${INSTALL_MESSAGE:-unknown error}" |
| 228 | + exit 1 |
| 229 | + ;; |
| 230 | +esac |
| 231 | + |
| 232 | +### OPTIONAL VERIFICATION |
| 233 | +if [ -z "${AUTOMATION_APP_PRIVATE_KEY}" ]; then |
| 234 | + print_status "AUTOMATION_APP_PRIVATE_KEY not set; skipping org-scoped token verification." |
| 235 | + exit 0 |
| 236 | +fi |
| 237 | + |
| 238 | +if [ -z "${AUTOMATION_APP_INSTALL_ID:-}" ]; then |
| 239 | + print_warning "No installation ID returned; cannot verify org-scoped token." |
| 240 | + exit 0 |
| 241 | +fi |
| 242 | + |
| 243 | +print_status "Verifying new installation by minting an org-scoped token..." |
| 244 | +if mint_installation_token \ |
| 245 | + "${AUTOMATION_APP_CLIENT_ID}" \ |
| 246 | + "${AUTOMATION_APP_PRIVATE_KEY}" \ |
| 247 | + "${AUTOMATION_APP_INSTALL_ID}" > /dev/null; then |
| 248 | + print_success "Automation app authenticated successfully in '${ORG}'. Installation is live." |
| 249 | +else |
| 250 | + print_error "Could not authenticate the automation app after install." |
| 251 | + exit 1 |
| 252 | +fi |
0 commit comments