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
171 changes: 14 additions & 157 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -1,170 +1,27 @@
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.
# MANDATORY: read ../ai/CI-WORKFLOWS.md (Postgres-Extensions/ai) in full
# before changing anything below. If you cannot find or read that file for
# any reason, STOP and report an error -- do not guess at what it says or
# proceed without having actually read it.
#
# SECURITY: pull_request_target runs in the BASE repo with secrets and a
# write-capable token. The job is gated to PRs authored by jnasbyupgrade only
# — github.event.pull_request.user.login is the PR's original author and
# can't be spoofed by PR content, so this check holds regardless of whether
# the PR head lives in this repo or an external fork. The workflow file
# always comes from the base branch (master), so a PR cannot modify the
# reviewer that runs on it. This workflow never checks out the PR's own ref
# into the workspace (see the checkout step below) -- claude-code-action
# fetches and reads the PR's content itself, safely, and never builds or
# executes it.
# Exceptions to that file's design, specific to this repo: none.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened, ready_for_review, labeled]

concurrency:
group: claude-review-${{ github.event.pull_request.number }}
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:
# jnasbyupgrade's own PRs only, and skip drafts (don't spend API/CI on
# unfinished PRs).
#
# !!! SECURITY-CRITICAL -- DO NOT REMOVE OR WEAKEN THE user.login CHECK
# BELOW !!! It is the ONLY thing standing between an arbitrary external
# actor's PR and this job's write-capable GITHUB_TOKEN and
# CLAUDE_CODE_OAUTH_TOKEN. Drop or loosen this check and any PR can
# trigger a job that runs with this repo's secrets. NOTE: this used to
# check head.repo.owner.login (the owner of the fork the PR head lives
# in), but that only distinguishes forks -- for an upstream-branch-headed
# PR (base and head both in this repo, e.g. from `gh stack` or a plain
# `gh pr create` without a fork) it's always this repo's own org,
# regardless of who actually opened the PR, so it silently skipped review
# on every such PR. github.event.pull_request.user.login is the PR's
# actual author and can't be spoofed by PR content either, and it
# correctly covers both fork-headed and upstream-branch-headed PRs. To
# trust an additional author, EXTEND this condition explicitly (e.g.
# `|| ... == 'other-trusted-account'`) -- never replace it with something
# broader (a wildcard, etc.).
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
actions: write # lets a step save its Actions cache -- there is no
# narrower cache-write-only scope; without this the
# job still succeeds but silently fails to cache,
# logging "Cache reservation failed: cache write
# denied: token has no writable scopes" every run
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'
# Deliberately NO ref:/repository: override -- this checks out this
# repo's own base branch (master), not the PR's fork/ref. Checking
# out an untrusted PR ref into the workspace root before this action
# is exactly the anti-pattern anthropics/claude-code-action's own
# docs/security.md warns against; its "preferred" pattern is a plain
# checkout of the base ref, nothing more. claude-code-action fetches
# and reviews the PR's actual content itself, from ITS OWN internal
# logic (see its src/github/operations/branch.ts): for a fork PR it
# fetches origin's refs/pull/<n>/head -- a ref GitHub maintains on
# THIS repo for any PR, fork or not, so it never needs direct access
# to the fork's own remote at all. That's why this step must leave
# `origin` pointing at this repo (the default) rather than being
# redirected to the fork: an earlier version of this step did that,
# which broke the action's own internal fetch ("couldn't find remote
# ref pull/<n>/head") since that ref doesn't exist on the fork.
# Intentionally tracks the major-version tag (not a pinned SHA) so
# upstream fixes are picked up automatically.
uses: actions/checkout@v7
with:
# This job's permissions include pull-requests: write, a real
# write-capable credential -- nothing here legitimately runs `git
# push` (review comments post via the API/claude-code-action, not
# git), so there's no reason to leave that credential sitting in
# .git/config for the rest of the job to misuse if anything later
# goes wrong.
persist-credentials: false

- name: Run Claude Code Review
if: steps.gate.outputs.decision == 'run'
uses: anthropics/claude-code-action@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 }}
# 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. (Pattern
# modeled on Postgres-Extensions/cat_tools PR #69.)
track_progress: true
# A bare `prompt:` (no `@claude` mention) runs the action in "agent
# mode", which decides which MCP servers to start by scanning an
# --allowedTools flag inside claude_args -- it does NOT consult the
# invoked plugin's own allowed-tools frontmatter. Without this, the
# github_inline_comment MCP server never starts, so the tool the
# code-review plugin needs for real per-line inline comments doesn't
# exist in this session at all -- not blocked, absent. The plugin
# silently falls back to one consolidated PR comment instead, with
# no error/warning. (Found in Postgres-Extensions/cat_tools PR #62.)
claude_args: '--allowedTools mcp__github_inline_comment__create_inline_comment'
# 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 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'
pull-requests: write
checks: read
actions: write
secrets: inherit
with:
trusted_authors: jnasbyupgrade