A GitHub Actions pipeline (.github/workflows/ai-review.yml) that gives every
PR one exhaustive, structured AI review instead of the churn of the Codex
GitHub App's automatic per-push reviews (which re-reviewed a PR 30-40 times
as commits landed). This pipeline runs exactly once per PR: no new
commit ever re-triggers it.
The Codex app's automatic review re-runs on every push, producing dozens of short, repetitive review rounds per PR and burning reviewer attention on churn instead of substance. This pipeline instead:
- Lets Claude and Codex each do their own unhurried, exhaustive pass over the diff, in parallel.
- Then a separate adjudicator (Codex) reconciles the two, verifying every finding by reading the real code (confirmed / refuted / uncertain) instead of taking either review at face value.
- Posts ONE consolidated, deterministic review — no model call decides what gets posted or how; a plain TypeScript script does.
┌─ claude-review ─┐
resolve ──────>┤ ├──> adjudicate ──> post-review
(decide) └─ codex-review ─┘ (Codex reconciles (post ONE
(two independent reviews + verifies by GitHub review)
in parallel → JSON) reading the code)
resolve(.github/scripts/ai-review/resolve.ts) decides whether this run should happen at all. It applies the once-per-PR dedup guard, the automatic trigger's draft/bot/fork skips and author write-access gate, and authorization for manual/ai-reviewrequests. There is no size cap: the models review agentically — reading the diff and the changed files via their own tools over many turns, like the local CLI — so PRs of any size are reviewed (very large diffs best-effort, within the model's context/turn budget). Each model job fetches the PR head and exact base branch into its full-history trusted checkout, then generates the diff locally with triple-dot merge-base semantics. This avoids GitHub's API diff-size limits.claude-reviewandcodex-reviewrun in parallel — each gives its model an independent, exhaustive pass and produces structured JSON findings validated againstfindings.schema.json. Claude reads the PR's checked-out head commit; Codex reviews the diff.adjudicatechecks out the PR head read-only, then runs Codex to reconcile the two finding sets — verifying each finding by reading the real code, merging duplicates (taggingsources: claude | codex | both), and preserving refuted findings with their reasons — into one result validated againstmerged-review.schema.json. Splitting this from the independent reviews lets those run concurrently and gives each job its own timeout.post-review(.github/scripts/ai-review/post-review.ts) is the only job with write access. It posts oneCOMMENT-event GitHub review (inline comments where the diff can anchor them, a summary body for everything else), then best-effort supersedes any prior AI review on the PR.
New commits never re-trigger a review — resolve.ts's dedup guard skips a
PR that already carries a review/comment with the <!-- supabase-ai-review --> marker posted by this workflow's own bot account; the marker alone,
if pasted by someone else, does not suppress a review. To get another review
on the same PR:
- a maintainer with repository write access (or the repository owner) posts a
comment whose first line is exactly
/ai-review, or - run the workflow manually via
workflow_dispatchwith the PR number.
Both bypass the dedup guard and the draft/fork/bot skips (a human explicitly asked).
The pull_request trigger (opened / ready_for_review) is live. The
automatic path is internal PRs only: resolve.ts skips drafts, bots, and
fork PRs, and requires the PR author to hold effective repository write
access (admin/write, the same WRITE_PERMISSIONS gate as the manual
/ai-review path). The permission lookup is the authoritative author check:
a same-repo head branch only proves the branch exists in this repo, not that
the PR author pushed it, so the author's own permission is always resolved.
External contributors' PRs are never reviewed automatically; a maintainer
comments /ai-review to request one.
Prompt/script tweaks take effect only once they land on develop: the
prompts, schemas, and validation script are read from a trusted checkout of
the default branch (not the PR under review), and post-review checks out
develop explicitly. Use workflow_dispatch against real merged/in-flight
PRs post-merge to iterate.
The Codex GitHub App's automatic reviews must stay disabled at https://chatgpt.com/codex/settings/code-review so PRs aren't double-reviewed.
merged-review.schema.json uses pattern (on category) and minItems (on
sources); some OpenAI structured-output strict-mode implementations have
historically rejected those keywords. Both are redundant with the runtime
assertMergedReview validator in post-review.ts. If the first live Codex
run 400s on the output schema because of this, drop pattern/minItems from
merged-review.schema.json and rely on the validator alone.
ANTHROPIC_API_KEY— recommend a dedicated, spend-capped, rotatable key for this workflow rather than sharing the release-notes pipeline's key: this workflow runs against every PR (including, eventually, external ones via/ai-review) and posts model text into a public review, so its blast radius and cost profile differ from the release-notes use case. Model output is also secret-scrubbed before it's posted or uploaded (see below) as defense-in-depth, but the dedicated key is the real containment.OPENAI_API_KEY— must be added beforecodex-reviewcan run.
- Least privilege per job. The top-level workflow grants no permissions
(
permissions: {}); each job requests only what it needs.resolvehaspull-requests: write(see below) pluscontents: read;claude-review/codex-reviewhave read-onlycontents+pull-requests; onlypost-reviewhaspull-requests: write. resolveruns only trusted, default-branch code. Its checkout is pinned to${{ github.event.repository.default_branch }}, never a PR's code, which is what makes it safe to also grant itpull-requests: write— used only for a best-effort 👀 reaction on the triggering comment (a reaction failure is logged and never fails the run).- Model jobs execute nothing from the PR head.
claude-reviewchecks out the PR's own head commit into a separatepath: pr— read-only review subject matter for Claude'sRead/Grep/Globtools — but every file it executes (the prompt,findings.schema.json, the validation script, even thebun-version-fileused to install the toolchain) comes from a second, separate checkout of the trusted default branch. Claude runs with--bareso it never auto-loads the PR head's ownCLAUDE.md/AGENTS.mdas instructions. The npm install of the Claude CLI runs with an isolated, pinned-registry npm config (--userconfig /dev/null --globalconfig /dev/null --registry=...) so a PR-supplied.npmrccannot redirect it.codex-reviewgoes further and checks out no PR worktree at all — it works purely frompr.diffunder/tmp, generated by trusted code from fetched Git objects. Neither job can push, comment, or otherwise mutate anything. bunnever runs with a cwd inside the untrustedprcheckout.bunauto-loadsbunfig.toml(whosepreloadruns arbitrary code) and.envfrom its cwd, so apr-cwdbuninvocation would let a PR-authoredpr/bunfig.tomlexecute attacker code in a step holdingANTHROPIC_API_KEY.claude-review's "Run Claude review" step keepsworking-directory: trustedfor the whole step and wraps only theclaudeinvocation in a( cd .../pr && claude ... )subshell —claudeis a standalone binary, not run viabun, sobunfig.tomlnever applies to it. Everybunprocess in the pipeline (validate-findings,redact,validate-merged,post) runs from a trusted checkout.- Codex's sandbox.
codex-reviewsetssafety-strategy: drop-sudo(removes sudo from the process running Codex — the action's own docs call out that a sudo-capable process can read secrets likeOPENAI_API_KEYout of memory even under a read-only filesystem sandbox) together withsandbox: read-only(no filesystem writes, no network for Codex's own command execution). See the YAML comment on that step for the exact reasoning, verified against the pinned action's source. - Authorization for
/ai-reviewrequires repository write, not org membership.resolve.tsalways resolves the commenter's effective repository permission and requiresadmin/write— only the repositoryOWNERmay skip that check. A read-only collaborator or an org member without push access cannot trigger a run. The command itself must match exactly: the comment's first line, trimmed, must be/ai-review(/ai-reviewers,/ai-review-please, etc. don't fire). The workflow's jobif:also pre-filters cheaply onauthor_associationas defense-in-depth, butresolve.ts's checks are the actual gate. - The automatic trigger requires the PR author to hold write access.
resolve.tsresolves the PR author's effective repository permission and requiresadmin/writebefore an automatic review runs, on top of the fork/draft/bot skips — so an external contributor's PR can never spend review budget or feed the models without a maintainer explicitly asking via/ai-review. - The only write-capable job runs exclusively trusted code.
post-reviewchecks out the base branch (develop) explicitly and never the PR head, so a PR cannot smuggle a script change into the one job that can write back to it. The checkout pin alone is not the whole boundary forpull_requestruns, though: GitHub executes the workflow FILE from the PR's own ref for those events. That is safe here because the automatic path only admits same-repo PRs, whose authors hold write access anyway (a workflow edit gains them nothing they don't already have), while fork PRs run with a read-only token and no secrets.issue_commentandworkflow_dispatchruns always use the default branch's workflow file. - Model text is sanitized before it's rendered.
sanitizeModelText()redacts secret-shaped substrings (redactSecrets(); see below), breaks every HTML comment opener (so injected diff content can't forge the hidden dedup/supersede markers), and neutralizes@mentions/#issue-refsin every model-provided string (summary,claim,evidence,suggested_fix,adjudication.reason) before it's posted.fileis separately validated at parse time (assertFindings/assertMergedReviewreject a backtick, newline, control character,<, or a reserved marker string in it) and re-sanitized at every render site, since it's rendered inside`code`spans a plain string field otherwise couldn't safely occupy. - Model output is secret-scrubbed before it's posted or uploaded.
redactSecrets()replaces common credential shapes (Anthropic/OpenAI API keys, GitHub personal-access/app/OAuth/Actions tokens) with«redacted»; it's composed intosanitizeModelText()for the posted review, and theredact <path>subcommand applies it toclaude-findings.json/claude-raw.json/merged-review.jsonin place before each is uploaded as an artifact. This is defense-in-depth against a prompt-injected modelRead-ing a secret-bearing path (e.g./proc/self/environ) and echoing a key back in a finding — the dedicatedANTHROPIC_API_KEYabove is the real containment. - Prompt-injection guards. Both prompts explicitly instruct the model to treat the PR title, body, diff, code, and code comments as review subject matter, not instructions, and to ignore anything embedded in them that tries to alter findings, verdicts, or output format.
- Advisory only. The posted review always uses the
COMMENTevent — neverREQUEST_CHANGESorAPPROVE— so it can never itself block or fast-track a merge. - Not a required check, and never runs in
merge_group. This pipeline has nopull_request/merge_grouptrigger wired into branch protection; it is purely advisory input for reviewers. - Artifacts are short-retention and should be treated as published. The
claude-findingsandmerged-reviewartifacts (3-day retention) contain model output about a PR's code; treat them as visible to anyone with read access to the repository's Actions runs, same as the posted review itself.
