From a7efb44c2ba74c3debdfb2548848dbeba8265ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Bl=C3=B6chlinger?= Date: Wed, 5 Aug 2026 17:26:11 +0200 Subject: [PATCH 1/3] Add claude_review.yml reusable workflow Runs a Claude code review on a PR when someone comments '@claude review' on it. Same reviewer as the /code-review command in the Claude Code CLI. Deliberately unconfigurable: no comment arguments, model fixed to opus and review depth to medium effort. Reviews are never automatic, so a PR only costs something when a developer explicitly asks for a review, and each run posts back what Claude Code recorded for it (cost, tokens, duration) so the requester sees the price. Review-only by construction: the job token gets contents: read and Claude is given no Edit/Write/Bash tools, so a review cannot touch the code. --- .github/workflows/claude_review.yml | 114 ++++++++++++++++++++++++++++ README.md | 79 ++++++++++++++++++- 2 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/claude_review.yml diff --git a/.github/workflows/claude_review.yml b/.github/workflows/claude_review.yml new file mode 100644 index 0000000..fe2ee85 --- /dev/null +++ b/.github/workflows/claude_review.yml @@ -0,0 +1,114 @@ +name: Claude review +# Central, org-wide reusable workflow that runs a Claude code review on a pull request, +# on demand, when someone comments "@claude review" on it. +# Consumers: uses: Duatic/ci-workflows/.github/workflows/claude_review.yml@v1 +# +# Owns everything that shouldn't be re-decided per repo: +# - the trigger phrase and the guard that keeps ordinary PR chatter from starting a runner +# - the model and review depth (opus / medium effort) - deliberately not configurable +# - review-only enforcement: the job token cannot push and Claude gets no write tools +# - auto-cancelling a superseded review on the same PR +# +# Consumers only choose their runner, and supply an ANTHROPIC_API_KEY secret. +# +# Reviews are never automatic - nothing runs until a developer asks for one, so review +# cost is only ever incurred on request. The run's own accounting (cost, tokens, duration) +# is posted back onto the PR so the requester sees what it cost. + +on: + workflow_call: + inputs: + runner: + description: 'Runner label(s) to run the job on, e.g. "ubuntu-latest" or "self-hosted".' + default: 'self-hosted' + required: false + type: string + secrets: + ANTHROPIC_API_KEY: + description: 'Claude API key used for the review. Set as a repo secret; pass via `secrets: inherit`.' + required: true + +concurrency: + # A second request on the same PR supersedes an in-flight review of it. + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + review: + name: Review + # `issue_comment` also fires for plain issues, hence the `issue.pull_request` check. + # Gating here rather than inside the job means normal PR comments never occupy a runner. + if: >- + github.event.issue.pull_request && + startsWith(github.event.comment.body, '@claude review') + runs-on: ${{ inputs.runner }} + permissions: + contents: read # review-only: the job token cannot push + pull-requests: write # ...but it can comment + id-token: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + track_progress: true + use_sticky_comment: true + # The same reviewer as the `/code-review` command in the Claude Code CLI, so + # findings are calibrated identically to what developers see locally. + 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.issue.number }}' + # `medium` effort reports only the findings the reviewer is confident in, which + # keeps false positives low. --allowedTools deliberately omits Edit/Write/Bash: + # combined with `contents: read` above, there is no path to changing the code. + claude_args: | + --model opus + --effort medium + --max-turns 20 + --allowedTools "Read,Grep,Glob,mcp__github_inline_comment__create_inline_comment,mcp__github_comment__*" + + - name: Report run details + # `always()`: a failed review still burned tokens, so still report what it recorded. + if: always() + uses: actions/github-script@v9 + env: + EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }} + with: + script: | + const fs = require('fs'); + const file = process.env.EXECUTION_FILE; + if (!file || !fs.existsSync(file)) { + core.info('No execution file produced - nothing to report.'); + return; + } + let stats; + try { + const log = JSON.parse(fs.readFileSync(file, 'utf8')); + const last = Array.isArray(log) ? log[log.length - 1] : log; + // Report whatever Claude Code recorded for the run (cost, tokens, duration, + // turns, ...), minus `result` - that's the review prose, already posted above. + const { result, ...rest } = last ?? {}; + stats = rest; + } catch (error) { + core.info(`Could not parse execution file: ${error.message}`); + return; + } + const body = [ + '
Claude review run details', + '', + '```json', + JSON.stringify(stats, null, 2), + '```', + '', + '
', + ].join('\n'); + await core.summary.addRaw(body).write(); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body, + }); diff --git a/README.md b/README.md index fb97c49..958ec32 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,14 @@ GitHub Release with auto-generated notes. ## What consumers call -Repos only ever call **`ci_orchestrator.yml`** - it owns the distro matrix, the `ROS_REPO` channel -per distro, gating, concurrency, and the draft-PR policy, and internally drives the two leaf +For CI, repos only ever call **`ci_orchestrator.yml`** - it owns the distro matrix, the `ROS_REPO` +channel per distro, gating, concurrency, and the draft-PR policy, and internally drives the two leaf workflows (`reusable_ici.yml`, `pre-commit.yml`) that most repos never reference directly. +**`claude_review.yml`** is the other consumer-facing workflow, and is unrelated to CI: it runs a +Claude code review on a PR when someone comments `@claude review` on it. It's opt-in per repo and +never runs on its own. See [Claude code review](#claude-code-review-claude_reviewyml). + ### `ci_orchestrator.yml` - consumer-facing entry point | Input | Required | Default | Description | @@ -121,6 +125,76 @@ cron, concurrent writers hitting the *same* gist trip GitHub's secondary (abuse) after coalescing each repo down to one write. The abuse limit is sensitive to concurrent writes against one resource, not just total call volume. +## Claude code review (`claude_review.yml`) + +Comment this on any open pull request: + +``` +@claude review +``` + +A Claude code review runs against that PR and posts its findings as inline comments on the +lines it has something to say about, plus a summary comment. It's the same reviewer as the +`/code-review` command in the Claude Code CLI, so findings are calibrated the same way as +what you see locally. + +Nothing about the request is configurable - the comment takes no arguments, and the workflow +fixes the model to `opus` and the review depth to `medium` effort. `medium` reports only the +findings the reviewer is confident in, which keeps false positives low. + +**Reviews are never automatic.** They only run when someone asks, so no PR costs anything +unless a developer wants a review on it. Each run posts a collapsed *Claude review run +details* comment with what Claude Code recorded for it - cost, tokens, duration, turns - so +the requester can see the price. The same block lands in the job summary. + +**The review can only read.** The job's token gets `contents: read`, and Claude is given no +`Edit`, `Write`, or `Bash` tools, so a review cannot modify code, commit, or open a PR - it +comments and nothing else. Asking Claude to fix something in the comment won't work; that +would need a separate workflow, deliberately not built yet. + +| Input | Required | Default | Description | +|---|---|---|---| +| `runner` | no | `self-hosted` | Runner label(s) to run the review on, e.g. `ubuntu-latest`. | + +| Secret | Required | Description | +|---|---|---| +| `ANTHROPIC_API_KEY` | yes | Claude API key. Pass via `secrets: inherit`. | + +### Enabling it on a repo + +1. Add an `ANTHROPIC_API_KEY` **repo** secret (Settings -> Secrets and variables -> Actions). + There's no org secret for this - org secrets need a GitHub business plan - so **every repo + needs its own copy**. +2. Add this as `.github/workflows/claude.yml`. It's the entire file: + + ```yaml + name: Claude review + on: + issue_comment: + types: [created] + + jobs: + claude: + uses: Duatic/ci-workflows/.github/workflows/claude_review.yml@v1 + permissions: + contents: read + pull-requests: write + id-token: write + secrets: inherit + ``` + + `permissions` is spelled out because a reusable workflow can only ever *reduce* the + caller's token permissions, never raise them - so this works regardless of the org default + for `GITHUB_TOKEN`. + +3. **Merge it to the default branch.** GitHub only runs `issue_comment` workflows from the + version of the file on the default branch, so `@claude review` does nothing on any PR + until this file is on `main` - including on the PR that adds it. + +Comments that don't start with `@claude review`, and `@claude review` on a plain issue rather +than a PR, are filtered before a runner starts, so ordinary chatter never occupies one of the +self-hosted runner slots. A second request on a PR cancels an in-flight review of it. + ## Leaf workflows (internal, not called directly by product repos) - **`reusable_ici.yml`** - the upstream `ros-industrial` industrial_ci template, builds one distro/channel combination. Auto-detects `repos.list`, `Aptfile`, and `requirements.txt`. @@ -129,3 +203,4 @@ one resource, not just total call volume. ## Requirements on consumer repos - Repos using a private-dependency PAT must have a secret available (repo or org level) and pass `secrets: inherit`. - Repos opting into gist-backed badges must have a `GIST_TOKEN` secret available (repo or org level) and pass `secrets: inherit`. +- Repos opting into `claude_review.yml` must have their own `ANTHROPIC_API_KEY` repo secret and pass `secrets: inherit`. From 360a7daf9b068f767f427787a6ffe1a1069f101c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Bl=C3=B6chlinger?= Date: Wed, 5 Aug 2026 17:36:27 +0200 Subject: [PATCH 2/3] Accept a Claude subscription token as well as an API key claude_code_oauth_token (from 'claude setup-token', valid one year) lets reviews draw on a Pro/Max/Team/Enterprise plan allowance instead of being billed per token. Both secrets are optional and passed through; the empty one is ignored. Also note in the README that the cost figure the run-details block reports is computed locally at list rates, so under subscription auth it's what the review would have cost on the API rather than an actual charge. --- .github/workflows/claude_review.yml | 10 ++++++++-- README.md | 27 +++++++++++++++++++++------ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.github/workflows/claude_review.yml b/.github/workflows/claude_review.yml index fe2ee85..7fb8452 100644 --- a/.github/workflows/claude_review.yml +++ b/.github/workflows/claude_review.yml @@ -24,9 +24,13 @@ on: required: false type: string secrets: + # Supply exactly one of these as a repo secret and pass via `secrets: inherit`. + CLAUDE_CODE_OAUTH_TOKEN: + description: 'Claude subscription token from `claude setup-token` (valid 1 year). Usage draws on that account''s plan allowance rather than being billed per token.' + required: false ANTHROPIC_API_KEY: - description: 'Claude API key used for the review. Set as a repo secret; pass via `secrets: inherit`.' - required: true + description: 'Claude API key. Alternative to CLAUDE_CODE_OAUTH_TOKEN; billed per token to the Console organization.' + required: false concurrency: # A second request on the same PR supersedes an in-flight review of it. @@ -54,6 +58,8 @@ jobs: - id: claude uses: anthropics/claude-code-action@v1 with: + # Whichever of the two the consumer repo has set; the other is empty and ignored. + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} track_progress: true use_sticky_comment: true diff --git a/README.md b/README.md index 958ec32..fec14b0 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,9 @@ findings the reviewer is confident in, which keeps false positives low. **Reviews are never automatic.** They only run when someone asks, so no PR costs anything unless a developer wants a review on it. Each run posts a collapsed *Claude review run details* comment with what Claude Code recorded for it - cost, tokens, duration, turns - so -the requester can see the price. The same block lands in the job summary. +the requester can see the price. The same block lands in the job summary. Note that the +dollar figure is computed locally from token counts at list rates: on subscription auth +(below) it's what the review *would* have cost on the API, not a charge. **The review can only read.** The job's token gets `contents: read`, and Claude is given no `Edit`, `Write`, or `Bash` tools, so a review cannot modify code, commit, or open a PR - it @@ -158,13 +160,26 @@ would need a separate workflow, deliberately not built yet. | Secret | Required | Description | |---|---|---| -| `ANTHROPIC_API_KEY` | yes | Claude API key. Pass via `secrets: inherit`. | +| `CLAUDE_CODE_OAUTH_TOKEN` | one of | Claude subscription token from `claude setup-token`. Usage draws on that account's plan allowance. | +| `ANTHROPIC_API_KEY` | one of | Claude API key. Billed per token to the Console organization instead. | + +Set whichever one you want as a repo secret; the workflow passes both through and ignores the +empty one. If both are set, the API key wins. ### Enabling it on a repo -1. Add an `ANTHROPIC_API_KEY` **repo** secret (Settings -> Secrets and variables -> Actions). - There's no org secret for this - org secrets need a GitHub business plan - so **every repo - needs its own copy**. +1. Add **one** of the two secrets above as a **repo** secret (Settings -> Secrets and + variables -> Actions). There's no org secret for either - org secrets need a GitHub + business plan - so **every repo needs its own copy**. + + For `CLAUDE_CODE_OAUTH_TOKEN`, run `claude setup-token` locally while logged into a Claude + Pro/Max/Team/Enterprise account. It prints a token valid for one year and doesn't store it + anywhere, so copy it straight into the secret. Reviews then consume that account's plan + allowance - its rolling 5-hour and weekly windows, shared with that person's own Claude + Code and Claude chat usage - rather than being billed per token. Two consequences worth + planning around: whoever minted the token is effectively funding every review in the repo, + and once their window is exhausted review jobs fail until it resets. Set a calendar + reminder to rotate the token before it expires. 2. Add this as `.github/workflows/claude.yml`. It's the entire file: ```yaml @@ -203,4 +218,4 @@ self-hosted runner slots. A second request on a PR cancels an in-flight review o ## Requirements on consumer repos - Repos using a private-dependency PAT must have a secret available (repo or org level) and pass `secrets: inherit`. - Repos opting into gist-backed badges must have a `GIST_TOKEN` secret available (repo or org level) and pass `secrets: inherit`. -- Repos opting into `claude_review.yml` must have their own `ANTHROPIC_API_KEY` repo secret and pass `secrets: inherit`. +- Repos opting into `claude_review.yml` must have their own `CLAUDE_CODE_OAUTH_TOKEN` (or `ANTHROPIC_API_KEY`) repo secret and pass `secrets: inherit`. From 4895022294500cf95b01952867f8b2c7d4a9eadb Mon Sep 17 00:00:00 2001 From: mbloechli Date: Wed, 5 Aug 2026 17:53:44 +0200 Subject: [PATCH 3/3] fix: reduce verbose comments --- .github/workflows/claude_review.yml | 13 +----- README.md | 61 +++-------------------------- 2 files changed, 8 insertions(+), 66 deletions(-) diff --git a/.github/workflows/claude_review.yml b/.github/workflows/claude_review.yml index 7fb8452..9d5d6d4 100644 --- a/.github/workflows/claude_review.yml +++ b/.github/workflows/claude_review.yml @@ -1,6 +1,5 @@ name: Claude review -# Central, org-wide reusable workflow that runs a Claude code review on a pull request, -# on demand, when someone comments "@claude review" on it. +# Runs a Claude code review on a pull request, when someone comments "@claude review" on it. # Consumers: uses: Duatic/ci-workflows/.github/workflows/claude_review.yml@v1 # # Owns everything that shouldn't be re-decided per repo: @@ -10,10 +9,6 @@ name: Claude review # - auto-cancelling a superseded review on the same PR # # Consumers only choose their runner, and supply an ANTHROPIC_API_KEY secret. -# -# Reviews are never automatic - nothing runs until a developer asks for one, so review -# cost is only ever incurred on request. The run's own accounting (cost, tokens, duration) -# is posted back onto the PR so the requester sees what it cost. on: workflow_call: @@ -40,8 +35,7 @@ concurrency: jobs: review: name: Review - # `issue_comment` also fires for plain issues, hence the `issue.pull_request` check. - # Gating here rather than inside the job means normal PR comments never occupy a runner. + # Only fire for a PR comment that starts with the trigger phrase. if: >- github.event.issue.pull_request && startsWith(github.event.comment.body, '@claude review') @@ -63,8 +57,6 @@ jobs: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} track_progress: true use_sticky_comment: true - # The same reviewer as the `/code-review` command in the Claude Code CLI, so - # findings are calibrated identically to what developers see locally. 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.issue.number }}' @@ -78,7 +70,6 @@ jobs: --allowedTools "Read,Grep,Glob,mcp__github_inline_comment__create_inline_comment,mcp__github_comment__*" - name: Report run details - # `always()`: a failed review still burned tokens, so still report what it recorded. if: always() uses: actions/github-script@v9 env: diff --git a/README.md b/README.md index fec14b0..eeb8213 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ cron, concurrent writers hitting the *same* gist trip GitHub's secondary (abuse) after coalescing each repo down to one write. The abuse limit is sensitive to concurrent writes against one resource, not just total call volume. -## Claude code review (`claude_review.yml`) +## Claude code review Comment this on any open pull request: @@ -138,21 +138,17 @@ lines it has something to say about, plus a summary comment. It's the same revie `/code-review` command in the Claude Code CLI, so findings are calibrated the same way as what you see locally. -Nothing about the request is configurable - the comment takes no arguments, and the workflow +Nothing about the request is configurable. The comment takes no arguments, and the workflow fixes the model to `opus` and the review depth to `medium` effort. `medium` reports only the findings the reviewer is confident in, which keeps false positives low. **Reviews are never automatic.** They only run when someone asks, so no PR costs anything unless a developer wants a review on it. Each run posts a collapsed *Claude review run -details* comment with what Claude Code recorded for it - cost, tokens, duration, turns - so -the requester can see the price. The same block lands in the job summary. Note that the -dollar figure is computed locally from token counts at list rates: on subscription auth -(below) it's what the review *would* have cost on the API, not a charge. +details* comment with what Claude Code recorded for it. Note that the +dollar figure is computed locally from token counts at list rates: on subscription auth it's what the review *would* have cost on the API, not a charge. **The review can only read.** The job's token gets `contents: read`, and Claude is given no -`Edit`, `Write`, or `Bash` tools, so a review cannot modify code, commit, or open a PR - it -comments and nothing else. Asking Claude to fix something in the comment won't work; that -would need a separate workflow, deliberately not built yet. +`Edit`, `Write`, or `Bash` tools, so a review cannot modify code, commit, or open a PR. | Input | Required | Default | Description | |---|---|---|---| @@ -163,52 +159,7 @@ would need a separate workflow, deliberately not built yet. | `CLAUDE_CODE_OAUTH_TOKEN` | one of | Claude subscription token from `claude setup-token`. Usage draws on that account's plan allowance. | | `ANTHROPIC_API_KEY` | one of | Claude API key. Billed per token to the Console organization instead. | -Set whichever one you want as a repo secret; the workflow passes both through and ignores the -empty one. If both are set, the API key wins. - -### Enabling it on a repo - -1. Add **one** of the two secrets above as a **repo** secret (Settings -> Secrets and - variables -> Actions). There's no org secret for either - org secrets need a GitHub - business plan - so **every repo needs its own copy**. - - For `CLAUDE_CODE_OAUTH_TOKEN`, run `claude setup-token` locally while logged into a Claude - Pro/Max/Team/Enterprise account. It prints a token valid for one year and doesn't store it - anywhere, so copy it straight into the secret. Reviews then consume that account's plan - allowance - its rolling 5-hour and weekly windows, shared with that person's own Claude - Code and Claude chat usage - rather than being billed per token. Two consequences worth - planning around: whoever minted the token is effectively funding every review in the repo, - and once their window is exhausted review jobs fail until it resets. Set a calendar - reminder to rotate the token before it expires. -2. Add this as `.github/workflows/claude.yml`. It's the entire file: - - ```yaml - name: Claude review - on: - issue_comment: - types: [created] - - jobs: - claude: - uses: Duatic/ci-workflows/.github/workflows/claude_review.yml@v1 - permissions: - contents: read - pull-requests: write - id-token: write - secrets: inherit - ``` - - `permissions` is spelled out because a reusable workflow can only ever *reduce* the - caller's token permissions, never raise them - so this works regardless of the org default - for `GITHUB_TOKEN`. - -3. **Merge it to the default branch.** GitHub only runs `issue_comment` workflows from the - version of the file on the default branch, so `@claude review` does nothing on any PR - until this file is on `main` - including on the PR that adds it. - -Comments that don't start with `@claude review`, and `@claude review` on a plain issue rather -than a PR, are filtered before a runner starts, so ordinary chatter never occupies one of the -self-hosted runner slots. A second request on a PR cancels an in-flight review of it. +Set whichever one you want as a repo secret. If both are set, the API key wins. ## Leaf workflows (internal, not called directly by product repos)