From e22b906295465f5fd8fb5c0d343b3a79493eb9d9 Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Mon, 6 Apr 2026 09:41:33 -0400 Subject: [PATCH 1/4] Add separate claude review / claude write actions Signed-off-by: Nicholas Gates --- .github/workflows/claude-review.yml | 208 ++++++++++++++++++++++++++++ .github/workflows/claude-write.yml | 200 ++++++++++++++++++++++++++ .github/workflows/claude.yml | 66 --------- 3 files changed, 408 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/claude-review.yml create mode 100644 .github/workflows/claude-write.yml delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 00000000000..8073ccc78ab --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,208 @@ +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 isFork = Boolean(pr.head.repo.fork) || pr.head.repo.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'; + } + } + + 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(cat:*),Bash(head:*),Bash(sed:*),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..440052467a1 --- /dev/null +++ b/.github/workflows/claude-write.yml @@ -0,0 +1,200 @@ +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.ref; + + const isFork = Boolean(pr.head.repo.fork) || pr.head.repo.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 && !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 79553228a71..00000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,66 +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 - timeout-minutes: 120 - 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 From c6126280af454bc201429e9a168cc449ddc61a74 Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Mon, 6 Apr 2026 09:47:53 -0400 Subject: [PATCH 2/4] Add separate claude review / claude write actions Signed-off-by: Nicholas Gates --- .github/workflows/claude-review.yml | 17 +++++++++++++++-- .github/workflows/claude-write.yml | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 8073ccc78ab..5b7947eb329 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -119,7 +119,8 @@ jobs: }); pr = response.data; - const isFork = Boolean(pr.head.repo.fork) || pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`; + 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); @@ -132,6 +133,18 @@ jobs: ) { 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); @@ -204,5 +217,5 @@ jobs: actions: read claude_args: | - --allowedTools "Read,Grep,Glob,Bash(git diff:*),Bash(git show:*),Bash(git log:*),Bash(cat:*),Bash(head:*),Bash(sed:*),Bash(jq:*),Bash(rg:*)" + --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 index 440052467a1..3344f59f993 100644 --- a/.github/workflows/claude-write.yml +++ b/.github/workflows/claude-write.yml @@ -98,9 +98,10 @@ jobs: }); const pr = response.data; - checkoutRef = pr.head.ref; + checkoutRef = pr.head.sha; - const isFork = Boolean(pr.head.repo.fork) || pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`; + 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) { @@ -108,6 +109,18 @@ jobs: } 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'; + } + } } } From 86a3754c88e4e6d713e4cdd3400b41fce7590723 Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 21 Apr 2026 13:10:42 -0400 Subject: [PATCH 3/4] Initial Commit Signed-off-by: Nicholas Gates --- .github/workflows/claude-review.yml | 2 -- .github/workflows/claude-write.yml | 2 -- 2 files changed, 4 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 5b7947eb329..eca7ee88b8b 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -52,8 +52,6 @@ jobs: 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 ?? ''; diff --git a/.github/workflows/claude-write.yml b/.github/workflows/claude-write.yml index 3344f59f993..7e4b8fd17fe 100644 --- a/.github/workflows/claude-write.yml +++ b/.github/workflows/claude-write.yml @@ -56,8 +56,6 @@ jobs: 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 ?? ''; From 077b1a1fd3f0f2ccc08bbf70d006122b1ebdeb7b Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 21 Apr 2026 14:09:12 -0400 Subject: [PATCH 4/4] Initial Commit Signed-off-by: Nicholas Gates --- .github/workflows/claude-review.yml | 66 +++++++++++++++++++++++------ .github/workflows/claude-write.yml | 4 +- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index eca7ee88b8b..a8323f82b51 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -20,6 +20,8 @@ name: Claude Code (Review) # claude-write.yml so maintainers can ask Claude to make follow-up changes there. concurrency: + # `issue_comment` events on PRs expose the PR number via `github.event.issue.number`, + # so this falls back there when `github.event.pull_request.number` is unset. group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} cancel-in-progress: true @@ -88,6 +90,10 @@ jobs: pullNumber = context.payload.pull_request?.number ?? null; } + if (pullNumber) { + core.setOutput('pull_number', String(pullNumber)); + } + let reason = ''; if (!mentioned) { @@ -103,11 +109,15 @@ jobs: const permission = await getPermission(sender); core.setOutput('actor_permission', permission); actorHasWrite = ['admin', 'maintain', 'write'].includes(permission) ? 'true' : 'false'; - if (actorHasWrite != 'true') { + if (actorHasWrite !== 'true') { reason = 'actor_lacks_write'; } } + if (!reason && context.eventName === 'issue_comment' && !trustedClaudeLogin) { + reason = 'missing_claude_app_login'; + } + if (!reason) { let pr = null; const response = await github.rest.pulls.get({ @@ -118,8 +128,7 @@ jobs: 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)); + const isFork = !headRepo || headRepo.fork || headRepo.full_name !== `${context.repo.owner}/${context.repo.repo}`; core.setOutput('checkout_ref', pr.head.sha); if (isFork) { @@ -139,6 +148,8 @@ jobs: pull_number: pullNumber, per_page: 100, }); + // Refuse PRs that modify `.github/` because workflow files define the + // automation policy this review job is enforcing. if (files.some(f => f.filename.startsWith('.github/'))) { reason = 'modifies_github_dir'; } @@ -151,24 +162,28 @@ jobs: env: CLAUDE_APP_LOGIN: ${{ vars.CLAUDE_APP_LOGIN }} - refuse-fork: - name: Explain Fork Refusal + explain-refusal: + name: Explain Refusal needs: gate - if: needs.gate.outputs.reason == 'fork_pr_refused' && needs.gate.outputs.actor_has_write == 'true' + if: | + needs.gate.outputs.actor_has_write == 'true' && + ( + needs.gate.outputs.reason == 'fork_pr_refused' || + needs.gate.outputs.reason == 'modifies_github_dir' || + needs.gate.outputs.reason == 'missing_claude_app_login' + ) runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - - name: Comment with the fork policy + - name: Comment with the refusal reason 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: [ + const reason = ${{ toJSON(needs.gate.outputs.reason) }}; + const messages = { + fork_pr_refused: [ "Claude review automation is disabled for fork pull requests.", "", "Why:", @@ -177,7 +192,32 @@ jobs: "- 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"), + ].join("\n"), + modifies_github_dir: [ + "Claude review automation is disabled for pull requests that modify `.github/` files.", + "", + "Why:", + "- workflow and action files are part of the automation policy", + "- this review workflow refuses to evaluate automation changes from the same PR", + "", + "Ask a human reviewer to inspect workflow changes directly." + ].join("\n"), + missing_claude_app_login: [ + "Claude review automation is misconfigured for issue-comment triggers.", + "", + "Why:", + "- `CLAUDE_APP_LOGIN` is not set", + "- without that bot login, the review workflow cannot safely route comments on Claude-owned PRs to the write workflow", + "", + "Set the `CLAUDE_APP_LOGIN` Actions variable, then retry the command." + ].join("\n"), + }; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(${{ needs.gate.outputs.pull_number }}), + body: messages[reason], }); claude: diff --git a/.github/workflows/claude-write.yml b/.github/workflows/claude-write.yml index 7e4b8fd17fe..d4d4c6ad03f 100644 --- a/.github/workflows/claude-write.yml +++ b/.github/workflows/claude-write.yml @@ -99,7 +99,7 @@ jobs: checkoutRef = pr.head.sha; const headRepo = pr.head.repo; - const isFork = !headRepo || Boolean(headRepo.fork) || headRepo.full_name !== `${context.repo.owner}/${context.repo.repo}`; + const isFork = !headRepo || headRepo.fork || headRepo.full_name !== `${context.repo.owner}/${context.repo.repo}`; if (isFork) { reason = 'fork_pr_refused'; } else if (!trustedClaudeLogin) { @@ -115,6 +115,8 @@ jobs: pull_number: context.payload.issue.number, per_page: 100, }); + // Refuse workflow changes so write-capable automation never acts on + // `.github/` modifications from the same pull request. if (files.some(f => f.filename.startsWith('.github/'))) { reason = 'modifies_github_dir'; }