Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
self-hosted-runner:
labels: [incus]
243 changes: 92 additions & 151 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# Self-contained copy of rubas/ci code-review.yml: this repo is public and
# cannot call a reusable workflow in the private rubas/ci. Keep it in sync by
# hand when rubas/ci changes.
# cannot call a reusable workflow or action in the private rubas/ci. Keep it in
# sync by hand when rubas/ci changes.
#
# Auth: no GitHub secrets and no Claude GitHub App. The job mints a GitHub OIDC
# token, exchanges it for a short-lived Infisical token over the read-only
# identity ci-review, and reads the shared Claude OAuth token. The action gets
# the job token for GitHub. All values below are identifiers, safe to
# commit; only the token is a secret and it never leaves Infisical.
# identity ci-review, and reads the babel login of CI from r-ci-repo
# prod:/babel (rubas/ci README.md, Logins). Claude Code calls the babel gateway
# on epyc, and babel picks the Claude account. The action gets the job token
# for GitHub. All values below are identifiers, safe to commit.
name: Claude Code Review

on:
Expand All @@ -33,9 +34,8 @@ jobs:
# private address, so only these runners reach Infisical. The guard above
# keeps fork PRs off them.
runs-on: incus
# Each attempt below caps itself at 15 minutes. A failed account fails in
# seconds, so this only bounds a pathological run.
timeout-minutes: 30
# The review step caps itself at 15 minutes.
timeout-minutes: 20
permissions:
contents: read # source and history context
actions: read # workflow-runs API: find the last reviewed commit
Expand All @@ -46,7 +46,7 @@ jobs:
# above is. Agent mode spends no turn on a checklist update, so this only
# catches a loop.
CLAUDE_ARGS: |
--model claude-opus-5
--model claude-opus-5-5
--effort high
--max-turns 100
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api --paginate repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git log:*),Bash(git blame:*),Bash(git diff:*)"
Expand Down Expand Up @@ -91,8 +91,8 @@ jobs:
echo "no reviewable range, the whole PR gets reviewed"
fi

- name: Fetch Claude OAuth token
id: claude-oauth
- name: Fetch the babel login
id: babel
env:
# Identifiers only. The runner holds no Infisical client secret.
INFISICAL_API_URL: https://secrets.rubas.dev/api
Expand Down Expand Up @@ -121,162 +121,103 @@ jobs:
fi
echo "::add-mask::$TOKEN"

