From c1ade3e7684fe6d9c8869c9cf7692592a2907fee 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-mention.yml | 70 ++++++++++++++++++++++++++ .github/workflows/claude-pr-review.yml | 48 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 .github/workflows/claude-mention.yml create mode 100644 .github/workflows/claude-pr-review.yml 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 }}