diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 00000000000..5b7947eb329 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,221 @@ +name: Claude Code (Review) + +# Setup +# - This workflow does not need the GitHub App private key. +# - Do not attach the `claude-automation` environment here. +# - Store `CLAUDE_CODE_OAUTH_TOKEN` as a repository or organization Actions secret. +# - Create a repository or organization Actions variable: +# - CLAUDE_APP_LOGIN +# Set this to the bot login for the GitHub App, usually `[bot]`. +# - It may use the default GITHUB_TOKEN to read repository data and post review +# comments, but it must never be able to push commits or open branches. +# +# Why this workflow exists separately +# - PR review traffic is a different trust boundary from issue automation. +# - This workflow is intentionally read-only with respect to repository contents. +# - Fork PRs are refused outright. We do not "promote" or manually bless fork +# content into Claude. If a contributor wants Claude to implement something, +# a maintainer should restate the task on an issue and use claude-write.yml. +# - PR conversation comments on PRs already opened by the Claude App are handled by +# claude-write.yml so maintainers can ask Claude to make follow-up changes there. + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} + cancel-in-progress: true + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + +jobs: + gate: + name: Gate PR Trigger + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + outputs: + should_run: ${{ steps.gate.outputs.should_run }} + reason: ${{ steps.gate.outputs.reason }} + pull_number: ${{ steps.gate.outputs.pull_number }} + checkout_ref: ${{ steps.gate.outputs.checkout_ref }} + actor_has_write: ${{ steps.gate.outputs.actor_has_write }} + steps: + - name: Check whether this PR event is allowed to reach Claude + id: gate + uses: actions/github-script@v7 + with: + github-token: ${{ github.token }} + script: | + const core = require('@actions/core'); + + const sender = context.payload.sender?.login ?? ''; + const senderType = context.payload.sender?.type ?? ''; + const trustedClaudeLogin = process.env.CLAUDE_APP_LOGIN ?? ''; + + async function getPermission(username) { + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return data.permission; + } catch (error) { + if (error.status === 404) { + return 'none'; + } + throw error; + } + } + + let mentioned = false; + let pullNumber = null; + + if (context.eventName === 'issue_comment') { + mentioned = (context.payload.comment?.body ?? '').includes('@claude'); + if (context.payload.issue?.pull_request) { + pullNumber = context.payload.issue.number; + } + } else if (context.eventName === 'pull_request_review_comment') { + mentioned = (context.payload.comment?.body ?? '').includes('@claude'); + pullNumber = context.payload.pull_request?.number ?? null; + } else if (context.eventName === 'pull_request_review') { + mentioned = (context.payload.review?.body ?? '').includes('@claude'); + pullNumber = context.payload.pull_request?.number ?? null; + } + + let reason = ''; + + if (!mentioned) { + reason = 'not_mentioned'; + } else if (!pullNumber) { + reason = 'not_a_pr_event'; + } else if (senderType === 'Bot') { + reason = 'bot_sender_refused'; + } + + let actorHasWrite = 'false'; + if (!reason) { + const permission = await getPermission(sender); + core.setOutput('actor_permission', permission); + actorHasWrite = ['admin', 'maintain', 'write'].includes(permission) ? 'true' : 'false'; + if (actorHasWrite != 'true') { + reason = 'actor_lacks_write'; + } + } + + if (!reason) { + let pr = null; + const response = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + }); + pr = response.data; + + const headRepo = pr.head.repo; + const isFork = !headRepo || Boolean(headRepo.fork) || headRepo.full_name !== `${context.repo.owner}/${context.repo.repo}`; + core.setOutput('pull_number', String(pullNumber)); + core.setOutput('checkout_ref', pr.head.sha); + + if (isFork) { + reason = 'fork_pr_refused'; + } else if ( + context.eventName === 'issue_comment' && + trustedClaudeLogin && + (pr.user?.login ?? '') === trustedClaudeLogin + ) { + reason = 'claude_pr_uses_write_workflow'; + } + + if (!reason) { + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + per_page: 100, + }); + if (files.some(f => f.filename.startsWith('.github/'))) { + reason = 'modifies_github_dir'; + } + } + } + + core.setOutput('actor_has_write', actorHasWrite); + core.setOutput('should_run', !reason ? 'true' : 'false'); + core.setOutput('reason', reason || 'allowed'); + env: + CLAUDE_APP_LOGIN: ${{ vars.CLAUDE_APP_LOGIN }} + + refuse-fork: + name: Explain Fork Refusal + needs: gate + if: needs.gate.outputs.reason == 'fork_pr_refused' && needs.gate.outputs.actor_has_write == 'true' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Comment with the fork policy + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(${{ needs.gate.outputs.pull_number }}), + body: [ + "Claude review automation is disabled for fork pull requests.", + "", + "Why:", + "- fork content is untrusted input", + "- this repository does not allow Claude to run against fork content", + "- there is no promotion path for forks", + "", + "If maintainers want Claude to implement a change, restate the task on an issue and use the issue-driven Claude workflow instead." + ].join("\\n"), + }); + + claude: + name: Run Claude PR Review + needs: gate + if: needs.gate.outputs.should_run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + issues: write + pull-requests: write + actions: read + steps: + - name: Checkout same-repo PR contents + uses: actions/checkout@v6 + with: + ref: ${{ needs.gate.outputs.checkout_ref }} + fetch-depth: 1 + # Keep git credentials out of the workspace. This workflow is review-only + # and should never push changes. + persist-credentials: false + + - name: Run Claude Code in review-only mode + id: claude + uses: anthropics/claude-code-action@6cad158a175744eb2e76f7f5fd108ec63145598c + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # This workflow deliberately uses the built-in token because it only needs + # to read repository state and post review comments. It cannot write repo + # contents, and it has no access to the GitHub App private key. + github_token: ${{ github.token }} + + additional_permissions: | + actions: read + + claude_args: | + --allowedTools "Read,Grep,Glob,Bash(git diff:*),Bash(git show:*),Bash(git log:*),Bash(head:*),Bash(jq:*),Bash(rg:*)" + --system-prompt "You are the repository's read-only Claude review workflow. Review the current same-repo pull request and respond in GitHub. Never modify files, never create commits, never push branches, and never open or update pull requests. Fork pull requests are blocked before this job starts." diff --git a/.github/workflows/claude-write.yml b/.github/workflows/claude-write.yml new file mode 100644 index 00000000000..3344f59f993 --- /dev/null +++ b/.github/workflows/claude-write.yml @@ -0,0 +1,213 @@ +name: Claude Code (Write) + +# Setup +# 1. Create a dedicated GitHub App and install it only on this repository. +# 2. Grant the App only: +# - Contents: Read & write +# - Issues: Read & write +# - Pull requests: Read & write +# 3. Create a GitHub Actions environment named `claude-automation`. +# 4. Store these environment secrets in `claude-automation`: +# - APP_ID +# - APP_PRIVATE_KEY +# - CLAUDE_CODE_OAUTH_TOKEN +# 5. Create a repository or organization Actions variable: +# - CLAUDE_APP_LOGIN +# Set this to the bot login for the GitHub App, usually `[bot]`. +# +# Why this workflow exists separately +# - This is the only workflow that can read the GitHub App private key. +# - It is primarily issue-driven. The only PR path it allows is a follow-up comment +# on a same-repo PR that was already opened by the Claude GitHub App. +# - Claude uses a short-lived GitHub App installation token, not GITHUB_TOKEN, so +# PRs opened by Claude trigger normal `pull_request` workflows. +# - A gate job runs before the environment is attached so untrusted events are +# rejected before the private key is exposed. +# - GitHub exposes the author of an app-created PR as the app's bot login rather +# than the numeric App ID in the PR payload, so the gate checks `CLAUDE_APP_LOGIN` +# instead of comparing directly to `APP_ID`. + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: true + +on: + issues: + types: [opened, assigned] + issue_comment: + types: [created] + +jobs: + gate: + name: Gate Issue Trigger + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + outputs: + should_run: ${{ steps.gate.outputs.should_run }} + reason: ${{ steps.gate.outputs.reason }} + checkout_ref: ${{ steps.gate.outputs.checkout_ref }} + steps: + - name: Check whether this event is allowed to reach Claude + id: gate + uses: actions/github-script@v7 + with: + github-token: ${{ github.token }} + script: | + const core = require('@actions/core'); + + const sender = context.payload.sender?.login ?? ''; + const senderType = context.payload.sender?.type ?? ''; + const trustedClaudeLogin = process.env.CLAUDE_APP_LOGIN ?? ''; + + async function getPermission(username) { + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return data.permission; + } catch (error) { + if (error.status === 404) { + return 'none'; + } + throw error; + } + } + + let mentioned = false; + let reason = ''; + let checkoutRef = context.payload.repository?.default_branch ?? ''; + + if (context.eventName === 'issues') { + const title = context.payload.issue?.title ?? ''; + const body = context.payload.issue?.body ?? ''; + mentioned = title.includes('@claude') || body.includes('@claude'); + } else if (context.eventName === 'issue_comment') { + const body = context.payload.comment?.body ?? ''; + mentioned = body.includes('@claude'); + + if (context.payload.issue?.pull_request) { + const response = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.issue.number, + }); + const pr = response.data; + + checkoutRef = pr.head.sha; + + const headRepo = pr.head.repo; + const isFork = !headRepo || Boolean(headRepo.fork) || headRepo.full_name !== `${context.repo.owner}/${context.repo.repo}`; + if (isFork) { + reason = 'fork_pr_refused'; + } else if (!trustedClaudeLogin) { + reason = 'missing_claude_app_login'; + } else if ((pr.user?.login ?? '') !== trustedClaudeLogin) { + reason = 'pr_not_owned_by_claude_app'; + } + + if (!reason) { + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.issue.number, + per_page: 100, + }); + if (files.some(f => f.filename.startsWith('.github/'))) { + reason = 'modifies_github_dir'; + } + } + } + } + + if (!reason && !mentioned) { + reason = 'not_mentioned'; + } + + if (!reason && senderType === 'Bot') { + reason = 'bot_sender_refused'; + } + + if (!reason) { + const permission = await getPermission(sender); + core.setOutput('actor_permission', permission); + if (!['admin', 'maintain', 'write'].includes(permission)) { + reason = 'actor_lacks_write'; + } + } + + core.setOutput('checkout_ref', checkoutRef); + core.setOutput('should_run', !reason ? 'true' : 'false'); + core.setOutput('reason', reason || 'allowed'); + env: + CLAUDE_APP_LOGIN: ${{ vars.CLAUDE_APP_LOGIN }} + + claude: + name: Run Claude Code + needs: gate + if: needs.gate.outputs.should_run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + + environment: + # The App private key lives only in this environment so only this single job + # can mint a GitHub App installation token. + name: claude-automation + deployment: false + + permissions: + contents: read + issues: read + pull-requests: read + actions: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ needs.gate.outputs.checkout_ref }} + fetch-depth: 0 + # Do not leave the built-in GITHUB_TOKEN in git config. Claude should use + # only the GitHub App token generated below so its PRs trigger CI normally. + persist-credentials: false + + - name: Generate short-lived GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-issues: write + permission-pull-requests: write + + - name: Setup Rust toolchain + uses: ./.github/actions/setup-rust + with: + enable-sccache: "false" + + - name: Install uv + uses: spiraldb/actions/.github/actions/setup-uv@0.18.5 + with: + sync: false + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@6cad158a175744eb2e76f7f5fd108ec63145598c + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ steps.app-token.outputs.token }} + + # Claude may inspect CI state on related PRs, but this workflow is otherwise + # issue-driven and is the only place with write-capable GitHub credentials. + additional_permissions: | + actions: read + + claude_args: | + --allowedTools "Bash(cargo nextest:*),Bash(cargo check:*),Bash(cargo clippy:*),Bash(cargo fmt:*),Bash(uv run:*)" + --system-prompt "You are the repository's write-capable Claude workflow. You run only from trusted issue traffic and from trusted PR conversation comments on same-repo pull requests that were previously opened by the repository's Claude GitHub App. Create or update branches and pull requests using the provided GitHub App token. Do not use fork pull request content because those runs are blocked before this job starts." diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 448cc5e2c50..00000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Claude Code - -concurrency: - # We shouldn't have multiple instances of Claude running on the same PR. - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -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')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-rust - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install uv - uses: spiraldb/actions/.github/actions/setup-uv@0.18.5 - with: - sync: false - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Customize the trigger phrase (default: @claude) - # trigger_phrase: "/claude" - - # Optional: Trigger when specific user is assigned to an issue - # assignee_trigger: "claude-bot" - - claude_args: | - --allowedTools "Bash(cargo nextest:*),Bash(cargo check:*),Bash(cargo clippy:*),Bash(cargo fmt:*),Bash(uv run:*)" - --system-prompt "You have been granted tools for editing files and running cargo commands (cargo nextest, cargo check, cargo clippy, cargo fmt) and uv for running pytest (e.g. via uv run --all-packages pytest)" - - # Optional: Custom environment variables for Claude - # claude_env: | - # NODE_ENV: test