Skip to content
Open
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
122 changes: 97 additions & 25 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,83 @@ name: Claude Code Review
# write-capable token. The job is gated to PRs from the trusted `jnasbyupgrade`
# fork only — an arbitrary external fork can never trigger this secret-bearing
# job. The workflow file always comes from the base branch (main), so a PR
# cannot modify the reviewer that runs on it. We check out the PR head only for
# read context (persist-credentials: false) and never build or execute PR code.
# cannot modify the reviewer that runs on it. We never check out the fork's PR
# head ourselves here: anthropics/claude-code-action's own internal checkout
# logic (setupBranch() in src/github/operations/branch.ts) already fetches the
# PR branch via `git fetch origin pull/<N>/head`, which requires `origin` to be
# the BASE repo -- checking out the fork directly instead (as a prior version
# of this file did) points `origin` at the fork, which has no such ref, and
# breaks that fetch with "couldn't find remote ref pull/<N>/head".
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
# labeled: lets adding the claude-debug label (see the "Check for
# claude-debug label" step below) kick off a fresh run by itself, with no
# push/re-run needed. Scoped in the job's `if:` below to only actually
# proceed when the label added IS claude-debug -- otherwise every
# unrelated label added to a PR would trigger another paid review.
types: [opened, synchronize, reopened, ready_for_review, labeled]

concurrency:
group: claude-review-${{ github.event.pull_request.number }}
# Concurrency cancellation resolves when a run is admitted, before the
# job's `if:` is evaluated -- a job's `if:` can only no-op itself, it can't
# un-cancel whatever the run already displaced. So only a labeled event
# whose label is NOT claude-debug gets its own per-label group here,
# keeping it from ever colliding with (and cancelling) the real review's
# group. labeled+claude-debug deliberately keeps the plain group, since
# it's meant to supersede an in-progress review.
group: claude-review-${{ github.event.pull_request.number }}${{ (github.event.action == 'labeled' && github.event.label.name != 'claude-debug') && format('-{0}', github.event.label.name) || '' }}
cancel-in-progress: true

jobs:
claude-review:
# Trusted fork only, and skip drafts (don't spend API/CI on unfinished PRs).
# To add more trusted owners, extend the head-owner check.
#
# SECURITY-CRITICAL: this owner check is the ONLY thing that makes
# `allow-unsafe-pr-checkout: true` below acceptable. Without it, this
# pull_request_target job would check out and let Claude act on
# arbitrary fork code while holding base-repo secrets/token — a "pwn
# request". Do not remove or loosen this condition (e.g. drop the
# owner check, or allow non-owner forks) without re-evaluating the
# fork-checkout step's safety.
# SECURITY-CRITICAL: this owner check is what makes it safe to run this
# pull_request_target job -- which holds base-repo secrets/token -- on
# every fork PR unattended. Do not remove or loosen this condition (e.g.
# drop the owner check, or allow non-owner forks) without re-evaluating
# whether this job should keep running on arbitrary forks.
if: >-
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.owner.login == 'jnasbyupgrade'
github.event.pull_request.head.repo.owner.login == 'jnasbyupgrade' &&
(github.event.action != 'labeled' || github.event.label.name == 'claude-debug')
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
pull-requests: write # post the review comments
checks: read # read sibling check-runs for the cost gate
# GitHub has no narrower "cache write" scope -- actions: write is the
# only permission that lets a step save an Actions cache entry (it also
# grants cancelling/deleting workflow runs and managing artifacts, which
# this job doesn't use). Without it, claude-code-review's own setup step
# can never write a cache, only ever miss. Granted here on top of the
# existing trusted-fork-owner gate below, not instead of it.
actions: write
steps:
# DEBUG MODE: add the "claude-debug" label to a PR to (a) skip the cost
# gate below entirely -- a debug session shouldn't wait 5-20+ min per
# iteration on sibling CI -- and (b) get show_full_output: true on the
# Run Claude Code Review step, dumping the full raw Claude Code JSON
# transcript (including tool results -- see that input's own WARNING
# below) to the job log. This is how you'd catch something like a
# silently-swallowed `--comment` flag (see that step's other comment).
# Queried live via `gh pr view`, not the static event payload, so
# adding the label and clicking "Re-run jobs" on an existing run picks
# it up without needing a new push.
- name: Check for claude-debug label
id: debug
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
run: |
enabled=$(gh pr view "$PR" --repo "$REPO" --json labels \
--jq 'any(.labels[]; .name == "claude-debug")' 2>/dev/null) || enabled=false
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
echo "claude-debug label present: $enabled"

