From 49a3c3f8e26ba2e060ed90fb06704f3ebd5cea34 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sat, 19 Sep 2026 14:24:51 -0400 Subject: [PATCH] Move the Claude workflows to the shared composite actions Replaces both workflows with thin callers of jantman/github-actions-workflows, pinned at `@v1`, which now holds the logic ten repositories would otherwise hold ten copies of. The behaviour is the one that has been reviewing pull requests on jantman/privatepuppet and jantman/biweeklybudget: the agent writes its findings to a JSON file and the action turns them into exactly one GitHub review, with the findings as batched inline comments and the run's duration, turn count and cost in the body. A run that decides to skip still posts a one-line review. A denied tool call, an `is_error` result, or an agent that ends its turn without writing the findings file all fail the check rather than passing quietly -- which is how these runs used to go green having reviewed nothing. Composite actions rather than reusable workflows, deliberately. The action's default-branch validation happens server-side at api.anthropic.com from OIDC claims, and nothing in claude-code-action's source says whether it reads `workflow_ref` (the caller) or `job_workflow_ref` (the shared repo). With `workflow_call` that unknown decides whether it works at all. A composite action never asks it: it runs as part of this job, so the workflow being validated is this repository's own file, exactly as before. What stays here, because a composite action cannot reach it -- by the time one runs, the runner is up and the token is minted: - the triggers, `concurrency` and `permissions` - the fork guard on the review job: a fork pull request gets no secrets, so without it the action fails red on someone else's contribution - `actions/checkout`, so this repository keeps control of fetch-depth - the trigger guard on the mention job Pinned to `@v1`, a moving tag. Moving it updates all ten repositories at once, which is the point and also the risk; pin a SHA instead to take updates deliberately. Checked with actionlint. Because this repository is public, the mention workflow additionally requires the triggering author's `author_association` to be OWNER, MEMBER or COLLABORATOR. That job has `contents: write` and blanket `Bash`, so without the check any passer-by who types the mention gets an agent run billed to the maintainer's account. The field is read per-event on purpose: on `issue_comment`, `github.event.issue.author_association` describes the issue's opener rather than the commenter, so reading it there would let a stranger trigger a run merely by commenting on a maintainer's own issue. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-code-review.yml | 44 ------------ .github/workflows/claude-mention.yml | 70 +++++++++++++++++++ .github/workflows/claude-pr-review.yml | 48 +++++++++++++ .github/workflows/claude.yml | 89 ------------------------ 4 files changed, 118 insertions(+), 133 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude-mention.yml create mode 100644 .github/workflows/claude-pr-review.yml delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 24e7c04..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }} --comment' - claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment,Read,Glob,Grep,Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh api:*),Bash(git log:*),Bash(git blame:*),Bash(git diff:*),Bash(git show:*),Bash(git rev-parse:*)"' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/.github/workflows/claude-mention.yml b/.github/workflows/claude-mention.yml new file mode 100644 index 0000000..265301e --- /dev/null +++ b/.github/workflows/claude-mention.yml @@ -0,0 +1,70 @@ +name: Claude Code + +# Reactive: does nothing until someone writes @claude. Pairs with claude-pr-review.yml. +# +# >>> DO NOT MERGE THESE TWO INTO ONE JOB. <<< +# They look redundant and are not. The shared action for THIS file supplies no `prompt`, +# which is what keeps claude-code-action in TAG mode. Supplying one -- as the review +# action does -- puts it in AGENT mode for every event it sees, where an @claude comment +# gets no PR context, no tracking comment, and nothing posted back. +# +# NOTE: `issue_comment` workflows always run from the default branch, so edits to this +# file do nothing until they are merged. + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + # >>> THIS GUARD IS LOAD-BEARING ON A PUBLIC REPOSITORY. <<< + # Two conditions, both required, both checked per-event. + # + # 1. author_association of the person who ACTUALLY TRIGGERED IT. This job has + # `contents: write` and blanket `Bash`, so without this check any passer-by who + # types @claude gets an arbitrary-code-execution agent run on the maintainer's + # OAuth token. The pairing matters: on `issue_comment`, + # `github.event.issue.author_association` is the issue's *opener*, not the + # commenter, so checking that field here would let a stranger trigger a run + # merely by commenting on a maintainer's own issue. OWNER covers the repository + # owner; COLLABORATOR/MEMBER cover anyone later given push access. + # 2. the @claude mention itself. + # + # The guard cannot live in the shared action: by the time a composite action runs, + # the runner is already up and the token already minted. + 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: write + pull-requests: write + issues: write + id-token: write + actions: read # Required for Claude to read CI results on PRs + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - uses: jantman/github-actions-workflows/claude-mention@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/claude-pr-review.yml b/.github/workflows/claude-pr-review.yml new file mode 100644 index 0000000..7e49e06 --- /dev/null +++ b/.github/workflows/claude-pr-review.yml @@ -0,0 +1,48 @@ +name: Claude PR Review + +# Proactive: reviews every PR without being asked. Pairs with claude-mention.yml, the +# reactive half. Both are thin: the logic lives in +# jantman/github-actions-workflows/claude-pr-review. +# +# >>> A CHANGE TO *THIS FILE* CANNOT BE TESTED BY THE PR THAT MAKES IT. <<< +# claude-code-action refuses to run when this file differs from the copy on the default +# branch -- that is what stops a pull request from rewriting it to steal the OAuth +# token. It exits 0 when it skips, so such a PR goes green having reviewed nothing. +# Look for "Exiting due to workflow validation skip". Merge first, then the next PR +# gets a real review. A change to the shared ACTION has no such problem, because this +# file does not change. + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +# A new push supersedes the review it interrupted. Reviewing a commit that has already +# been replaced burns a full agent run on code that no longer exists. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + claude-review: + # A pull request from a fork gets no secrets, so CLAUDE_CODE_OAUTH_TOKEN is empty + # and the action fails red rather than skipping -- a confusing failure on somebody + # else's contribution. Skip cleanly instead. Always true on a repository that takes + # no fork pull requests. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # POST /pulls/N/reviews, and the agent reads the PR + issues: write # the action's own tracking/error comments, not the review + id-token: write + actions: read # lets the reviewer read this commit's CI results + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - uses: jantman/github-actions-workflows/claude-pr-review@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 966eaf8..0000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,89 +0,0 @@ -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')) || - (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: write - pull-requests: write - issues: write - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - fetch-depth: 1 - - - 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: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - claude_args: >- - --allowedTools - "Bash(git add:*)" - "Bash(git commit:*)" - "Bash(git checkout:*)" - "Bash(git diff:*)" - "Bash(git log:*)" - "Bash(git status:*)" - "Bash(git fetch:*)" - "Bash(git branch:*)" - "Bash(git push:*)" - "Bash(nox:*)" - "Bash(poetry:*)" - "Bash(pip install:*)" - "Bash(pip index:*)" - "Bash(gh pr:*)" - "Bash(gh pr view:*)" - "Bash(gh pr list:*)" - "Bash(gh api:*)" - "Bash(gh run:*)" - "Bash(cat:*)" - "Bash(ls:*)" - Read - Write - Edit - Glob - Grep - WebSearch - WebFetch - mcp__github__list_pull_requests - mcp__github__pull_request_read - mcp__github__pull_request_review_write - mcp__github__add_issue_comment - mcp__github__issue_read - mcp__github__issue_write - mcp__github__search_code - mcp__github__get_file_contents - mcp__github__create_or_update_file - mcp__github__list_commits - mcp__github__get_commit - mcp__github__create_pull_request - mcp__github__update_pull_request - mcp__github__create_branch - mcp__github__list_branches