Files
windmill/.github/workflows/pr-ready-review.yml
T
Ruben Fiszel 0fc74dec5f fix(ci): use random delimiters for untrusted multiline workflow values (#10706)
* fix(ci): use a random delimiter for the review prompt env var

* fix(ci): use a random delimiter for the review command extra_prompt output
2026-08-14 18:38:10 +02:00

209 lines
9.1 KiB
YAML

name: Claude Auto Review
on:
pull_request:
types: [ready_for_review, opened]
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:
CLAUDE_CODE_OAUTH_TOKEN:
required: true
WINDMILL_EE_PRIVATE_ACCESS:
required: false
concurrency:
group: claude-review-${{ inputs.pr_number || github.event.pull_request.number }}
cancel-in-progress: true
jobs:
auto-review:
runs-on: ubuntu-latest
# 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.ready_for_review == true) &&
github.event.pull_request.head.repo.fork == false
)
permissions:
contents: read
pull-requests: read
id-token: write
steps:
# 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 (workflow_call) and opened 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 codex/pi fork paths deliberately avoid.
- name: Check clean-round marker
id: marker
if: github.event_name == 'pull_request' && github.event.action == 'ready_for_review'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
# Fetch failures fail open (skip=false): an API hiccup must run the
# review, never skip it or fail the job.
COMMENTS=$(gh api "repos/$REPO/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"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout repository
if: steps.marker.outputs.skip != 'true'
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Check EE access
if: steps.marker.outputs.skip != '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: Resolve PR number
if: steps.marker.outputs.skip != 'true'
id: resolve
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
if [ -n "$INPUT_PR_NUMBER" ]; then
PR_NUMBER="$INPUT_PR_NUMBER"
PR_AUTHOR=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.user.login')
else
PR_NUMBER="$EVENT_PR_NUMBER"
PR_AUTHOR="$EVENT_PR_AUTHOR"
fi
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "pr_author=$PR_AUTHOR" >> "$GITHUB_OUTPUT"
- name: Fetch prior PR discussion
if: steps.marker.outputs.skip != 'true'
id: prior
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.resolve.outputs.pr_number }}
run: |
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:]' \
> prior-comments.json || echo "[]" > prior-comments.json
jq -r '
if length == 0 then ""
else
"## Prior PR discussion (most recent up to 20 comments)\n\nIf you have already reviewed this PR (look for your own earlier 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.\n\n" +
(map("### @\(.user) (\(.created_at))\n\n\(.body)") | join("\n\n---\n\n"))
end
' prior-comments.json > prior-comments.md
- name: Read review prompt
if: steps.marker.outputs.skip != 'true'
id: review-prompt
env:
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
run: |
# prior-comments.md is PR comment text verbatim, and commenting needs no write access.
# With a fixed delimiter, a comment containing a bare `EOF` line closes the block early:
# the step dies, and whatever follows in that comment is read as further environment
# assignments for the rest of this job, which holds the review tokens. Hence a random
# delimiter, per GitHub's guidance for untrusted multiline values.
delimiter="REVIEW_PROMPT_EOF_$(openssl rand -hex 16)"
{
echo "REVIEW_PROMPT<<$delimiter"
cat REVIEW.md
echo ''
cat .claude/review-prompt.md
if [ -n "$EXTRA_PROMPT" ]; then
echo ''
echo '## Additional reviewer instructions'
echo ''
printf '%s\n' "$EXTRA_PROMPT"
fi
if [ -s prior-comments.md ]; then
echo ''
cat prior-comments.md
fi
echo "$delimiter"
} >> "$GITHUB_ENV"
- name: Automatic PR Review
if: steps.marker.outputs.skip != 'true'
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
track_progress: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ steps.resolve.outputs.pr_number }}
PR AUTHOR: ${{ steps.resolve.outputs.pr_author }}
${{ env.REVIEW_PROMPT }}
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
--model claude-opus-5