# COST GATE: the paid Claude review is the last thing to run. Wait for the
# PR head's OTHER check-runs to finish and only proceed if they are clean.
# If any sibling check failed we skip the review to avoid spending money
Expand All @@ -56,6 +101,7 @@ jobs:
# gate never waits on or fails because of itself.
- name: Wait for CI; skip the paid review if any check failed
id: gate
if: steps.debug.outputs.enabled != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
Expand All @@ -82,35 +128,61 @@ jobs:
echo "decision=$decision" >> "$GITHUB_OUTPUT"
echo "gate decision: $decision"

- name: Check out PR head (read-only context)
if: steps.gate.outputs.decision == 'run'
- name: Check out base branch
if: steps.debug.outputs.enabled == 'true' || steps.gate.outputs.decision == 'run'
# Intentionally tracks the major-version tag (not a pinned SHA) so
# upstream fixes are picked up automatically.
uses: actions/checkout@v6
#
# No `repository:`/`ref:` here on purpose — this checks out the base
# branch (main), never the fork's PR head. See the SECURITY note
# above; anthropics/claude-code-action fetches the actual PR head
# itself afterward via the base repo's `refs/pull/<N>/head` ref.
uses: actions/checkout@v7
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
persist-credentials: false
# Unsafe by default (actions/checkout refuses fork-PR checkouts
# under pull_request_target/workflow_run). Only OK here because
# the job is gated to the project owner's forks — see the `if:`
# on the `claude-review` job above; that check is what makes
# this safe.
allow-unsafe-pr-checkout: true

- name: Run Claude Code Review
if: steps.gate.outputs.decision == 'run'
if: steps.debug.outputs.enabled == 'true' || steps.gate.outputs.decision == 'run'
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# See the "Check for claude-debug label" step above -- WARNING (from
# this input's own description): outputs ALL Claude messages
# including tool execution results, which may contain secrets, and
# these logs are publicly visible in GitHub Actions.
show_full_output: ${{ steps.debug.outputs.enabled == 'true' }}
# Provide github_token so the action uses it directly for GitHub API
# calls instead of the OIDC->GitHub-App-token exchange, which 401s under
# pull_request_target. GITHUB_TOKEN is repo/workflow-scoped (independent
# of the actor's role) and has pull-requests: write here.
github_token: ${{ secrets.GITHUB_TOKEN }}
# A `prompt:` input puts the action in "automation mode", which by
# default posts nothing until the whole run finishes -- there's no
# visibility into a review that runs long. track_progress forces a
# tracking PR comment with a live checklist that updates as Claude
# works, so a slow run is visible instead of silent.
track_progress: true
# NOTE: plugin_marketplaces can't be pinned — it tracks the
# marketplace repo's default branch (upstream anthropics/claude-code).
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# --comment is required: without it, the code-review plugin only
# prints its findings to the job log and never posts anything to
# the PR (confirmed by capturing the hidden SDK transcript on a
# canary PR in pgxntool-test: the review correctly found an
# injected bug but ended with "No `--comment` argument was
# provided, so no GitHub comments were posted"). Every review run
# before this fix has been silently invisible on GitHub.
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }} --comment'
# A direct `prompt:` (no @claude mention) runs the action in "agent
# mode". In that mode, claude-code-action only installs the
# github_inline_comment MCP server if it sees
# mcp__github_inline_comment__create_inline_comment listed in an
# --allowedTools flag inside claude_args (src/modes/agent/parse-tools.ts) --
# it does NOT look at the code-review plugin's own `allowed-tools`
# frontmatter to decide that. Without this, the MCP server never
# starts, the tool genuinely doesn't exist in the session, and the
# plugin silently falls back to one consolidated PR comment instead
# of real inline line comments.
claude_args: '--allowedTools mcp__github_inline_comment__create_inline_comment'