name: Pi 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: DEEPSEEK_API_KEY: required: false WINDMILL_EE_PRIVATE_ACCESS: required: false concurrency: group: pi-review-${{ inputs.pr_number || github.event.pull_request.number }} cancel-in-progress: true jobs: pi-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 Pi configuration id: pi_config env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} run: | if [ -n "$DEEPSEEK_API_KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" else echo "enabled=false" >> "$GITHUB_OUTPUT" echo "DEEPSEEK_API_KEY is not configured; skipping Pi review." fi - name: Resolve PR metadata if: steps.pi_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 /pi comment gated by # check-write-access), so allow forks only on that path. if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then echo "Skipping Pi 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.pi_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.pi_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.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' uses: actions/setup-node@v7 with: node-version: 22 - name: Install Pi CLI if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' # Pinned: this job holds DEEPSEEK_API_KEY and PR write access, and an # unpinned reviewer also makes verdicts non-reproducible across runs. run: npm install --global @earendil-works/pi-coding-agent@0.84.1 - name: Pre-fetch base and head refs for the PR if: steps.pi_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.pi_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 Pi review context if: steps.pi_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 "## Pi Review (DeepSeek V4)" 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 Pi review if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} PI_SKIP_VERSION_CHECK: '1' PR_IS_FORK: ${{ steps.pr.outputs.is_fork }} PR_BASE_REF: ${{ steps.pr.outputs.base_ref }} PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }} PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} run: | set -o pipefail PI_HARDEN_FLAGS=() # Keep generated files (final message, events, context) outside the # checkout: on the fork path a committed symlink at any of these paths # would redirect our write and could overwrite an action's code that # then runs with our credentials. RUNNER_TEMP is outside the checkout. OUT_DIR="$RUNNER_TEMP" CTX="$RUNNER_TEMP/pr-review-context.md" 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 drop the bash tool so the agent has no shell to exfiltrate with. git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/pi-prompt.md git show "origin/$PR_BASE_REF:.github/pi/pr-review.prompt.md" >> /tmp/pi-prompt.md PI_TOOLS=read,grep,find,ls # The agent has no shell, so pre-compute the diff (base...head SHAs are # trusted) into the context file it reads. It may still read fork files # by absolute path for extra context — reads are safe. { echo "" echo "## Pre-computed review diff (base...head)" echo "You have no shell. The full diff is below. The repository checkout" echo "is at $GITHUB_WORKSPACE — you may read files there by absolute path." echo '```diff' git -C "$GITHUB_WORKSPACE" diff --unified=0 "$PR_BASE_SHA...$PR_HEAD_SHA" echo '```' } >> "$CTX" # Pi resolves ALL project config from /.pi (settings/packages, # extensions, skills, themes, prompts, SYSTEM.md); inside the fork # checkout a fork could inject any to run code or rewrite our system # prompt. Discovery is cwd-based, so run from a fresh empty dir. PI_WORKDIR=$(mktemp -d) cd "$PI_WORKDIR" # Belt-and-suspenders on top of the isolated cwd: refuse discovery of # extensions/skills/templates/themes/context-files, and PI_OFFLINE=1 to # block any startup network op or package install. PI_OFFLINE gates only # startup network ops, not the provider inference call. PI_HARDEN_FLAGS=(--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files) export PI_OFFLINE=1 else cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md PI_TOOLS=read,grep,find,ls,bash fi # The context file lives in RUNNER_TEMP (outside the checkout); tell the # agent its absolute path. printf '\nReview context file (absolute path): %s\n' "$CTX" >> /tmp/pi-prompt.md # DeepSeek's reasoning_effort accepts low/high/max and silently maps both # medium and xhigh onto high. Set the level explicitly rather than letting # pi's default clamp onto it, so a change to either the default or the # clamping is a visible diff here instead of a silent shift in review depth. pi -p \ --provider deepseek \ --model deepseek-v4-pro \ --thinking high \ --tools "$PI_TOOLS" \ "${PI_HARDEN_FLAGS[@]}" \ --mode json \ < /tmp/pi-prompt.md \ | tee "$OUT_DIR/pi-events.jsonl" \ | jq -rc --unbuffered ' if .type == "agent_start" then "🤖 pi agent started" elif .type == "turn_start" then "── turn ──" elif .type == "message_end" then "[\(.message.role)] " + ( (.message.content // []) | map( if .type == "text" then "text(\(.text | length)c)" elif .type == "tool_use" then "🔧 \(.name) \(.input | @json | .[:160])" elif .type == "tool_result" then "✅ result" else .type end ) | join(" | ") ) elif .type == "turn_end" then "── turn done (\((.toolResults // []) | length) tool result(s)) ──" elif .type == "agent_end" then "🏁 pi agent done" else empty end ' jq -r ' select(.type == "agent_end") | .messages | map(select(.role == "assistant")) | last | (.content[]? | select(.type == "text") | .text) ' "$OUT_DIR/pi-events.jsonl" > "$OUT_DIR/pi-final-message.md" # The final message often opens with chatter ("Now I have all the context # I need..."), which would land above the verdict in the posted comment. # Keep the trim conditional: without the heading there is nothing to cut # and the range expression would empty the file. if grep -q '^## Pi Review' "$OUT_DIR/pi-final-message.md"; then sed -i -n '/^## Pi Review/,$p' "$OUT_DIR/pi-final-message.md" fi - name: Post Pi review comment if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' uses: actions/github-script@v7 env: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} GH_JOB_TOKEN: ${{ github.token }} with: github-token: ${{ github.token }} script: | const fs = require('fs'); const path = `${process.env.RUNNER_TEMP}/pi-final-message.md`; if (!fs.existsSync(path)) { core.info('Pi did not produce a final message; skipping PR comment.'); return; } let body = fs.readFileSync(path, 'utf8').trim(); if (!body) { core.info('Pi final message was empty; skipping PR comment.'); return; } // Defense-in-depth for fork reviews: the model call needs the provider // credential in the environment (readable via /proc/self/environ), and // the posted comment is an exfiltration channel that bypasses GitHub // Actions log masking. Strip the credential if it leaked into the text. for (const s of [process.env.DEEPSEEK_API_KEY, process.env.GH_JOB_TOKEN]) { if (typeof s === 'string' && s.length >= 8) { body = body.split(s).join('[REDACTED]'); } } body = body.trim(); if (!body) { core.info('Pi 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, });