name: Claude Code on: issue_comment: types: [created] pull_request_review_comment: types: [created] issues: types: [opened, assigned] pull_request_review: types: [submitted] jobs: claude: if: | ( (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association)) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association)) ) runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write actions: read steps: # Actions are pinned to full commit SHAs rather than mutable major tags: # this job holds ANTHROPIC_API_KEY and grants the agent Bash, so a # force-moved tag would be an unreviewed code change inside a # secret-holding job. The trailing `# vX.Y.Z` comment is the form # Dependabot reads, so pinning costs us no upgrade automation. (#1882) # This lookup exists only to answer "is the head in a fork?". The action # below checks out the PR branch itself, so no `sha` is needed here. - name: Get PR details if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request) || github.event_name == 'pull_request_review_comment' || github.event_name == 'pull_request_review' id: pr uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | let prNumber; if (context.eventName === 'issue_comment') { prNumber = context.issue.number; } else { prNumber = context.payload.pull_request.number; } const pr = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber }); // A fork PR's head lives in a different repository — or in none at // all, if the fork was deleted after the PR was opened (`head.repo` // is then null). Neither is ours to run against, so both count as a // fork here and the steps below decline rather than guess. const headRepo = pr.data.head.repo?.full_name ?? null; const isFork = headRepo !== `${context.repo.owner}/${context.repo.repo}`; core.setOutput('is_fork', String(isFork)); # A fork PR's head is untrusted code, and running a tool-enabled agent # holding ANTHROPIC_API_KEY against it is what this declines. The gate is # load-bearing even though we no longer check the head out ourselves: # claude-code-action fetches a cross-repository head via # `refs/pull/N/head` on its own (see `isCrossRepository` in its # src/github/operations/branch.ts), so skipping the action is the only # thing that keeps that code out of the workspace. Reviewing the base tree # instead would only trade the problem for a confident review of the wrong # tree, so decline visibly and leave the reason in the run. - name: Decline fork PR if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'true' run: | { echo "### Claude Code declined this pull request" echo echo "The head branch lives in a fork, so Claude is not run against it." echo "Push the branch to this repository and re-trigger if a review is needed." } >> "$GITHUB_STEP_SUMMARY" # Base ref only — no `ref:` input, so nothing a PR controls selects what # is checked out. On a PR, claude-code-action then fetches and checks out # the PR branch itself, which is why a second PR-head checkout here would # be redundant. It was also the one thing keeping CodeQL's # `actions/untrusted-checkout` alert open: the rule is syntactic and # cannot see the `is_fork` gate, the job-level `author_association` check, # or the repo's `collaborators_only` fork-PR policy. (#2069) - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Run Claude Code # `skipped` means the trigger was an issue or a non-PR comment. A # same-repo PR gives `is_fork == 'false'`. A fork PR reaches here with # `is_fork == 'true'` and both disjuncts false, so it is skipped — and # so the action never fetches its head. A *failed* lookup is excluded # too: this condition carries no status-check function, so GitHub # applies an implicit `success()` and skips the step after a failed # prior step regardless of what the comparisons say. if: steps.pr.outcome == 'skipped' || steps.pr.outputs.is_fork == 'false' id: claude uses: anthropics/claude-code-action@5ef2e550a465a721f4f45e4a7d3c340c873e1dcc # v1.0.190 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Allow Claude to read CI results on PRs additional_permissions: | actions: read # Trigger when assigned to an issue assignee_trigger: "claude" claude_args: | --mcp-config .mcp.json --allowedTools "Bash,mcp__mcp-docs" --append-system-prompt "If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a
block. When working on MCP-related code or reviewing MCP-related changes, use the mcp-docs MCP server to look up the latest protocol documentation. For schema details, reference https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema which contains versioned schemas in JSON (schema.json) and TypeScript (schema.ts) formats."