diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..618edd8 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,2 @@ +self-hosted-runner: + labels: [incus] diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 7f756ac..7cdead5 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -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: @@ -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 @@ -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:*)" @@ -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 @@ -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< `. SINCE is empty: - review `gh pr diff `. 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//pulls//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 is empty: + review `gh pr diff `. 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//pulls//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 diff --git a/deny.toml b/deny.toml index ad1d9a4..4fafbdb 100644 --- a/deny.toml +++ b/deny.toml @@ -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"