Skip to content
Merged
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
152 changes: 31 additions & 121 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -1,137 +1,47 @@
name: Claude Code Review

# Runs on PRs INTO this repo. We use pull_request_target (not pull_request) so
# that PRs from a fork can access CLAUDE_CODE_OAUTH_TOKEN — GitHub withholds
# secrets from `pull_request` runs triggered by forks, which is why the plain
# `pull_request` version never worked for fork PRs.
# Thin caller. All logic lives in Postgres-Extensions/ai; read that file for
# the SECURITY rationale behind pull_request_target + the trusted-author gate.
# Everything below is the minimum GitHub requires to live in THIS repo:
# - the trigger (a called workflow cannot declare its own)
# - run-level concurrency (only a workflow-level `concurrency:` can cancel
# the whole caller run outright; `jobs.<id>.concurrency` on the caller job
# itself can't, and the per-label cancellation logic below needs exactly
# that)
# - the GITHUB_TOKEN ceiling (a called workflow can only narrow it, never widen)
# Do not add logic here. If this repo needs different behavior, change ai/ so
# every repo gets it.
#
# SECURITY: pull_request_target runs in the BASE repo with secrets and a
# 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 (master), so a PR
# cannot modify the reviewer that runs on it. We never check out the fork's PR
# head: GitHub Actions refuses that combination by default (the "pwn request"
# guard — see actions/checkout's allow-unsafe-pr-checkout), and
# anthropics/claude-code-action's own docs (docs/security.md) recommend
# checking out the base ref and letting the action read PR content via the
# GitHub API instead. The code-review prompt passes the PR number; the action
# has a GitHub token and pull-requests read/write, so it fetches the diff
# itself (e.g. `gh pr diff`) without ever writing fork code to disk.
# Pinned to @main, not @v1: this repo is the permanent canary for
# claude-code-review.yml. A change to that file runs here for real before the
# v1 tag protecting every other consuming repo is ever moved to include it.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
# `labeled` lets adding the claude-debug label start a run on its own, with
# no push needed. Scoped in ai/'s job `if:` so only that label proceeds.
types: [opened, synchronize, reopened, ready_for_review, labeled]

concurrency:
group: claude-review-${{ github.event.pull_request.number }}
# A non-debug `labeled` event gets its own per-label group so it can never
# cancel an in-progress real review: cancellation resolves when a run is
# admitted, before any `if:` is evaluated, so an `if:` can only no-op itself,
# not un-cancel what it displaced. labeled+claude-debug deliberately keeps the
# plain group -- it is meant to supersede a running review.
# 'claude-debug' is spelled out because `inputs` is not readable here; it must
# match ai/'s debug_label default.
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 author only, and skip drafts (don't spend API/CI on unfinished PRs).
# To add more trusted authors, extend the author check.
if: >-
github.event.pull_request.draft == false &&
github.event.pull_request.user.login == 'jnasbyupgrade'
runs-on: ubuntu-latest
timeout-minutes: 60
uses: Postgres-Extensions/ai/.github/workflows/claude-code-review.yml@main
permissions:
contents: read
pull-requests: write # post the review comments
checks: read # read sibling check-runs for the cost gate
# write (not just read) needed so claude-code-action's internal
# bun-setup step can save its cache; read-only causes a harmless but
# noisy "Cache reservation failed: cache write denied: token has no
# writable scopes" warning.
# actions: write is the only scope that permits an Actions cache write
# (no narrower one exists). Don't "tighten" this to read.
actions: write
steps:
# 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
# reviewing a PR that is already known-broken. Uniform across all repos:
# it discovers sibling checks dynamically (no per-repo workflow names).
# - decision=run : all sibling checks completed with a good conclusion,
# OR no sibling checks exist after a short grace window
# (nothing to gate on), OR the poll timed out is treated
# as skip (see below).
# - decision=skip : at least one sibling check failed/cancelled/etc, or
# we timed out waiting for still-pending checks.
# We exclude this workflow's own check-run (job name `claude-review`) so the
# gate never waits on or fails because of itself.
- name: Wait for CI; skip the paid review if any check failed
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.event.pull_request.head.sha }}
run: |
decision=skip
for i in $(seq 1 72); do # ~24 min max
json=$(gh api "repos/$REPO/commits/$SHA/check-runs" --paginate \
--jq '[.check_runs[] | select(.name != "claude-review")]' 2>/dev/null) || json=''
[ -z "$json" ] && { sleep 20; continue; }
total=$(jq 'length' <<<"$json")
if [ "$total" -eq 0 ]; then
[ "$i" -ge 9 ] && { decision=run; break; } # ~3 min grace: nothing to gate on
sleep 20; continue
fi
pending=$(jq '[.[]|select(.status!="completed")]|length' <<<"$json")
if [ "$pending" -eq 0 ]; then
bad=$(jq '[.[]|select((.conclusion//"")|test("^(failure|cancelled|timed_out|action_required|stale)$"))]|length' <<<"$json")
[ "$bad" -eq 0 ] && decision=run || decision=skip
break
fi
sleep 20
done
echo "decision=$decision" >> "$GITHUB_OUTPUT"
echo "gate decision: $decision"

- name: Check out base branch
if: steps.gate.outputs.decision == 'run'
# Intentionally tracks the major-version tag (not a pinned SHA) so
# upstream fixes are picked up automatically.
#
# No `repository:`/`ref:` here on purpose — this checks out the base
# branch (master), never the fork's PR head. See the SECURITY note
# above.
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false

- name: Run Claude Code Review
if: steps.gate.outputs.decision == 'run'
# Pinned to an immutable SHA: this job runs as pull_request_target with
# pull-requests: write, so a moved upstream tag must not change what
# runs -- same rationale as github-script's pin in pgxntool's
# ci.yml/protect-label.yml.
uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# 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 }}
# 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'
# --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: 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'
secrets: inherit
with:
trusted_authors: jnasbyupgrade