Skip to content

Commit 20e265d

Browse files
authored
Merge pull request #456 from Extra-Chill/issue-450-github-app-runbook
docs: GitHub App setup runbook (closes #450)
2 parents 9533caf + a70a794 commit 20e265d

1 file changed

Lines changed: 348 additions & 0 deletions

File tree

docs/github-app-setup.md

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
# GitHub App Setup
2+
3+
Operator runbook for switching Data Machine Code's GitHub authentication from a classic PAT to a GitHub App installation. Posts as a bot user instead of a human, scopes to one install, rotates per-call installation tokens automatically.
4+
5+
This is a one-time setup per host. After it lands, every `datamachine/*-github-*` ability call (issue filing, PR creation, comments, gitsync writes, workspace pushes) automatically posts as the bot. Consumers don't need to know which mode is active.
6+
7+
## When to do this
8+
9+
Switch from PAT to App when any of these are true:
10+
11+
- You want a non-personal author identity for bot-filed issues / bot-opened PRs / bot-authored commits.
12+
- You want to scope which repos the credential can touch via `allowed_repos` per credential profile.
13+
- The PAT is owned by a single human whose departure would break the platform.
14+
- You want per-call installation tokens (short-lived, mintable, revocable) instead of a long-lived PAT.
15+
16+
The PAT can stay around as a named fallback profile — operators who legitimately need to post as themselves keep that path open.
17+
18+
## Prerequisites
19+
20+
- Admin access to the GitHub org where the App will be installed.
21+
- WP-CLI access on the host (`wp datamachine ...`, `wp datamachine-code ...`).
22+
- A safe place to store the App private key (e.g. `/root/.secrets/<app-slug>.private-key.pem`, mode `0600`).
23+
- An existing PAT-based credential profile if you want to preserve it as a fallback.
24+
25+
## Concept primer
26+
27+
The credential resolver is in `inc/Support/GitHubCredentialResolver.php`. It supports two modes per credential profile:
28+
29+
| Mode | Token shape | Lifetime | Best for |
30+
|--------|------------------------------------------------|------------------|--------------------------------------------------|
31+
| `pat` | `Authorization: token ghp_...` | Until revoked | Personal automation, single-human attribution |
32+
| `app` | `Authorization: token ghs_...` (installation) | ~1 hour, cached | Bot identity, org-scoped, per-call minting |
33+
34+
`mode: 'app'` profiles store `github_app_id`, `github_app_installation_id`, and `github_app_private_key`. On `resolve()` the resolver:
35+
36+
1. Signs a JWT from the App ID + private key (RS256, ~10 min validity).
37+
2. Exchanges the JWT for an installation access token via `POST /app/installations/:id/access_tokens`.
38+
3. Caches the installation token in a transient until its `expires_at` minus a 60-second skew.
39+
4. Returns the token with `Authorization: token <ghs_...>` ready for any GitHub API call.
40+
41+
JWT signing requires either the `openssl` PHP extension (default on most hosts) or `firebase/php-jwt` as a fallback (already pulled in via Composer for installs that want a non-OpenSSL path).
42+
43+
## Step 1 — Create the GitHub App
44+
45+
In the org's GitHub settings → Developer settings → GitHub Apps → New GitHub App:
46+
47+
- **Name:** a brand-aligned slug, e.g. `homeboy-ci`. This becomes the visible author identity on issues/PRs/commits as `<slug>[bot]`.
48+
- **Homepage URL:** anything (required field; not used at runtime).
49+
- **Webhook:** uncheck "Active". DMC doesn't consume webhooks.
50+
- **Permissions** (mirror `wp datamachine-code github status`):
51+
- Repository: Contents — read
52+
- Repository: Issues — read & write
53+
- Repository: Pull requests — read & write
54+
- Repository: Checks — read
55+
- Repository: Commit statuses — read
56+
- Repository: Actions — read (artifact downloads)
57+
- **Where can this app be installed:** "Only on this account" (the org).
58+
59+
Submit. GitHub takes you to the App's settings page.
60+
61+
Record from that page:
62+
63+
- The numeric **App ID** (top of page).
64+
- Click **Generate a private key** → downloads a `.pem` file. Move it to the host:
65+
```bash
66+
install -m 0600 -o root -g root \
67+
<download>/<app-slug>.<date>.private-key.pem \
68+
/root/.secrets/<app-slug>.private-key.pem
69+
```
70+
71+
## Step 2 — Install the App on the org
72+
73+
From the App's settings page: **Install App** → select the org → choose "All repositories" (matches the default PAT scope; switch to "Only select repositories" if you want finer scoping at the GitHub level).
74+
75+
After install, the URL is `https://github.com/organizations/<org>/settings/installations/<installation_id>`. Record the **Installation ID**.
76+
77+
You can verify both IDs against the org from the host:
78+
79+
```bash
80+
gh api /orgs/<org>/installations \
81+
--jq '.installations[] | {app_id, app_slug, installation_id: .id, account: .account.login, repos: .repository_selection}'
82+
```
83+
84+
Expected:
85+
86+
```json
87+
{"app_id":3034937,"app_slug":"homeboy-ci","installation_id":114752821,"account":"Extra-Chill","repos":"all"}
88+
```
89+
90+
## Step 3 — Land the credentials on the host
91+
92+
Three settings to write into `datamachine_settings` via the canonical `datamachine/update-settings` ability. Use the ability rather than `wp option patch` so the sanitizer in `data-machine-code.php` runs (PEM newlines are preserved; other fields go through `sanitize_text_field()`).
93+
94+
```bash
95+
# IDs are short, plain strings — set via the CLI shortcut.
96+
wp datamachine settings set github_app_id 3034937
97+
wp datamachine settings set github_app_installation_id 114752821
98+
```
99+
100+
The private key is multiline so the positional-arg CLI form rejects it. Use a small eval-file:
101+
102+
```bash
103+
cat > /tmp/set-gh-app-key.php <<'PHP'
104+
<?php
105+
$pem = file_get_contents( '/root/.secrets/homeboy-ci.private-key.pem' );
106+
if ( ! is_string( $pem ) || false === strpos( $pem, 'BEGIN' ) ) {
107+
fwrite( STDERR, "ERROR: PEM read failed\n" );
108+
exit( 1 );
109+
}
110+
111+
$ability = wp_get_ability( 'datamachine/update-settings' );
112+
if ( ! $ability ) {
113+
fwrite( STDERR, "ERROR: datamachine/update-settings ability not registered\n" );
114+
exit( 1 );
115+
}
116+
117+
$result = $ability->execute( array( 'github_app_private_key' => $pem ) );
118+
if ( is_wp_error( $result ) ) {
119+
fwrite( STDERR, 'ability_error: ' . $result->get_error_message() . "\n" );
120+
exit( 1 );
121+
}
122+
123+
$stored = \DataMachine\Core\PluginSettings::get( 'github_app_private_key', '' );
124+
printf(
125+
"configured: %s\nbyte_count: %d\n",
126+
false !== strpos( $stored, 'BEGIN' ) ? 'yes' : 'no',
127+
strlen( $stored )
128+
);
129+
PHP
130+
131+
wp --allow-root --path=/path/to/wordpress eval-file /tmp/set-gh-app-key.php
132+
rm /tmp/set-gh-app-key.php
133+
```
134+
135+
Expected:
136+
137+
```
138+
configured: yes
139+
byte_count: 1674
140+
```
141+
142+
Byte count varies (~1700 for a 2048-bit key). `configured: yes` is the contract.
143+
144+
## Step 4 — Define credential profiles
145+
146+
`github_credential_profiles` is the new shape that replaces the legacy single-credential keys. Each profile is independently selectable per-call via `selector: { profile_id: '...' }`. `github_default_profile_id` points at whichever profile zero-arg `resolve()` returns.
147+
148+
Recommended layout: keep the App as the default, demote the existing PAT to a named fallback.
149+
150+
```bash
151+
cat > /tmp/set-gh-profiles.php <<'PHP'
152+
<?php
153+
$ability = wp_get_ability( 'datamachine/update-settings' );
154+
$profiles = array(
155+
array(
156+
'id' => 'homeboy-ci',
157+
'label' => 'homeboy-ci[bot] (GitHub App)',
158+
'mode' => 'app',
159+
'default_repo' => '',
160+
'allowed_repos' => array(),
161+
),
162+
array(
163+
'id' => 'personal-pat',
164+
'label' => 'Chris personal PAT (fallback)',
165+
'mode' => 'pat',
166+
'pat' => \DataMachine\Core\PluginSettings::get( 'github_pat', '' ),
167+
'default_repo' => '',
168+
'allowed_repos' => array(),
169+
),
170+
);
171+
172+
$result = $ability->execute(
173+
array(
174+
'github_credential_profiles' => $profiles,
175+
'github_default_profile_id' => 'homeboy-ci',
176+
)
177+
);
178+
179+
if ( is_wp_error( $result ) ) {
180+
fwrite( STDERR, 'profiles_error: ' . $result->get_error_message() . "\n" );
181+
exit( 1 );
182+
}
183+
184+
echo "profiles_written: " . count( $profiles ) . "\n";
185+
echo "default: homeboy-ci\n";
186+
PHP
187+
188+
wp --allow-root --path=/path/to/wordpress eval-file /tmp/set-gh-profiles.php
189+
rm /tmp/set-gh-profiles.php
190+
```
191+
192+
`GitHubProfileSanitizer::sanitize()` (`inc/Support/GitHubProfileSanitizer.php`) enforces shape: each profile must have a non-empty `id`, a `mode` in `{pat, app}`, and the credentials matching its mode. Unknown keys are dropped. Duplicate `id`s collapse to the last write.
193+
194+
**Per-repo scoping (optional).** To restrict a profile to specific repos, populate `allowed_repos`:
195+
196+
```php
197+
'allowed_repos' => array( 'Extra-Chill/extrachill-roadie', 'Extra-Chill/data-machine-code' ),
198+
```
199+
200+
When a caller passes `selector: { repo: '<owner/name>' }`, the resolver picks the profile whose `allowed_repos` contains that repo, or whose `default_repo` matches. Unmatched repos fall through to the default profile.
201+
202+
## Step 5 — Verify the resolver
203+
204+
```bash
205+
wp datamachine-code github status
206+
```
207+
208+
Expected:
209+
210+
```
211+
Configured credential profiles:
212+
Default profile: homeboy-ci
213+
setting value
214+
Auth Mode app
215+
Configured Configured
216+
GitHub App ID Configured
217+
GitHub App Installation ID Configured
218+
GitHub App Private Key Configured
219+
...
220+
id label mode configured
221+
personal-pat Chris personal PAT (fallback) pat yes
222+
homeboy-ci homeboy-ci[bot] (GitHub App) app yes
223+
```
224+
225+
The top-level `GitHub PAT` may show `Not configured` even though `personal-pat` shows configured in the profile list — that's the legacy single-cred surface vs the new profile surface, and both can coexist. The profile list is authoritative for `resolve()`.
226+
227+
## Step 6 — Smoke the bot identity
228+
229+
File a throwaway issue and verify the GitHub-side author:
230+
231+
```bash
232+
wp eval '
233+
$result = wp_get_ability( "datamachine/create-github-issue" )->execute(
234+
array(
235+
"repo" => "<org>/<test-repo>",
236+
"title" => "[smoke] GitHub App identity verification",
237+
"body" => "Closing immediately.",
238+
"labels" => array( "smoke-test" ),
239+
)
240+
);
241+
echo "issue_url: " . ( $result["html_url"] ?? "?" ) . PHP_EOL;
242+
echo "issue_number: " . ( $result["number"] ?? "?" ) . PHP_EOL;
243+
'
244+
```
245+
246+
Confirm the author on GitHub:
247+
248+
```bash
249+
gh api repos/<org>/<test-repo>/issues/<number> \
250+
--jq '{author: .user.login, author_type: .user.type}'
251+
```
252+
253+
Expected:
254+
255+
```json
256+
{"author":"<app-slug>[bot]","author_type":"Bot"}
257+
```
258+
259+
If `author_type` is still `User`, the resolver is falling back to the PAT — check `github_default_profile_id` and re-run.
260+
261+
Close the smoke issue:
262+
263+
```bash
264+
gh issue close <number> --repo <org>/<test-repo> --comment "Smoke verified."
265+
```
266+
267+
## Step 7 — Test the fallback path (optional)
268+
269+
To confirm the PAT profile is still selectable:
270+
271+
```bash
272+
wp eval '
273+
$result = wp_get_ability( "datamachine/create-github-issue" )->execute(
274+
array(
275+
"repo" => "<org>/<test-repo>",
276+
"title" => "[smoke] PAT fallback verification",
277+
"body" => "Closing immediately.",
278+
"labels" => array( "smoke-test" ),
279+
// Explicit selector overrides the default profile.
280+
"credential_selector" => array( "profile_id" => "personal-pat" ),
281+
)
282+
);
283+
'
284+
```
285+
286+
This issue should show your human GitHub username as the author.
287+
288+
If `credential_selector` is not yet plumbed through to the specific ability you're testing, the resolver test path is:
289+
290+
```bash
291+
wp eval '
292+
$resolved = \DataMachineCode\Support\GitHubCredentialResolver::resolve(
293+
null, null,
294+
array( "profile_id" => "personal-pat" )
295+
);
296+
echo "mode: " . ( $resolved["mode"] ?? "?" ) . PHP_EOL;
297+
echo "profile_id: " . ( $resolved["profile_id"] ?? "?" ) . PHP_EOL;
298+
'
299+
```
300+
301+
Expected:
302+
303+
```
304+
mode: pat
305+
profile_id: personal-pat
306+
```
307+
308+
## Troubleshooting
309+
310+
### `github_pat_not_configured` for profile "homeboy-ci"
311+
312+
The profile is declared as `mode: 'app'` but the resolver picked the PAT branch — this means the mode field got rewritten to `pat` somewhere. Inspect the stored profile:
313+
314+
```bash
315+
wp eval 'var_dump( \DataMachine\Core\PluginSettings::get( "github_credential_profiles", array() ) );'
316+
```
317+
318+
Common cause: rewriting the profile array without setting `mode` explicitly. Always include `'mode' => 'app'` in the App profile entry.
319+
320+
### `github_app_token_request_failed` with HTTP 401
321+
322+
The installation rejected the JWT. Three things to check:
323+
324+
1. App ID matches the deployed App (compare with `gh api /orgs/<org>/installations`).
325+
2. Installation ID matches the install on this org (the same API call returns it).
326+
3. The PEM file is the matching private key for the App. If you regenerated the key after first install, the old PEM is rejected — store the new one and re-run step 3.
327+
328+
### Installation token cache poisoned
329+
330+
The resolver caches installation tokens in `transient_datamachine_code_github_app_token_<hash>`. To force a fresh mint:
331+
332+
```bash
333+
wp transient delete --all
334+
```
335+
336+
Cheap; only DMC's GitHub App tokens are stored under that prefix.
337+
338+
### JWT signing fails with `openssl_sign() not available`
339+
340+
Either install the `openssl` PHP extension (preferred — bundled with most distros) or confirm `firebase/php-jwt` is in the Composer autoloader. The resolver falls back to `firebase/php-jwt` automatically when the OpenSSL extension is missing.
341+
342+
## Behavioral notes
343+
344+
- **Per-call token minting** means each batch of GitHub API calls runs against a token that's at most ~1 hour old. There's no long-lived secret to rotate beyond the App's private key itself.
345+
- **Token expiry is observed.** The resolver re-mints when the cached token is within 60 seconds of expiry (constant `APP_TOKEN_EXPIRY_SKEW`). Long-running CLI commands don't hit "token expired" errors mid-run.
346+
- **Author identity is determined by the credential.** `mode: 'app'` posts as `<app-slug>[bot]`. `mode: 'pat'` posts as the PAT owner. Consumers don't have to know — they call abilities, the resolver picks the credential, GitHub does the rest.
347+
- **Audit trail.** Every API call from a `mode: 'app'` profile is attributable to the App installation (visible in the org's audit log) and tagged with the bot author on every commit/issue/PR/comment.
348+
- **Removing the PAT entirely.** Optional. The PAT can stay as a fallback profile or be dropped from `github_credential_profiles`. Removing it is recommended once OAuth-linked per-user accounts are available (so individual humans have their own identities) and the bot covers all automation.

0 commit comments

Comments
 (0)