Skip to content
Open
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
205 changes: 205 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
name: Claude Code Review (shared)

# Single source of truth for the Claude PR review job across Postgres-Extensions.
# Consumers hold a thin caller file; see ai/CLAUDE.md for the template.
#
# SECURITY: callers invoke this from `pull_request_target`, which runs in the
# BASE repo with org secrets and a write-capable token. Three things keep that
# safe, none of which a PR can subvert:
# 1. GitHub always reads the CALLER file from the PR's base branch, and
# `uses:` cannot contain contexts or expressions -- so a PR can neither
# edit the caller that runs on it nor redirect which ref of THIS file runs.
# 2. `trusted_authors` below is a required input with no default: a caller
# that omits it fails workflow-graph validation rather than silently
# reviewing arbitrary fork PRs with org secrets in scope.
# 3. Nothing here ever checks out the PR head. claude-code-action fetches it
# itself via the base repo's refs/pull/<N>/head (setupBranch() in
# src/github/operations/branch.ts) -- which is why the checkout step below
# must NOT set repository:/ref:. Pointing `origin` at the fork breaks that
# fetch with "couldn't find remote ref pull/<N>/head".
#
# Input names use underscores, not hyphens: `inputs.some-name` is ambiguous
# with subtraction in the expression parser.

on:
workflow_call:
inputs:
trusted_authors:
description: >-
Comma-separated GitHub logins (no spaces) whose PRs may run this job.
SECURITY-CRITICAL -- see note above. Required on purpose; there is no
safe default.
required: true
type: string
debug_label:
description: >-
PR label that skips the cost gate and turns on show_full_output.
Must match the label name hardcoded in the caller's `concurrency:`
group (the caller cannot read `inputs` there). Set to '' to disable.
required: false
type: string
default: claude-debug

jobs:
review:
# Skip drafts (don't spend on unfinished work); trusted authors only; and
# for a `labeled` event, proceed only when the label IS the debug label --
# otherwise every unrelated label would trigger another paid review.
#
# Checks the PR AUTHOR (user.login), not head.repo.owner.login: the latter
# is this org for any PR whose head branch lives in the base repo (gh
# stack, or `gh pr create` with no fork), so an owner-based check silently
# skipped review on every such PR regardless of who opened it.
if: >-
github.event.pull_request.draft == false &&
contains(format(',{0},', inputs.trusted_authors), format(',{0},', github.event.pull_request.user.login)) &&
(github.event.action != 'labeled' || github.event.label.name == inputs.debug_label)
runs-on: ubuntu-latest
timeout-minutes: 60
# No `permissions:` block on purpose. A called workflow can only narrow
# what the caller granted, never widen it, so the ceiling lives in each
# caller; declaring a block here would break any caller granting less.
steps:
# DEBUG MODE: add the debug label to a PR to (a) skip the cost gate --
# a debug iteration shouldn't wait 5-20 min on sibling CI -- and (b) get
# show_full_output on the review step (see that input's WARNING below).
# Queried live rather than from github.event.pull_request.labels:
# "Re-run jobs" replays the ORIGINAL stored payload, so a payload-based
# check would never see a label added after the run started.
- name: Check for the debug label
id: debug
if: inputs.debug_label != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
LABEL: ${{ inputs.debug_label }}
run: |
enabled=$(gh pr view "$PR" --repo "$REPO" --json labels \
--jq 'any(.labels[]; .name == env.LABEL)' 2>/dev/null) || enabled=false
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
echo "debug label '$LABEL' present: $enabled"

