Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/claude-pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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' }}
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]`.

Expand Down Expand Up @@ -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) |
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
68 changes: 53 additions & 15 deletions pr-review/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:*)"
Expand Down Expand Up @@ -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"
Expand All @@ -254,28 +284,33 @@ 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
# post rather than silently posting success (fail-open) or failure (punitive).
# 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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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 "")
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down