Skip to content

CI: binary pg_upgrade testing, docs-only gate, single-source PG matrix #70

CI: binary pg_upgrade testing, docs-only gate, single-source PG matrix

CI: binary pg_upgrade testing, docs-only gate, single-source PG matrix #70

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.
#
# SECURITY: pull_request_target runs in the BASE repo with secrets and a
# write-capable token. The job is gated to PRs authored by the trusted
# `jnasbyupgrade` account only — an arbitrary external actor 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.
# 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.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
claude-review:
# !!! 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 anyone can trigger
# a job that runs with this repo's secrets. To trust an additional
# account, EXTEND this condition explicitly (e.g. `|| ... ==
# 'other-trusted-account'`) -- never replace it with something broader
# (a wildcard, etc.).
#
# Checks PR AUTHOR (github.event.pull_request.user.login), not head repo
# owner: an earlier version of this check used head.repo.owner.login,
# which only works for fork-headed PRs -- for an upstream-branch-headed
# PR (e.g. one opened for `gh stack`, base and head both in this repo),
# head.repo.owner.login is always this repo's OWN org, never the actual
# author, so that check silently skipped review on every such PR
# regardless of who opened it (caught when review kept skipping on a
# whole PR stack that was legitimately jnasbyupgrade's own work).
# user.login is the PR's original author and can't be spoofed by PR
# content (unlike, say, a string embedded in the PR body or a commit
# message), so this check holds regardless of whether the PR head lives
# in this repo or an external fork -- it's the right question here
# anyway: we're trusting the PERSON asking for a review to run, not the
# repository their branch happens to live in.
# Skips drafts too (don't spend API/CI on unfinished PRs).
if: >-
github.event.pull_request.draft == false &&
github.event.pull_request.user.login == 'jnasbyupgrade'
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
# No narrower scope exists for cache-write alone; without this,
# claude-code-action's own internal setup silently fails to save its
# Actions cache ("Cache reservation failed: cache write denied: token
# has no writable scopes") -- a warning, not a hard failure, so the job
# still completes and looks fine, just slower/uncached every run. Don't
# try to "tighten" this down to something narrower; it doesn't exist.
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'
# 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 (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
- 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 }}
# 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'
# A bare prompt: (no @claude mention) runs claude-code-action in
# "agent mode", which decides which MCP servers to start by
# scanning --allowedTools inside claude_args -- it does NOT consult
# the invoked plugin's own allowed-tools frontmatter. Without this,
# mcp__github_inline_comment__create_inline_comment never starts
# (not "exists but blocked" -- genuinely absent), so the
# code-review plugin silently falls back to one consolidated PR
# comment instead of real per-line inline comments. No error, no
# warning -- every review just quietly uses the wrong output shape.
claude_args: '--allowedTools mcp__github_inline_comment__create_inline_comment'
# --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'