Skip to content

Commit f9f5e0c

Browse files
committed
feat: add script to programmatically install enterprise-owned GitHub App
1 parent 47627b0 commit f9f5e0c

3 files changed

Lines changed: 296 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
77
## [Unreleased]
88

99
### Added
10+
- `github-install-enterprise-app` — programmatically installs an enterprise-owned automation GitHub App into an org using an installer GitHub App (JWT → installation token flow); supports `--dry-run` and optional org-scoped token verification. Adds `openssl` as a dependency
1011
- `github-copilot-report`: NDJSON usage-metrics endpoints, Entra ID enrichment via `az rest`, auto-detection of credits per seat with promo/standard table, `--no-entra` flag
1112
- README: GitHub Actions integration examples (workflow_dispatch, artifact upload, environment protection)
1213
- `.github/workflows/update-readme-sha.yml` — automatically updates the pinned commit SHA in README.md on every push to `main`

‎README.md‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Each script is a self-contained utility designed for a specific task. Navigate t
119119
### Folder Layout (Domain-Based)
120120

121121
- `org-admin/`: `github-add-repo-collaborators-by-pattern`, `github-add-repo-permissions`, `github-archive-old-repos`, `github-auto-repo-creation`, `github-close-archived-repo-security-alerts`, `github-enable-issues`, `github-get-repo-list`, `github-import-repo`, `github-migrate-internal-repos-to-private`, `github-repo-from-template`
122-
- `enterprise/`: `github-add-enterprise-team-read-permissions`, `github-dockerfile-discovery`, `github-get-consumed-licenses`, `github-get-public-repos`
122+
- `enterprise/`: `github-add-enterprise-team-read-permissions`, `github-dockerfile-discovery`, `github-get-consumed-licenses`, `github-get-public-repos`, `github-install-enterprise-app`
123123
- `reporting/`: `github-monthly-issues-report`, `github-repo-permissions-report`, `github-copilot-report`
124124
- `personal/`: `github-organize-stars`
125125

@@ -640,6 +640,48 @@ Total seats purchased: 200
640640
641641
---
642642

643+
### Install Enterprise App
644+
645+
**Script:** `enterprise/github-install-enterprise-app/github-install-enterprise-app.sh`
646+
647+
Programmatically installs an enterprise-owned "automation" GitHub App into an enterprise-owned organization, using a second enterprise-owned "installer" GitHub App that holds the **Enterprise organization installations** permission. Follows GitHub's [Automating app installations](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-github-apps-for-your-enterprise/automate-installations) guide.
648+
649+
Authentication is performed entirely with the two GitHub Apps (JWT → installation access token). No `GITHUB_TOKEN` / PAT is used, and JWTs and access tokens are never printed.
650+
651+
**Required variables:**
652+
```bash
653+
export ENTERPRISE="my-enterprise"
654+
export ORG="my-org" # Target org for the install
655+
export INSTALLER_APP_CLIENT_ID="Iv23li..." # Installer app client ID
656+
export INSTALLER_APP_PRIVATE_KEY="~/installer-app.private-key.pem"
657+
export INSTALLER_APP_INSTALL_ID="12345678" # Installer app's enterprise install ID
658+
export AUTOMATION_APP_CLIENT_ID="Iv23li..." # App to install in the org
659+
```
660+
661+
**Usage:**
662+
```bash
663+
cd enterprise/github-install-enterprise-app
664+
./github-install-enterprise-app.sh # Install the automation app
665+
./github-install-enterprise-app.sh --dry-run # Authenticate only; make no changes
666+
```
667+
668+
**What it does:**
669+
- Generates a short-lived RS256 JWT from the installer app's client ID and private key (via `openssl`)
670+
- Exchanges the JWT for an enterprise-scoped installation access token
671+
- Installs the automation app in the target organization and prints the new installation ID
672+
- Optionally verifies the install by minting an org-scoped token when `AUTOMATION_APP_PRIVATE_KEY` is supplied
673+
674+
| Variable | Description | Default |
675+
|----------|-------------|---------|
676+
| `AUTOMATION_APP_PRIVATE_KEY` | Path to the automation app `.pem`; when set, verifies the new install by minting an org-scoped token | (unset — verification skipped) |
677+
| `REPO_SELECTION` | Repository access for the install: `all` or `selected` | `all` |
678+
| `API_URL_PREFIX` | GitHub API base URL | `https://api.github.com` |
679+
680+
> [!IMPORTANT]
681+
> Requires two enterprise-owned GitHub Apps. The installer app must be installed on the enterprise account with read and write access to **Enterprise organization installations**. This script additionally requires `openssl` for JWT generation.
682+
683+
---
684+
643685
### Add Enterprise Team Read Permissions
644686

645687
**Script:** `enterprise/github-add-enterprise-team-read-permissions/github-add-enterprise-team-read-permissions.sh`
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

Comments
 (0)