diff --git a/.github/workflows/claude-review.yaml b/.github/workflows/claude-review.yaml index 0630d68..dbebe48 100644 --- a/.github/workflows/claude-review.yaml +++ b/.github/workflows/claude-review.yaml @@ -53,11 +53,59 @@ on: default: sonnet secrets: CLAUDE_CODE_OAUTH_TOKEN: - description: 'Subscription token from `claude setup-token`. Usage draws on the allowance of whoever generated it.' - required: true + description: 'Subscription token from `claude setup-token`. Usage draws on the allowance of whoever generated it. Not required, so that a run without it - a Dependabot or fork pull request, which GitHub gives no secrets - is skipped with a notice rather than failing.' + required: false jobs: + gate: + # Decides whether this run can be reviewed at all, before a runner is spent on the review. + # GitHub gives no secrets to a run that Dependabot or a fork pull request started, and the + # review action rejects a bot actor, so either would otherwise end as a red job on every + # such pull request. Those are skipped by design: bot pull requests are reviewed by hand. + # The skip is reported as a notice rather than passed over in silence, so a missing token + # in a repository that should have one is visible. + # + # A fork pull request is refused even when a maintainer asks by comment, by decision: that + # run carries the token while the reviewed text is written by someone with no access, who + # could steer the verdict. A refused request gets a reply so it does not look ignored. + runs-on: [ubuntu-latest] + name: Can this be reviewed + timeout-minutes: 2 + outputs: + run: ${{ steps.check.outputs.run }} + steps: + - name: Check for a token, a human actor and a same-repository branch + id: check + env: + HAS_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} + ACTOR: ${{ github.actor }} + EVENT: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + PR: ${{ inputs.pr-number }} + run: | + # Fetched first and on its own, so a failed lookup stops the job rather than reading + # as "not a fork". + cross=$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json isCrossRepository --jq '.isCrossRepository') + if [ "$cross" = true ]; then + echo "::notice::Review refused: pull request #$PR comes from a fork, and fork pull requests are not reviewed." + if [ "$EVENT" = issue_comment ]; then + gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" \ + --body 'Claude review: refused. This pull request comes from a fork, and fork pull requests are not reviewed automatically, because the reviewed text is written by someone with no access while the run carries the token.' + fi + echo "run=false" >> "$GITHUB_OUTPUT" + elif [ "$HAS_TOKEN" != true ]; then + echo "::notice::Review skipped: no CLAUDE_CODE_OAUTH_TOKEN reached this run. GitHub withholds secrets from Dependabot and fork pull requests; elsewhere, check the secret is set and passed by the caller." + echo "run=false" >> "$GITHUB_OUTPUT" + elif [[ "$ACTOR" == *'[bot]' ]]; then + echo "::notice::Review skipped: $ACTOR is a bot, and bot pull requests are reviewed by hand." + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + review: + needs: gate + if: needs.gate.outputs.run == 'true' runs-on: [ubuntu-latest] name: Review # A backstop against a runaway session only. Coverage is deliberately not capped with diff --git a/README.md b/README.md index 13785ab..b42fd20 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,12 @@ inputs are at the top of `.github/workflows/claude-review.yaml`. and the range it covers starts at the last commit a posted review recorded, so commits the older one would have reviewed are not skipped. A burst of pushes during a review produces one delta review of all of them. +- **Bot and fork pull requests are skipped.** GitHub gives no secrets to a run started by + Dependabot or by a fork pull request, and bot pull requests are reviewed by hand, so a gate job + skips those runs with a notice instead of failing. A fork pull request is refused even when a + maintainer asks by comment, because that run carries the token over text written by someone + with no access; the request gets a reply saying so. A notice saying no token arrived, on a + pull request that is neither, means the secret is missing or not passed. - **The caller needs** `contents: read` and `pull-requests: write`, and must pass the secret explicitly.