Two pieces of hardening on the Claude Code workflow, plus a comment for a finding that needed no change. Same-repo gating. `Get PR details` now emits an `is_fork` output, and the PR-head checkout and the `Run Claude Code` step are both gated on it. A fork PR is declined outright — no checkout, no agent run, with the reason written to the step summary — rather than falling through to a "metadata-only" review of the base tree, which would trade an untrusted-code problem for a wrong-tree one. A deleted fork (`head.repo` null) counts as a fork. The head checkout also drops its `repository:` input: only a same-repo head reaches that step now, and checkout's default `github.repository` is a value no PR can influence. SHA pins. actions/checkout, actions/github-script, and claude-code-action were all on mutable major tags in a job that holds ANTHROPIC_API_KEY and grants the agent Bash. Each is now pinned to a full commit SHA with the trailing `# vX.Y.Z` comment Dependabot reads, so upgrade automation is unaffected. Pinned to the v7 / v9 SHAs so this does not regress the bumps in the open Dependabot PR #1922. The failure fallback Copilot flagged turns out not to exist: `Checkout repository` carries no status-check function, so GitHub applies an implicit `success()` and skips it after a failed lookup regardless of the `outcome` comparison. Its condition is now the explicit `== 'skipped'` and says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
130 lines
6.2 KiB
YAML
130 lines
6.2 KiB
YAML
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)
|
|
- 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 check out, 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('sha', pr.data.head.sha);
|
|
core.setOutput('is_fork', String(isFork));
|
|
|
|
# A fork PR's head is untrusted code, and checking it out would put it in
|
|
# reach of a tool-enabled agent run holding ANTHROPIC_API_KEY. Reviewing
|
|
# the base tree instead would only trade that 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 its code is not checked out and Claude is not run."
|
|
echo "Push the branch to this repository and re-trigger if a review is needed."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
# No `repository:` — a same-repo head is all that reaches this step, and
|
|
# checkout defaults to `github.repository`, which no PR can influence.
|
|
- name: Checkout PR branch
|
|
if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'false'
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ steps.pr.outputs.sha }}
|
|
fetch-depth: 0
|
|
|
|
# `skipped` means the trigger was an issue or a non-PR comment, so there
|
|
# is no head to check out and the base tree is the right one. The lookup
|
|
# having *failed* is deliberately not included: this condition carries no
|
|
# status-check function, so GitHub applies an implicit `success()` and
|
|
# skips the step after a failed prior step anyway. Spelling out `skipped`
|
|
# keeps that from having to be re-derived by the next reader.
|
|
- name: Checkout repository
|
|
if: steps.pr.outcome == 'skipped'
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Run Claude Code
|
|
# Runs only against a tree this workflow actually checked out: the base
|
|
# repo (non-PR trigger) or a same-repo PR head. A fork PR reaches here
|
|
# with `is_fork == 'true'` and both disjuncts false, so it is skipped.
|
|
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 <details> 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."
|