diff --git a/platform/cluster/flux/apps/agents/GITHUB-APP-SETUP.md b/platform/cluster/flux/apps/agents/GITHUB-APP-SETUP.md index 1f092a5c..7f076b73 100644 --- a/platform/cluster/flux/apps/agents/GITHUB-APP-SETUP.md +++ b/platform/cluster/flux/apps/agents/GITHUB-APP-SETUP.md @@ -7,9 +7,13 @@ Agent runners use **two** GitHub credentials with a deliberate split: broker is unavailable. - **GitHub App installation token (short-lived, repo-scoped)** — the workspace write path: `git push`, `gh pr create`, `gh pr comment`, - and `gh run rerun`. Permissions are `contents:write`, - `pull_requests:write`, and `actions:write` only; `administration` is - never requested, so this token cannot change repo settings. The + `gh run rerun`, authoring `.github/workflows` (the Pipeline Complete + pipeline), and keeping tracking issues current. Permissions are + `contents:write`, `pull_requests:write`, `actions:write`, + `issues:write`, `workflows:write`, and `packages:read`; + `administration` is never requested, so this token cannot change repo + settings or rulesets. `packages` is read-only — publishing is done by + the release workflow's own `GITHUB_TOKEN`, not by runner tokens. The `main` ruleset still blocks force-push and branch deletion. assistant-api mints a fresh, single-repo installation token on demand; @@ -42,7 +46,10 @@ and click **Create GitHub App** — or set the same values by hand at "metadata": "read", "contents": "write", "pull_requests": "write", - "actions": "write" + "actions": "write", + "issues": "write", + "workflows": "write", + "packages": "read" }, "default_events": [] }' @@ -153,7 +160,8 @@ but `git push` / `gh pr create` / `gh run rerun` behave read-only, and Cause: GitHub mints an installation token scoped to **whatever the installation actually holds**, silently dropping anything the App was not granted. assistant-api always _requests_ `contents`, -`pull_requests`, and `actions` at `write` +`pull_requests`, `actions`, `issues`, `workflows` at `write` and +`packages` at `read` (`GitHubAppInstallationTokenClient.REQUESTED_PERMISSIONS`), so a narrowed token means the **App itself** is under-permissioned — almost always one of: @@ -168,15 +176,18 @@ assistant-api logs this exact case at WARN on the next mint: ``` installation token for / is missing requested permissions -[actions, contents, pull_requests] (granted: {metadata=read}). Widen the -personal-stack-agents App's repository permissions … then approve … +[actions, contents, issues, packages, pull_requests, workflows] +(granted: {metadata=read}). Widen the personal-stack-agents App's +repository permissions … then approve … ``` Fix: 1. App settings → **Permissions → Repository permissions**: set Metadata: Read-only, **Contents: Read and write**, **Pull requests: - Read and write**, **Actions: Read and write**. Save. + Read and write**, **Actions: Read and write**, **Issues: Read and + write**, **Workflows: Read and write**, **Packages: Read-only**. Do + not grant Administration. Save. 2. For **each** account the App is installed on (ExtraToast and ESA-Blueshell), open the installation and **approve the updated permissions** (GitHub surfaces a "review request" banner; an org diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt index 906ed8b0..2a522ee9 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt @@ -18,10 +18,18 @@ import java.util.Base64 * Mints short-lived, single-repo GitHub App installation tokens so the * runner's `gh` can create PRs/re-run Actions and `git push` can create * feature branches over HTTPS. Each token is scoped to the one - * repository being acted on and carries only `contents:write`, - * `pull_requests:write`, and `actions:write`; `administration` is never - * requested, so the token cannot change repo settings. `main` stays - * guarded by branch protection. + * repository being acted on and carries `contents:write`, + * `pull_requests:write`, `actions:write`, `issues:write`, + * `workflows:write`, and `packages:read`; `administration` is never + * requested, so the token cannot change repo settings or rulesets. + * `main` stays guarded by branch protection. + * + * `workflows:write` lets the runner author the per-repo CI pipeline that + * ends in the required `Pipeline Complete` check; `issues:write` lets it + * keep tracking issues/milestones current; `packages:read` lets local + * builds resolve published `dev.extratoast.*` / `@extratoast` artifacts. + * Artifact publishing stays a CI concern (the release workflow's own + * `GITHUB_TOKEN`), so `packages:write` is not requested here. * * Disabled — [enabled] is false and [mint] returns null — whenever the * App id or private key is absent, so an unconfigured deployment is a @@ -90,9 +98,10 @@ class GitHubAppInstallationTokenClient( if (shortfall.isNotEmpty()) { log.warn( "installation token for {}/{} is missing requested permissions {} (granted: {}). " + - "Widen the personal-stack-agents App's repository permissions to contents/pull_requests/" + - "actions: read & write, then approve the updated permissions on the {} installation — " + - "until then runner git push / gh pr / gh run rerun stay read-only.", + "Widen the personal-stack-agents App's repository permissions (contents/pull_requests/" + + "actions/issues/workflows: read & write, packages: read), then approve the updated " + + "permissions on the {} installation — until then runner git push / gh pr / gh run rerun / " + + "workflow edits / issue edits stay restricted.", slug.owner, slug.repo, shortfall, @@ -183,14 +192,21 @@ class GitHubAppInstallationTokenClient( private const val GH_API_VERSION_HEADER = "X-GitHub-Api-Version" private const val GH_API_VERSION = "2022-11-28" - // The only permissions a runner token ever carries: enough for - // git push, gh pr create/comment, and gh run rerun. `administration` - // is deliberately absent so the token cannot change repo settings. + // The permissions a runner token carries: enough for git push, + // gh pr create/comment, gh run rerun, authoring `.github/workflows` + // (the Pipeline Complete pipeline), keeping tracking issues current, + // and resolving published packages for local builds. `administration` + // is deliberately absent so the token cannot change repo settings or + // rulesets; `packages` is read-only because publishing is done by the + // release workflow's own GITHUB_TOKEN, not by runner tokens. val REQUESTED_PERMISSIONS = mapOf( "contents" to "write", "pull_requests" to "write", "actions" to "write", + "issues" to "write", + "workflows" to "write", + "packages" to "read", ) private val PERMISSION_RANK = mapOf("read" to 1, "write" to 2, "admin" to 3) diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt index d09dcef4..71054f52 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt @@ -87,10 +87,14 @@ class GitHubAppInstallationTokenClientTest { .andExpect(jsonPath("$.permissions.contents").value("write")) .andExpect(jsonPath("$.permissions.pull_requests").value("write")) .andExpect(jsonPath("$.permissions.actions").value("write")) + .andExpect(jsonPath("$.permissions.issues").value("write")) + .andExpect(jsonPath("$.permissions.workflows").value("write")) + .andExpect(jsonPath("$.permissions.packages").value("read")) .andRespond( withSuccess( """{"token":"ghs_abc","expires_at":"2026-06-02T15:00:00Z",""" + - """"permissions":{"contents":"write","pull_requests":"write","actions":"write"}}""", + """"permissions":{"contents":"write","pull_requests":"write","actions":"write",""" + + """"issues":"write","workflows":"write","packages":"read"}}""", MediaType.APPLICATION_JSON, ), ) @@ -122,23 +126,37 @@ class GitHubAppInstallationTokenClientTest { fun `narrowedPermissions flags absent and weaker grants, and passes a full grant`() { val requested = GitHubAppInstallationTokenClient.REQUESTED_PERMISSIONS - // A metadata-only install grants none of contents/pull_requests/actions. + // A metadata-only install grants none of the requested permissions. assertThat(GitHubAppInstallationTokenClient.narrowedPermissions(requested, emptyMap())) - .containsExactly("actions", "contents", "pull_requests") + .containsExactly("actions", "contents", "issues", "packages", "pull_requests", "workflows") // contents granted read-only is weaker than the requested write. assertThat( GitHubAppInstallationTokenClient.narrowedPermissions( requested, - mapOf("contents" to "read", "pull_requests" to "write", "actions" to "write"), + mapOf( + "contents" to "read", + "pull_requests" to "write", + "actions" to "write", + "issues" to "write", + "workflows" to "write", + "packages" to "read", + ), ), ).containsExactly("contents") - // Exactly the requested write set — nothing narrowed. + // Exactly the requested set — nothing narrowed. assertThat( GitHubAppInstallationTokenClient.narrowedPermissions( requested, - mapOf("contents" to "write", "pull_requests" to "write", "actions" to "write"), + mapOf( + "contents" to "write", + "pull_requests" to "write", + "actions" to "write", + "issues" to "write", + "workflows" to "write", + "packages" to "read", + ), ), ).isEmpty() }