# One shared path for the whole fleet, so adding an account is a single
# write. Every CLAUDE_CODE_OAUTH_TOKEN* key there is one Claude
# account; sorting by key gives the same list on every runner.
SECRET_PATH="/claude-review"
SECRETS=$(curl -fsSL -G "$INFISICAL_API_URL/v3/secrets/raw" \
--data-urlencode "workspaceId=$INFISICAL_PROJECT_ID" \
--data-urlencode "environment=prod" \
--data-urlencode "secretPath=$SECRET_PATH" \
--data-urlencode "secretPath=/babel" \
-H "Authorization: Bearer $TOKEN")
mapfile -t CLAUDE_TOKENS < <(jq -r '
[ .secrets[]?
| select(.secretKey | test("^CLAUDE_CODE_OAUTH_TOKEN(_[0-9]+)?$"))
| select(.secretValue != null and .secretValue != "") ]
| sort_by(.secretKey) | .[].secretValue' <<<"$SECRETS")
COUNT=${#CLAUDE_TOKENS[@]}
if [[ "$COUNT" -eq 0 ]]; then
echo "::error::no CLAUDE_CODE_OAUTH_TOKEN* at Infisical path $SECRET_PATH (env prod)"
exit 1
fi

# Start at a different account per run so one account does not absorb
# every review and hit its limit first. Three attempt steps follow, so
# a fourth account only widens the rotation.
ATTEMPTS=$(( COUNT < 3 ? COUNT : 3 ))
for i in $(seq 0 $((ATTEMPTS - 1))); do
CLAUDE_TOKEN="${CLAUDE_TOKENS[$(( (GITHUB_RUN_NUMBER + i) % COUNT ))]}"
echo "::add-mask::$CLAUDE_TOKEN"
echo "token_$((i + 1))=$CLAUDE_TOKEN" >>"$GITHUB_OUTPUT"
for name in BABEL_KEY BABEL_POOL_SENTINEL; do
value=$(jq -r --arg k "$name" '.secrets[]? | select(.secretKey == $k) | .secretValue // empty' <<<"$SECRETS")
if [[ -z "$value" ]]; then
echo "::error::no $name at Infisical path /babel (env prod)"
exit 1
fi
echo "::add-mask::$value"
echo "${name,,}=$value" >>"$GITHUB_OUTPUT"
done
echo "$COUNT Claude account(s), $ATTEMPTS attempt(s) this run"

- name: Build the review prompt
# One copy of the prompt for every attempt below. It has to be a step
# output and not job-level env, because it names steps.since. The four
# variable values arrive through env and printf, so the run block holds
# no expansion and the prompt body stays a quoted heredoc.
id: prompt
env:
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
HEAD: ${{ github.event.pull_request.head.sha }}
SINCE: ${{ steps.since.outputs.since }}
run: |
set -euo pipefail
{
echo 'text<<REVIEW_PROMPT_EOF'
printf 'REPO: %s\nPR: %s\nHEAD: %s\nSINCE: %s\n\n' \
"$REPO" "$PR" "$HEAD" "$SINCE"
cat <<'PROMPT'
Cross-check the selected changes once. Post only findings that require
a change before merge. The local Babysit playbook owns gates and the
full review.

SINCE holds a commit: review `git diff <SINCE> <HEAD>`. SINCE is empty:
review `gh pr diff <PR>`. The selected diff is the reviewed artifact.
The PR title and body supply intent. Unchanged code supplies evidence.

Read the root AGENTS.md and any AGENTS.md under a changed directory.
Read DESIGN.md or WRITING.md when the selected diff changes that
surface. Use `git log` or `git blame` when intent remains unclear.
Never run a build, test, linter, formatter, or type check. CI owns them.

Review correctness, security, data integrity, state and ordering,
boundaries and contracts, required migrations, user recovery, and
tests for changed behaviour.

Use one proof test for every finding. All of these statements must hold:
- The selected diff introduced the issue.
- The current branch proves the issue.
- The issue qualifies under one of the cases below.
- The finding anchors to a line in the selected diff.
Drop the finding when any statement is unproven.

A defect qualifies when a reachable trigger causes wrong behaviour, a
security or data risk, a broken contract, or a wrong required action. A
test gap qualifies when it names the changed behaviour and the regression
that existing tests cannot catch. An AGENTS.md violation qualifies when
it quotes the loaded rule that the changed line breaks. Changed prose
qualifies when it directs a reader or model to the wrong action or
removes information required to act. A cleanup qualifies when the
selected diff duplicates a named existing unit or adds an unnecessary
comment, paragraph, or section that can be deleted in full.

Read existing inline comments with
`gh api --paginate repos/<REPO>/pulls/<PR>/comments`. Keep each defect in
one place.

Post each surviving finding on its changed line with
`mcp__github_inline_comment__create_inline_comment`. Open with `[P1]`
for correctness, security, data loss, or a broken contract, `[P2]` for
other merge-blocking behaviour, and `[P3]` for cleanup. State the
failure, its trigger, and the fix. Add a suggestion block when the exact
replacement for the anchored line is known. Inline findings are the
complete output. Post no summary or top-level comment. A clean review
posts nothing. Never approve or request changes.
PROMPT
echo 'REVIEW_PROMPT_EOF'
} >>"$GITHUB_OUTPUT"

# Up to three attempts, one Claude account each, in the rotated order the
# fetch step wrote. A dead, revoked or rate-limited account fails in
# seconds, so the fallback costs a run only when it is needed.
#
# No track_progress. It forces tag mode, which posts a tracking comment
# per run and injects the whole PR thread into the prompt every round, so
# each round re-reads settled findings and answers them again. Agent mode
# makes the prompt the only instruction; the inline-comment MCP server
# comes from the allowed tools, not from the mode.
- name: Claude review (account 1)
id: review-1
uses: anthropics/claude-code-action@cfc3eb22bfed5c26ef66e3223c982af27e4524de # v1.0.231
continue-on-error: true
- name: Claude review
uses: anthropics/claude-code-action@1d6de8cb0c237e7c15e9e1bdf973826ebae490cc # v1.0.248
timeout-minutes: 15
env:
# The babel gateway on epyc. It takes its key from x-api-key and the
# pool sentinel from the bearer, and sends each turn on a Claude
# account of its pool. The UniFi policy "CI → epyc babel" and the
# nftables of epyc admit the runners (rubas/incus services/epyc).
ANTHROPIC_BASE_URL: http://epyc.rubas.dev:8787
ANTHROPIC_AUTH_TOKEN: ${{ steps.babel.outputs.babel_pool_sentinel }}
with:
# The job token, not the Claude GitHub App token the action would get
# from Anthropic, so the review needs no app installed on this repo.
github_token: ${{ github.token }}
anthropic_api_key: ${{ steps.babel.outputs.babel_key }}
# The buffered-comment classifier posts the API key straight to
# api.anthropic.com and ignores ANTHROPIC_BASE_URL, so it would send
# the babel key to Anthropic. Every inline comment posts at once.
classify_inline_comments: "false"
prompt: |
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
HEAD: ${{ github.event.pull_request.head.sha }}
SINCE: ${{ steps.since.outputs.since }}

Cross-check the selected changes once. Post only findings that require
a change before merge. The local Babysit playbook owns gates and the
full review.

SINCE holds a commit: review `git diff <SINCE> <HEAD>`. SINCE is empty:
review `gh pr diff <PR>`. The selected diff is the reviewed artifact.
The PR title and body supply intent. Unchanged code supplies evidence.

Read the root AGENTS.md and any AGENTS.md under a changed directory.
Read DESIGN.md or WRITING.md when the selected diff changes that
surface. Use `git log` or `git blame` when intent remains unclear.
Never run a build, test, linter, formatter, or type check. CI owns them.

Review correctness, security, data integrity, state and ordering,
boundaries and contracts, required migrations, user recovery, and
tests for changed behaviour.

Use one proof test for every finding. All of these statements must hold:
- The selected diff introduced the issue.
- The current branch proves the issue.
- The issue qualifies under one of the cases below.
- The finding anchors to a line in the selected diff.
Drop the finding when any statement is unproven.

A defect qualifies when a reachable trigger causes wrong behaviour, a
security or data risk, a broken contract, or a wrong required action. A
test gap qualifies when it names the changed behaviour and the regression
that existing tests cannot catch. An AGENTS.md violation qualifies when
it quotes the loaded rule that the changed line breaks. Changed prose
qualifies when it directs a reader or model to the wrong action or
removes information required to act. A cleanup qualifies when the
selected diff duplicates a named existing unit or adds an unnecessary
comment, paragraph, or section that can be deleted in full.

Read existing inline comments with
`gh api --paginate repos/<REPO>/pulls/<PR>/comments`. Keep each defect in
one place.

Post each surviving finding on its changed line with
`mcp__github_inline_comment__create_inline_comment`. Open with `[P1]`
for correctness, security, data loss, or a broken contract, `[P2]` for
other merge-blocking behaviour, and `[P3]` for cleanup. State the
failure, its trigger, and the fix. Add a suggestion block when the exact
replacement for the anchored line is known. Inline findings are the
complete output. Post no summary or top-level comment. A clean review
posts nothing. Never approve or request changes.
claude_args: ${{ env.CLAUDE_ARGS }}
# The runner image ships the pinned CLI and the Bun version of this
# action (rubas/incus services/ci/runner-image), so the action skips
# both downloads.
path_to_claude_code_executable: /usr/local/bin/claude
path_to_bun_executable: /usr/local/lib/claude-code-action/bun
# The action refuses a bot actor unless it is listed here. The agents
# open and push PRs as rubas-agent[bot] (rubas/ops#107).
allowed_bots: rubas-agent
# Subscription OAuth keeps this work off metered API credits.
claude_code_oauth_token: ${{ steps.claude-oauth.outputs.token_1 }}
prompt: ${{ steps.prompt.outputs.text }}
claude_args: ${{ env.CLAUDE_ARGS }}

- name: Claude review (account 2)
id: review-2
if: steps.review-1.outcome == 'failure' && steps.claude-oauth.outputs.token_2 != ''
uses: anthropics/claude-code-action@cfc3eb22bfed5c26ef66e3223c982af27e4524de # v1.0.231
continue-on-error: true
timeout-minutes: 15
with:
github_token: ${{ github.token }}
allowed_bots: rubas-agent
claude_code_oauth_token: ${{ steps.claude-oauth.outputs.token_2 }}
prompt: ${{ steps.prompt.outputs.text }}
claude_args: ${{ env.CLAUDE_ARGS }}

- name: Claude review (account 3)
id: review-3
if: steps.review-1.outcome == 'failure' && steps.review-2.outcome != 'success' && steps.claude-oauth.outputs.token_3 != ''
uses: anthropics/claude-code-action@cfc3eb22bfed5c26ef66e3223c982af27e4524de # v1.0.231
continue-on-error: true
timeout-minutes: 15
with:
github_token: ${{ github.token }}
allowed_bots: rubas-agent
claude_code_oauth_token: ${{ steps.claude-oauth.outputs.token_3 }}
prompt: ${{ steps.prompt.outputs.text }}
claude_args: ${{ env.CLAUDE_ARGS }}

- name: Fail when no account reviewed
# The steps above swallow their own failure, so the job needs this to
# stay red. A red run also keeps the next run's SINCE at the last commit
# that a review really covered.
if: steps.review-1.outcome == 'failure' && steps.review-2.outcome != 'success' && steps.review-3.outcome != 'success'
run: |
echo "::error::Claude review failed on every available account"
exit 1
10 changes: 10 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ all-features = true

[advisories]
version = 2
# TODO(revert: when wreq-util releases zstd >= 0.14.3, 0x676e67/wreq-util#130): wreq-util 0.2.0,
# the newest release, needs zstd ^0.13.3. It only calls `stream::write::Encoder::new` and
# `stream::read::Decoder::new` for TLS certificate compression, and these calls reach none of the
# affected functions. cargo-deny reports an ignore that matches nothing, so this list cannot
# outlive the bump.
ignore = [
{ id = "RUSTSEC-2026-0391", reason = "affects only raw::{Encoder,Decoder}::with_context" },
{ id = "RUSTSEC-2026-0398", reason = "affects only the seekable feature, which is off" },
{ id = "RUSTSEC-2026-0403", reason = "affects only stream::write::Decoder" },
]

[bans]
multiple-versions = "warn"
Expand Down