diff --git a/.github/workflows/claude-pr-review.yml b/.github/workflows/claude-pr-review.yml index 57e00c4..56f5713 100644 --- a/.github/workflows/claude-pr-review.yml +++ b/.github/workflows/claude-pr-review.yml @@ -12,6 +12,10 @@ on: secrets: claude_code_oauth_token: required: true + app_id: + required: true + app_private_key: + required: true inputs: model: description: 'Claude model to use' @@ -54,5 +58,7 @@ jobs: - uses: glitchwerks/github-actions/pr-review@v2 with: claude_code_oauth_token: ${{ secrets.claude_code_oauth_token || secrets.CLAUDE_CODE_OAUTH_TOKEN }} + app_id: ${{ secrets.app_id || secrets.APP_ID }} + app_private_key: ${{ secrets.app_private_key || secrets.APP_PRIVATE_KEY }} model: ${{ inputs.model || 'claude-sonnet-4-5' }} max_turns: ${{ inputs.max_turns || '30' }} diff --git a/CLAUDE.md b/CLAUDE.md index 5ce4b17..1084648 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ The `claude-*.yml` reusable workflows (everything except `ci-failure.yaml` and ` **Workflows with `container: ghcr.io/...` must declare `packages: read`** in their `permissions:` block when the image is in an org-owned private GHCR package. GitHub Actions issues an implicit `docker login + docker pull` before the job container starts, authenticated with `GITHUB_TOKEN`; without `packages: read` the registry returns `manifest unknown` (an authorization-masked error that looks like the image is missing). All five Phase 5 (#188) container-pinned reusable workflows hit this trap because their `permissions:` blocks were carried over from the pre-container form — see #192 for the diagnosis and the hotfix that added `packages: read` to each. Add this scope alongside `contents:`, `pull-requests:`, etc. for any future workflow with a `container:` directive pointing at GHCR. -**Token selection for `claude-code-action`:** Use `github_token: ${{ github.token }}` for read-only operations (PR review, which does not push commits). Use the resolved App token (`${{ steps.token.outputs.value }}`) when `claude-code-action` must push commits — tag responses, lint-diagnose, lint-failure, ci-failure, and apply-fix all pass the App token. GitHub suppresses `synchronize` events for pushes authenticated with `GITHUB_TOKEN`, so an App token is required to re-trigger downstream workflows like `pr-review`. App tokens are short-lived and show a distinct bot identity (e.g., `my-app[bot]`). +**Token selection for `claude-code-action`:** All workflows now use the resolved App token (`${{ steps.token.outputs.value }}`) for `github_token` — including `pr-review`, which does not push commits but benefits from a consistent App bot identity. The prior carve-out that used `github.token` (surfacing as `github-actions[bot]`) for read-only PR review was reversed under issue #250 for identity consistency across all Claude-powered workflows. GitHub suppresses `synchronize` events for pushes authenticated with `GITHUB_TOKEN`, so an App token is required to re-trigger downstream workflows like `pr-review` on those paths as well. App tokens are short-lived and show a distinct bot identity (e.g., `my-app[bot]`). **Composite action inputs are always strings** — there is no `type` field. Boolean inputs like `require_association` arrive as the string `'true'`/`'false'` and must be compared with `[ "$VAR" = "true" ]`. @@ -103,5 +103,5 @@ When changes are released: move both `v2` and the new `v2.x.x` tag to the latest | Secret | Used by | |---|---| | `CLAUDE_CODE_OAUTH_TOKEN` | All `claude-code-action` invocations | -| `APP_ID` | `ci-failure.yaml`, `apply-fix`, `lint-failure` — GitHub App ID for generating short-lived tokens for git push and API calls | +| `APP_ID` | `ci-failure.yaml`, `apply-fix`, `lint-failure`, `pr-review` — GitHub App ID for generating short-lived tokens for git push and API calls | | `APP_PRIVATE_KEY` | Same as above — GitHub App private key (PEM format) | diff --git a/README.md b/README.md index b25baf4..f7a8192 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Reusable GitHub Actions and workflows for Claude-powered automation — PR review, `@claude` mention responses, lint-failure diagnosis, CI-failure diagnosis, and manual fix application — built on top of [`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action). -`CLAUDE_CODE_OAUTH_TOKEN` is required for all actions. Write-capable actions (those that push commits or trigger downstream workflows) additionally require `APP_ID` and `APP_PRIVATE_KEY` — credentials for a GitHub App that issues short-lived tokens at job time. +`CLAUDE_CODE_OAUTH_TOKEN` is required for all actions. All actions also require `APP_ID` and `APP_PRIVATE_KEY` — credentials for a GitHub App that issues short-lived tokens at job time, ensuring a consistent bot identity (e.g., `my-app[bot]`) across all Claude-powered comments and commits. **New consumer?** Start with [docs/consumer-onboarding.md](docs/consumer-onboarding.md) — an end-to-end walkthrough for wiring this library into a `glitchwerks` org repo for the first time. The reference docs below assume you have already completed the basic setup it covers. @@ -61,6 +61,8 @@ jobs: uses: glitchwerks/github-actions/.github/workflows/claude-pr-review.yml@v2 secrets: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + app_id: ${{ secrets.APP_ID }} + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} ``` Optional inputs: @@ -157,15 +159,15 @@ If you need more control (e.g., embed the review step inside a larger job), use ### Breaking changes - **All `uses:` references must change from `@v1` to `@v2`.** Every workflow or composite action call that pins to `@v1` must be updated. -- **`gh_pat` input has been removed.** The deprecated `gh_pat` input is no longer accepted by any action. `app_id` + `app_private_key` are now required for all write-capable actions (`tag-claude`, `lint-failure`, `ci-failure`, `apply-fix`). +- **`gh_pat` input has been removed.** The deprecated `gh_pat` input is no longer accepted by any action. `app_id` + `app_private_key` are now required for all actions (`tag-claude`, `lint-failure`, `ci-failure`, `apply-fix`, `pr-review`). - **`tag-claude` no longer falls back to `github.token`.** An App token is mandatory — omitting `app_id` and `app_private_key` will cause the action to fail at the token resolution step. -- **All write-capable actions now post under the GitHub App's bot identity** (e.g., `my-app[bot]`) rather than under `github-actions[bot]` or a PAT's user identity. +- **All actions now post under the GitHub App's bot identity** (e.g., `my-app[bot]`) rather than under `github-actions[bot]` or a PAT's user identity. This includes `pr-review`, which previously used `github.token` (issue #250). ### Migration steps 1. Update every `uses: glitchwerks/github-actions/...@v1` line (and `uses: .github/workflows/...@v1`) to `@v2`. 2. If you were passing `gh_pat`, create a GitHub App and add `APP_ID` + `APP_PRIVATE_KEY` as repository secrets. See the GitHub App setup section for instructions. -3. For `tag-claude` consumers: ensure your caller workflow passes `app_id` and `app_private_key` secrets. +3. For `tag-claude` and `pr-review` consumers: ensure your caller workflow passes `app_id` and `app_private_key` secrets. ### Before and after @@ -477,4 +479,4 @@ actionlint # from repo root ## Prerequisites - A `CLAUDE_CODE_OAUTH_TOKEN` secret must be set on the consuming repository (or organization). Obtain this token from [claude.ai](https://claude.ai). -- A GitHub App (`APP_ID` + `APP_PRIVATE_KEY`) is required for write operations (git push, triggering downstream workflows). See the GitHub App setup section under CI Failure Diagnosis for instructions. +- A GitHub App (`APP_ID` + `APP_PRIVATE_KEY`) is required for all actions — for identity consistency (`pr-review`) and write operations (git push, triggering downstream workflows) in the others. See the GitHub App setup section under CI Failure Diagnosis for instructions. diff --git a/pr-review/action.yml b/pr-review/action.yml index 2b3affe..a451d5d 100644 --- a/pr-review/action.yml +++ b/pr-review/action.yml @@ -5,6 +5,12 @@ inputs: claude_code_oauth_token: description: 'Claude Code OAuth token' required: true + app_id: + description: 'GitHub App ID for generating a short-lived token (required for App-identity comments)' + required: false + app_private_key: + description: 'GitHub App private key for generating a short-lived token (required for App-identity comments)' + required: false model: description: 'Claude model to use' required: false @@ -194,12 +200,35 @@ runs: echo "::warning::No baked persona at /opt/claude/.claude/CLAUDE.md — running without overlay persona" fi + - name: Generate App token + id: app-token + if: inputs.app_id != '' && inputs.app_private_key != '' + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ inputs.app_id }} + private-key: ${{ inputs.app_private_key }} + + - name: Resolve review token + id: token + shell: bash + # shellcheck disable=SC2016 + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ -n "$APP_TOKEN" ]; then + echo "::debug::Using GitHub App token for review identity" + echo "value=$APP_TOKEN" >> "$GITHUB_OUTPUT" + else + echo "::error::No authentication token provided. Set app_id + app_private_key inputs. See README for GitHub App setup instructions." + exit 1 + fi + - uses: anthropics/claude-code-action@v1 id: claude-review if: steps.authz.outputs.skip != 'true' && steps.size-check.outputs.skip != 'true' with: claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }} - github_token: ${{ github.token }} + github_token: ${{ steps.token.outputs.value }} use_sticky_comment: true track_progress: true claude_args: --max-turns ${{ env.EFFECTIVE_MAX_TURNS }} --model ${{ inputs.model }} --allowedTools "Bash(gh pr diff:*),Bash(gh pr review:*),Bash(gh pr view:*)" @@ -246,6 +275,7 @@ runs: # status checks per the HEAD commit of the PR's head branch. HEAD_SHA: ${{ github.event.pull_request.head.sha }} SEVERITY_REGEX_SH: ${{ github.action_path }}/lib/severity-regex.sh + REVIEW_START_TIME: ${{ env.REVIEW_START_TIME }} run: | set -euo pipefail REPO="$GH_REPOSITORY" @@ -254,20 +284,25 @@ runs: # shellcheck disable=SC1090 source "$SEVERITY_REGEX_SH" - # Fetch the most recent comment authored by github-actions[bot]. Claude's - # review uses use_sticky_comment: true, but in practice each new run - # produces a separate sticky comment (multiple bot comments accumulate - # on a PR with multiple pushes). We grep the genuinely-latest one. + # Fetch the most recent comment authored by any [bot] account *that was + # updated during this review run*. The endswith("[bot]") matcher picks + # up Claude's App-identity comment regardless of bot login (#250 made + # the matcher login-agnostic), but it would also pick up stale comments + # from other bots (Dependabot, Renovate, CodeQL, etc.) — so we MUST + # gate by REVIEW_START_TIME, otherwise a foreign bot updating its own + # comment after Claude finishes would silently win the sort and the + # gate would read the wrong body. Mirrors the synthesis step at + # ~line 370 and the incomplete-review check at ~line 589. # # IMPORTANT (#184 fix): the GitHub REST API's /issues/{n}/comments # endpoint does NOT support sort/direction query params — those are # silently ignored and results come back in created_at ASC order. # We must sort manually in jq. Using sort_by(.updated_at) | last - # picks the most-recently-edited bot comment regardless of API - # default ordering. Pagination: 100 comments is enough for any + # picks the most-recently-edited matching bot comment regardless of + # API default ordering. Pagination: 100 comments is enough for any # realistic PR; if a PR ever exceeds, the gate misses newer pages. BODY=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \ - --jq 'map(select(.user.login == "github-actions[bot]")) | sort_by(.updated_at) | last | .body // ""' \ + --jq "map(select((.user.login | endswith(\"[bot]\")) and .updated_at >= \"$REVIEW_START_TIME\")) | sort_by(.updated_at) | last | .body // \"\"" \ 2>/dev/null || echo "") # If no bot comment exists, the review has not completed — skip the status @@ -275,7 +310,7 @@ runs: # Branch protection will see no status and treat it as a missing required # check, which correctly blocks merge until the review actually runs. if [ -z "$BODY" ]; then - echo "Quality gate: no github-actions[bot] sticky comment found — skipping status post" + echo "Quality gate: no [bot] sticky comment found — skipping status post" exit 0 fi @@ -338,7 +373,7 @@ runs: # In practice each new run produces a separate sticky comment (multiple bot # comments accumulate on a PR with multiple pushes — see action.yml line 254). COMMENT_JSON=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \ - --jq "map(select(.user.login == \"github-actions[bot]\") | select(.updated_at >= \"$REVIEW_START_TIME\")) | sort_by(.updated_at) | last // empty" \ + --jq "map(select((.user.login | endswith(\"[bot]\")) and .updated_at >= \"$REVIEW_START_TIME\")) | sort_by(.updated_at) | last // empty" \ 2>/dev/null || echo "") if [ -z "$COMMENT_JSON" ]; then @@ -428,6 +463,7 @@ runs: # potentially hitting API replication lag on the just-patched body. # shellcheck disable=SC2016 SYNTHESIS_COMMENT_ID: ${{ env.SYNTHESIS_COMMENT_ID }} + REVIEW_START_TIME: ${{ env.REVIEW_START_TIME }} run: | set -euo pipefail REPO="$GH_REPOSITORY" @@ -438,7 +474,9 @@ runs: # --- Fetch the review comment body. ------------------------------------------ # If the synthesis step pinned a comment ID to $GITHUB_ENV, use it directly # (avoids replication-lag race on the just-PATCHed body). Otherwise fall back - # to the sort_by(updated_at)|last selector. + # to the sort_by(updated_at)|last selector, gated by REVIEW_START_TIME so a + # foreign bot's stale comment can't win the sort under endswith("[bot]") + # (#250) — mirrors the authoritative gate's selector. if [ -n "${SYNTHESIS_COMMENT_ID:-}" ]; then BODY=$(gh api "repos/$REPO/issues/comments/$SYNTHESIS_COMMENT_ID" \ --jq '.body // ""' 2>/dev/null || echo "") @@ -448,13 +486,13 @@ runs: # edited bot comment; the API returns created_at ASC by default and does not # honour sort/direction params. BODY=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \ - --jq 'map(select(.user.login == "github-actions[bot]")) | sort_by(.updated_at) | last | .body // ""' \ + --jq "map(select((.user.login | endswith(\"[bot]\")) and .updated_at >= \"$REVIEW_START_TIME\")) | sort_by(.updated_at) | last | .body // \"\"" \ 2>/dev/null || echo "") fi # Skip entirely when there is no bot comment (review did not complete). if [ -z "$BODY" ]; then - echo "Shadow gate: no github-actions[bot] sticky comment found — skipping" + echo "Shadow gate: no [bot] sticky comment found — skipping" exit 0 fi @@ -553,11 +591,11 @@ runs: # Skip the warning if Claude already posted a review comment during this run. # Claude posts its summary as a regular PR comment (via use_sticky_comment), not a # formal review submission, so we check comments rather than reviews. - # Filter by author (github-actions[bot]) and timestamp to avoid false positives + # Filter by author (any [bot]) and timestamp to avoid false positives # from human comments or comments posted by a prior run on the same PR. COMMENT_COUNT=$(gh pr view "$PR_NUMBER" --json comments \ | jq --arg since "$REVIEW_START_TIME" \ - '[.comments[] | select(.author.login == "github-actions[bot]") | select(.createdAt > $since)] | length' \ + '[.comments[] | select(.author.login | endswith("[bot]")) | select(.createdAt > $since)] | length' \ 2>/dev/null || echo "0") if [ "$COMMENT_COUNT" -gt "0" ]; then