# COST GATE: the paid review runs last. Wait for the PR head's OTHER
# check-runs and proceed only if they are clean -- no point paying to
# review a PR already known to be broken. Sibling checks are discovered
# dynamically, so this needs no per-repo workflow names.
# decision=run : every sibling completed with a good conclusion, or no
# siblings exist after a ~3 min grace window.
# decision=skip : a sibling failed/cancelled, or we timed out waiting.
#
# Self-exclusion matches each check-run's own details_url against this
# run's id (github.run_id) -- every check-run already carries a
# details_url of the form ".../actions/runs/<run_id>/job/<job_id>", so
# this needs no extra API call and no permission beyond the checks:read
# the caller already grants for the sibling lookup below.
#
# Do NOT resolve the run's own check-SUITE id via a separate `gh api`
# call instead: that call has no retry/fallback like the paginated
# lookup below, so under `set -e` a transient GitHub error there aborts
# the whole job red instead of just skipping the review -- worse than
# having no gate at all.
#
# Do NOT switch this to matching on check-run name either: as a
# reusable workflow this job's check-run is named "<caller job> /
# review", so a name filter would fail to exclude it, the gate would
# wait on itself to the timeout, and the review would be skipped on
# every single PR.
- 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 }}
SHA: ${{ github.event.pull_request.head.sha }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail

decision=skip
for i in $(seq 1 72); do # ~24 min max
# --slurp (not --jq) because --paginate --jq emits one array PER
# PAGE; `jq length` over that yields one number per page and the
# arithmetic below then fails. --slurp is mutually exclusive with
# --jq, hence the pipe to real jq.
pages=$(gh api "repos/$REPO/commits/$SHA/check-runs?per_page=100" \
--paginate --slurp 2>/dev/null) || pages=''
[ -z "$pages" ] && { sleep 20; continue; }
siblings=$(jq --arg rid "$RUN_ID" \
'[.[].check_runs[] | select(.details_url // "" | contains("/actions/runs/" + $rid + "/") | not)]' <<<"$pages")
total=$(jq 'length' <<<"$siblings")
if [ "$total" -eq 0 ]; then
[ "$i" -ge 9 ] && { decision=run; break; } # ~3 min grace
sleep 20; continue
fi
pending=$(jq '[.[]|select(.status!="completed")]|length' <<<"$siblings")
if [ "$pending" -eq 0 ]; then
bad=$(jq '[.[]|select((.conclusion//"")|test("^(failure|cancelled|timed_out|action_required|stale)$"))]|length' <<<"$siblings")
[ "$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.debug.outputs.enabled == 'true' || steps.gate.outputs.decision == 'run'
# Tracks the major-version tag, not a pinned SHA, so upstream fixes are
# picked up automatically. No repository:/ref: -- see SECURITY (3) above.
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false

- name: Run Claude Code Review
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 }}
# WARNING (from this input's own description): outputs ALL Claude
# messages including tool execution results, which may contain
# secrets, into publicly visible Actions logs. Debug label only.
show_full_output: ${{ steps.debug.outputs.enabled == 'true' }}
# Supplying github_token makes the action use it directly 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.
github_token: ${{ secrets.GITHUB_TOKEN }}
# A `prompt:` input puts the action in automation mode, which posts
# nothing until the whole run finishes. track_progress forces a
# tracking comment with a live checklist, so a slow run is visible.
track_progress: true
# NOTE: plugin_marketplaces can't be pinned -- it tracks the
# marketplace repo's default branch (anthropics/claude-code).
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
# --comment is required: without it the plugin prints findings to the
# job log only and posts nothing to the PR.
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. There, claude-code-action installs the github_inline_comment
# MCP server only if it sees mcp__github_inline_comment__create_inline_comment
# in --allowedTools (src/modes/agent/parse-tools.ts) -- it does NOT
# consult the plugin's own allowed-tools frontmatter. Without it the
# server never starts and the plugin silently degrades to one
# consolidated comment instead of inline line comments.
#
# --allowedTools is an allowlist in agent mode, so anything not named
# here is silently DENIED. The Bash(gh ...) entries mirror the
# plugin's own allowed-tools frontmatter (anthropics/claude-code
# plugins/code-review/commands/code-review.md) verbatim. `Task` isn't
# in that frontmatter (core tools need no declaration in a normal
# session) but the command's steps 1-5 launch subagents to do the
# actual review -- without it there is no reviewer left to run.
# `TodoWrite` likewise isn't in the frontmatter or the action's
# baseline set, and the plugin's Notes unconditionally require a todo
# list before starting.
#
# The whole value MUST stay one shell-quote token. YAML's outer single
# quotes are consumed by the YAML parser and never reach the action;
# what it receives is re-tokenized by the `shell-quote` npm package,
# splitting on whitespace. The inner double quotes are what keep the
# spaces inside each Bash(gh ...) entry from splitting the value into
# garbage tokens.
claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment,Task,TodoWrite,Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
63 changes: 63 additions & 0 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Claude Code (shared)

# Single source of truth for the @claude interactive job across
# Postgres-Extensions. Consumers hold a thin caller file; see
# ai/CI-WORKFLOWS.md for the template.
#
# Unlike claude-code-review.yml, none of this workflow's triggers
# (issue_comment, pull_request_review_comment, issues, pull_request_review)
# ever read anything from a PR head branch -- every one of them reads the
# workflow file from the current default branch, so there is no
# pull_request_target trust subtlety here to guard against.
#
# Input names use underscores, not hyphens: `inputs.some-name` is ambiguous
# with subtraction in the expression parser.

on:
workflow_call:
inputs:
trusted_actors:
description: >-
Comma-separated GitHub logins (no spaces) allowed to trigger this
job via @claude mentions. Required on purpose; there is no safe
default.
required: true
type: string

# No concurrency limit: @claude mentions are independent, read-only
# requests; serializing would only delay responses and cancelling would
# drop them.
jobs:
claude:
# SECURITY: restricts @claude to trusted_actors, not just matching
# comment text -- otherwise anyone could trigger this job.
if: |
contains(format(',{0},', inputs.trusted_actors), format(',{0},', github.actor)) &&
(
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
timeout-minutes: 30
# No `permissions:` block on purpose. A called workflow can only narrow
# what the caller granted, never widen it, so the ceiling lives in each
# caller; declaring a block here would break any caller granting less.
steps:
- name: Checkout repository
# Tracks the major-version tag, not a pinned SHA, so upstream fixes
# are picked up automatically.
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false

- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Allows Claude to read CI results on PRs
additional_permissions: |
actions: read
Loading