|
| 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