name: Codex Auto Review on: pull_request: types: [ready_for_review, opened, synchronize] workflow_call: inputs: pr_number: description: 'PR number to review' required: true type: number extra_prompt: description: 'Additional reviewer instructions appended to the standard review prompt' required: false type: string default: '' triggered_by: description: 'GitHub username that triggered this review (for audit only)' required: false type: string default: '' secrets: OPENAI_API_KEY: required: false CODEX_AUTH_JSON: required: false WINDMILL_EE_PRIVATE_ACCESS: required: false concurrency: group: codex-review-${{ inputs.pr_number || github.event.pull_request.number }} cancel-in-progress: true jobs: codex-review: runs-on: ubicloud-standard-2 timeout-minutes: 30 # A non-fork PR (head.repo.fork == false) can only be opened by someone with push # access to this repo, so fork==false already enforces write access. Do NOT re-add # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false ) permissions: contents: read issues: write pull-requests: write steps: - name: Check Codex configuration id: codex_config env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: | if [ -n "$OPENAI_API_KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" echo "auth_mode=api_key" >> "$GITHUB_OUTPUT" elif [ -n "$CODEX_AUTH_JSON" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" echo "auth_mode=oauth_json" >> "$GITHUB_OUTPUT" else echo "enabled=false" >> "$GITHUB_OUTPUT" echo "Codex auth is not configured; set OPENAI_API_KEY or CODEX_AUTH_JSON to enable Codex review." fi - name: Resolve PR metadata if: steps.codex_config.outputs.enabled == 'true' id: pr env: GH_TOKEN: ${{ github.token }} INPUT_PR_NUMBER: ${{ inputs.pr_number }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} EVENT_TITLE: ${{ github.event.pull_request.title }} EVENT_BODY: ${{ github.event.pull_request.body }} EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }} EVENT_AUTHOR: ${{ github.event.pull_request.user.login }} EVENT_ACTION: ${{ github.event.action }} run: | if [ -n "$INPUT_PR_NUMBER" ]; then PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \ --json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository,author) PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number') BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName') BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid') HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid') PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""') IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository') PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login // ""') else PR_NUMBER="$EVENT_PR_NUMBER" BASE_REF="$EVENT_BASE_REF" BASE_SHA="$EVENT_BASE_SHA" HEAD_SHA="$EVENT_HEAD_SHA" PR_TITLE="$EVENT_TITLE" PR_BODY="$EVENT_BODY" IS_FORK="$EVENT_FORK" PR_AUTHOR="$EVENT_AUTHOR" fi # Fork PRs run untrusted code with secrets present, so the automatic # pull_request trigger never reviews them. A non-empty INPUT_PR_NUMBER # means we arrived via workflow_call (a maintainer /codex comment gated # by check-write-access), so allow forks only on that path. if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then echo "Skipping Codex review for fork PR (automatic trigger)." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi # An agent-driven PR flips to ready only after a clean /review round on # a draft, marked by an author comment naming the head SHA (pr skill, # "Review rounds"). Re-reviewing that same head on ready_for_review is # redundant. The marker alone is author attestation, so also require # reviewer evidence: a Codex review (posted by github-actions[bot], not # forgeable by the author) that predates the marker and carries a # non-blocking verdict. Comment-triggered and synchronize runs never # skip. Keep the three copies of this check in sync (pr-ready-review / # codex-pr-review / pi-pr-review); a shared local action would need the # repo checked out before the check, which the fork paths here # deliberately avoid. if [ "$EVENT_ACTION" = "ready_for_review" ] && [ -z "$INPUT_PR_NUMBER" ]; then # Fetch failures fail open (no skip): an API hiccup must run the # review, never skip it or fail the job. COMMENTS=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments?per_page=100" --paginate | jq -s '[.[][]]') || COMMENTS='[]' MARKER_TIME=$(jq -r --arg author "$PR_AUTHOR" --arg marker "✅ Review round clean @ $HEAD_SHA" \ '[.[] | select(.user.login == $author) | select(.body | contains($marker)) | .created_at] | min // empty' <<<"$COMMENTS") CODEX_VERDICT='' if [ -n "$MARKER_TIME" ]; then # Only Codex evidence that predates the marker counts: the ready- # triggered Codex run itself posts after the flip and must not # vouch for a sibling reviewer's skip. CODEX_VERDICT=$(jq -r --arg mt "$MARKER_TIME" \ '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("## Codex Review")) | select(.created_at < $mt)] | last | .body // ""' <<<"$COMMENTS" \ | grep -m1 -oE '(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)' || true) fi if [ -n "$MARKER_TIME" ] && [ -n "$CODEX_VERDICT" ] && [ "$CODEX_VERDICT" != "Should address issues before merging" ]; then echo "Clean review round marker found for $HEAD_SHA with pre-marker non-blocking Codex verdict; skipping redundant review." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi fi # PR title/body are attacker-controlled free text. Use an unguessable # per-run delimiter so a fork can't embed a fixed heredoc terminator to # inject extra outputs — e.g. is_fork=false (last-write-wins), which # would re-enable the EE checkout and trusted-path settings for forks. RAND=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n') TITLE_EOF="TITLE_EOF_${RAND}" BODY_EOF="BODY_EOF_${RAND}" { echo "skip=false" echo "is_fork=$IS_FORK" echo "pr_number=$PR_NUMBER" echo "base_ref=$BASE_REF" echo "base_sha=$BASE_SHA" echo "head_sha=$HEAD_SHA" echo "pr_author=$PR_AUTHOR" echo "title<<$TITLE_EOF" printf '%s\n' "$PR_TITLE" echo "$TITLE_EOF" echo "body<<$BODY_EOF" printf '%s\n' "$PR_BODY" echo "$BODY_EOF" } >> "$GITHUB_OUTPUT" - name: Checkout repository if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' uses: actions/checkout@v5 with: ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge fetch-depth: 1 # Don't persist github.token in .git/config: the review agent can read # the checkout, and on the fork path that token (issue/PR write) would # otherwise be exfiltratable. All later git ops target the public origin # and need no auth; EE checkout and gh use their own explicit tokens. persist-credentials: false # Never expose the EE private-repo token to untrusted fork code. Skipping # this step leaves steps.ee.outputs.available empty, so the EE checkout and # substitution steps below are skipped too. - name: Check EE access if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' && steps.pr.outputs.is_fork != 'true' id: ee env: EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} run: | if [ -n "$EE_TOKEN" ]; then echo "available=true" >> "$GITHUB_OUTPUT" echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT" else echo "available=false" >> "$GITHUB_OUTPUT" fi - name: Checkout EE repository if: steps.ee.outputs.available == 'true' uses: actions/checkout@v5 with: repository: windmill-labs/windmill-ee-private path: ./windmill-ee-private ref: ${{ steps.ee.outputs.ee_repo_ref }} token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} fetch-depth: 1 - name: Substitute EE code if: steps.ee.outputs.available == 'true' run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private - name: Set up Node.js if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' uses: actions/setup-node@v4 with: node-version: 22 - name: Install Codex CLI if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' run: npm install --global @openai/codex@0.144.1 - name: Configure Codex auth if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: | CODEX_HOME="$HOME/.codex" echo "CODEX_HOME=$CODEX_HOME" >> "$GITHUB_ENV" mkdir -p "$CODEX_HOME" chmod 700 "$CODEX_HOME" cat > "$CODEX_HOME/config.toml" <<'EOF' cli_auth_credentials_store = "file" EOF if [ -n "$OPENAI_API_KEY" ]; then printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key else printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json" chmod 600 "$CODEX_HOME/auth.json" node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json" fi - name: Pre-fetch base and head refs for the PR if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: PR_BASE_REF: ${{ steps.pr.outputs.base_ref }} PR_NUMBER: ${{ steps.pr.outputs.pr_number }} run: | git fetch --no-tags origin \ "$PR_BASE_REF" \ "+refs/pull/$PR_NUMBER/head" - name: Fetch prior PR discussion if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ steps.pr.outputs.pr_number }} run: | # Write outside the checkout: on the fork path the merge tree is # attacker-controlled, and a committed symlink at this path would # redirect the write. gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \ --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \ > "$RUNNER_TEMP/prior-comments.json" || echo "[]" > "$RUNNER_TEMP/prior-comments.json" - name: Write Codex review context if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: PR_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ steps.pr.outputs.pr_number }} PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }} PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} PR_TITLE: ${{ steps.pr.outputs.title }} PR_BODY: ${{ steps.pr.outputs.body }} PR_AUTHOR: ${{ steps.pr.outputs.pr_author }} EXTRA_PROMPT: ${{ inputs.extra_prompt }} run: | node <<'NODE' const fs = require('fs'); const tmp = process.env.RUNNER_TEMP; const lines = [ `Repository: ${process.env.PR_REPOSITORY}`, `PR number: ${process.env.PR_NUMBER}`, ]; if (process.env.PR_AUTHOR) { lines.push(`PR AUTHOR: ${process.env.PR_AUTHOR}`); } lines.push( `Base SHA: ${process.env.PR_BASE_SHA}`, `Head SHA: ${process.env.PR_HEAD_SHA}`, '', 'PR title:', process.env.PR_TITLE || '(empty)', '', 'PR body:', process.env.PR_BODY || '(empty)', '', 'Changed commits command:', `git log --oneline ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`, '', 'Changed files command:', `git diff --stat ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`, '', 'Full review diff command:', `git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}` ); if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) { lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim()); } if (fs.existsSync(`${tmp}/prior-comments.json`)) { try { const comments = JSON.parse(fs.readFileSync(`${tmp}/prior-comments.json`, 'utf8')); if (Array.isArray(comments) && comments.length > 0) { lines.push( '', 'Prior PR discussion (most recent up to 20 comments):', '', 'If you have already reviewed this PR (look for your own earlier "## Codex Review" comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.', '' ); for (const c of comments) { lines.push(`### @${c.user} (${c.created_at})`, '', c.body, '', '---', ''); } } } catch (_) {} } fs.writeFileSync(`${tmp}/pr-review-context.md`, `${lines.join('\n')}\n`); NODE - name: Run Codex review if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: PR_IS_FORK: ${{ steps.pr.outputs.is_fork }} PR_BASE_REF: ${{ steps.pr.outputs.base_ref }} run: | if [ "$PR_IS_FORK" = "true" ]; then # Fork code is untrusted. Read the review policy/prompt from the base # ref (git show) rather than the attacker-controlled merge checkout, # so a malicious fork can't rewrite the reviewer's own instructions, # and run in a network-disabled sandbox to block secret exfiltration. git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/codex-prompt.md git show "origin/$PR_BASE_REF:.github/codex/pr-review.prompt.md" >> /tmp/codex-prompt.md SANDBOX_MODE=workspace-write else cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md SANDBOX_MODE=danger-full-access fi # The context file lives in RUNNER_TEMP (outside the attacker-controlled # checkout); tell the agent its absolute path. printf '\nReview context file (absolute path): %s\n' "$RUNNER_TEMP/pr-review-context.md" >> /tmp/codex-prompt.md # Write the final message outside the checkout too: a fork could commit # codex-final-message.md as a symlink and redirect this write to overwrite # e.g. a GitHub Action's index.js, which then runs with our credentials. codex exec \ -C "$GITHUB_WORKSPACE" \ -m gpt-5.6-sol \ -c 'model_reasoning_effort="xhigh"' \ -s "$SANDBOX_MODE" \ -o "$RUNNER_TEMP/codex-final-message.md" \ - < /tmp/codex-prompt.md - name: Post Codex review comment if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' uses: actions/github-script@v7 env: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} GH_JOB_TOKEN: ${{ github.token }} with: github-token: ${{ github.token }} script: | const fs = require('fs'); const path = `${process.env.RUNNER_TEMP}/codex-final-message.md`; if (!fs.existsSync(path)) { core.info('Codex did not produce a final message; skipping PR comment.'); return; } let body = fs.readFileSync(path, 'utf8').trim(); if (!body) { core.info('Codex final message was empty; skipping PR comment.'); return; } // Defense-in-depth for fork reviews: the model call needs the provider // credential in the env, and the posted comment bypasses Actions log // masking. Strip any credential (API key, raw auth JSON, nested // tokens) that leaked into the review text before posting. const secrets = []; const addSecret = (v, min) => { if (typeof v === 'string' && v.length >= min) secrets.push(v); }; addSecret(process.env.OPENAI_API_KEY, 8); addSecret(process.env.CODEX_AUTH_JSON, 8); addSecret(process.env.GH_JOB_TOKEN, 8); if (process.env.CODEX_AUTH_JSON) { try { const collect = (o) => { if (typeof o === 'string') addSecret(o, 20); else if (Array.isArray(o)) o.forEach(collect); else if (o && typeof o === 'object') Object.values(o).forEach(collect); }; collect(JSON.parse(process.env.CODEX_AUTH_JSON)); } catch (_) {} } for (const s of [...new Set(secrets)].sort((a, b) => b.length - a.length)) { body = body.split(s).join('[REDACTED]'); } body = body.trim(); if (!body) { core.info('Codex final message was empty after redaction; skipping PR comment.'); return; } await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: Number(process.env.PR_NUMBER), body, });