Skip to content
Merged
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
72 changes: 72 additions & 0 deletions .github/workflows/code-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: code-review

on:
pull_request:
# Review a pull request when it opens or becomes ready, and again
# after each push to it.
types: [opened, reopened, ready_for_review, synchronize]

permissions:
contents: read
pull-requests: write

concurrency:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concurrency group is evaluated before the job's if:. A second event for the same PR (reopened, ready_for_review) can cancel a review in progress, and if that second run's job is then skipped, nothing replaces the cancelled review.

group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
review:
# GitHub withholds repository secrets from pull requests opened
# from a fork, so the review runs only on branches pushed to
# this repository.
if: >-
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same-repo check does not cover PRs opened by Dependabot. For those, pull_request runs get no repository secrets, so secrets.ANTHROPIC_API_KEY is empty and the job fails. Add github.actor != 'dependabot[bot]' to the condition, or skip the job when the secret is empty.

runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# Full history, so the review can walk each commit in the PR.
- uses: actions/checkout@v4
with:
fetch-depth: 0

# The action does not run a slash command given as its prompt
# (anthropics/claude-code-action#1003), so the prompt asks Claude
# to invoke the skill through the Skill tool instead.
- uses: anthropics/claude-code-action@d535f5172dec02b204866b393d6b20b9b2aaf98d # v1.0.266
id: review
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if: only excludes fork PRs. For Dependabot PRs on branches in this repo, GitHub withholds Actions secrets, so secrets.ANTHROPIC_API_KEY is empty and the action errors, leaving a red check on every dependency bump. Consider also skipping github.actor == 'dependabot[bot]', or checking that the secret is non-empty.

github_token: ${{ secrets.GITHUB_TOKEN }}
prompt: >-
Use the Skill tool to run the code-review skill with the
arguments "high --comment ${{ github.event.pull_request.number }}".

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflow works around claude-code-action#1003 by asking the model to call the Skill tool. If the model skips the call or mangles the arguments, the run still exits successfully, so the review can silently post nothing. No step checks that a comment was actually created.

claude_args: >-
--model claude-sonnet-5-5
--allowedTools
"Skill,Read,Grep,Glob,Agent,Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*),Bash(gh api:*),mcp__github_inline_comment__create_inline_comment"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bash(gh api:*) lets the model make any GitHub REST call, not just post review comments. The job's GITHUB_TOKEN has pull-requests: write, and the model reads untrusted PR content (diff, commit messages, PR text). A prompt injection there could run gh api -X PATCH/DELETE against the PR or other people's comments, or approve the PR through the reviews endpoint. Consider a narrower allowlist, such as gh api repos/*/pulls/*/comments plus the inline-comment MCP tool.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bash(gh api:*) is an unrestricted gh api, so the model can make any REST call, including writes and --method overrides, with the job's pull-requests: write token. That token can approve, dismiss, or close PRs. The PR title, body, and diff are untrusted input to the model, so a prompt injection can use this. Nothing here needs more than gh pr view, gh pr diff, and the inline-comment tool. Drop gh api:*, or narrow it to the specific read-only endpoints the skill uses.


# A run in which Claude never invokes the skill still exits
# successfully, with no review posted. Fail the job when the
# execution log holds no Skill tool call.
- name: Check that the review ran
env:
EXECUTION_FILE: ${{ steps.review.outputs.execution_file }}
run: |
if ! jq -e '[.[] | select(.type == "assistant")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check only confirms that a Skill tool_use block exists. It does not check that the call succeeded, that it named code-review, or that any comment was posted. If the skill is missing or the call returns an error, the job still goes green and no review appears, which is the failure this step is meant to catch. Also require a matching tool_result without is_error, and .input.skill == "code-review".

| .message.content[]?
| select(.type == "tool_use" and .name == "Skill")]
| length > 0' "$EXECUTION_FILE" > /dev/null; then
echo "::error::Claude did not invoke the code-review skill"
exit 1
fi

# The action hides Claude's output, so a failed run otherwise
# leaves no error text. Print only the final result message.
- name: Show why the review failed
if: failure() && steps.review.outputs.execution_file != ''
env:
EXECUTION_FILE: ${{ steps.review.outputs.execution_file }}
run: |
jq -r '.[] | select(.type == "result") | .result // empty' \
"$EXECUTION_FILE"
Loading