1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
name: claude
# Interactive @claude responder — mention `@claude` in a PR / issue
# comment (or an issue body/title) and Claude Code picks the task
# up: answer questions, implement requested changes on the PR
# branch, triage the issue, etc. Companion to the automatic
# `claude-review.yml`; this one only fires on explicit mentions, so
# it needs no review-skip policy.
#
# `.tera` suffix: same dual purpose as the other workflow templates
# here — keeps GHA from auto-running the source inside pj-base, and
# opts the file into kata's Tera rendering for the
# `{{ vars.actions.* }}` action-version pins.
#
# Setup requirement on every consumer (one-time): same pair as
# claude-review.yml.tera — the Claude GitHub App
# (github.com/apps/claude) installed on the repo, plus the
# `CLAUDE_CODE_OAUTH_TOKEN` secret from `claude setup-token`
# (Claude Pro/Max subscription). Pay-as-you-go alternative: store
# `ANTHROPIC_API_KEY` and swap the input below.
#
# `when = "always"` (in template.toml): every consumer wants the
# same responder; fixes to the workflow flow automatically.
#
# No `pull_request_review` trigger, and the reason is a merge
# blocker masquerading as a responder that did not fire. Replying
# to an inline review comment — `/pulls/{n}/comments/{id}/replies`,
# which is exactly the AGENTS.md "reply to reviewers in each
# thread" convention — makes GitHub create a NEW review whose
# summary body is EMPTY and fires `pull_request_review` with
# `review.body == ""`. The `@claude` sits in the comment, not the
# summary, so the job's condition cannot see it; the job skips,
# and a SKIPPED check run named "claude" then blocks the merge
# under branch protection until an admin bypasses it. Observed on
# yukimemi/hakari#18. In-thread replies are already caught by
# `pull_request_review_comment` above, so dropping the review event
# loses nothing for that flow. The only loss is an `@claude` placed
# in a review *summary*, which is not a documented pattern.
on:
issue_comment:
types:
pull_request_review_comment:
types:
issues:
types:
permissions:
# contents/pull-requests/issues write: Claude may push fix
# commits to the PR branch and reply on threads. id-token for
# the action's OIDC exchange; actions read so it can inspect CI
# results when asked about failures.
contents: write
pull-requests: write
issues: write
id-token: write
actions: read
concurrency:
# Serialise responder runs per PR / issue — parallel @claude
# mentions on the same thread could race pushing commits to the
# same branch. No cancel: let the earlier request finish.
group: claude-respond-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
claude:
# Two conditions, and the first is new because the bottom of this
# file now hands the run a shell.
#
# `issue_comment` and friends always run the workflow from the
# default branch *with secrets*, and the mention used to be the only
# gate — so on a public repo any stranger could start this job. That
# was survivable while the action had no way to execute anything:
# the worst case was a wasted run. It stops being survivable the
# moment `Bash(cargo test:*)` is in the allow-list, because
# compiling a branch runs its `build.rs`, its proc-macros and its
# test bodies. Gating on the author's association keeps "can run
# code here" and "can push code here" the same set of people.
#
# The association lives under a different key per event type, and an
# absent key is the empty string — so `||` picks the first one that
# is present. The commenter's comes first, which is the one that
# matters on a comment event; the issue author's is the fallback for
# `issues`.
if: |
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'),
github.event.comment.author_association ||
github.event.issue.author_association) &&
(
(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 == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
# Same rationale as claude-review.yml.tera: the action has no
# wall-clock timeout of its own; bound a hung run well below
# GHA's 360-minute default. 45 rather than 30 to match the review
# job, for the same reason it went there — a mention that asks
# "does this actually hold?" may now compile from a cold cache.
timeout-minutes: 45
steps:
# Same guard as claude-review.yml.tera: an empty credential
# makes claude-code-action exit 0 without responding, so a
# mention would silently go unanswered with a green check next
# to it. Accepts either `CLAUDE_CODE_OAUTH_TOKEN` (subscription)
# or `ANTHROPIC_API_KEY` (pay-as-you-go, where the consumer also
# swaps the input below to `anthropic_api_key`).
#
# Read via env rather than inlining the expressions into the
# script, so the values are never substituted into the shell
# source.
- name: Check responder credentials
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
if [ -z "${CLAUDE_CODE_OAUTH_TOKEN}" ] && [ -z "${ANTHROPIC_API_KEY}" ]; then
echo "::error title=claude responder is not configured::Neither CLAUDE_CODE_OAUTH_TOKEN nor ANTHROPIC_API_KEY is set on ${{ github.repository }}. Subscription path: generate a token with 'claude setup-token' and store it with 'gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo ${{ github.repository }}'. Pay-as-you-go path: set ANTHROPIC_API_KEY instead and swap the action input to anthropic_api_key. Until then @claude mentions in this repo go unanswered."
exit 1
fi
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 1
# persist-credentials stays ON (the default) on purpose: the
# action's setupBranch runs `git fetch origin <branch>` BEFORE
# it configures its own git auth, so with the credential
# scrubbed the fetch dies with "could not read Username for
# 'https://github.com'" on private repos — public repos only
# survive because the anonymous fetch happens to succeed
# (anthropics/claude-code-action#1236). The App token the
# action exchanges covers API calls, not that first fetch.
# No-op on non-pnpm consumers (Rust, Bun, or anything else) —
# gated on pnpm-lock.yaml so this only fires when a pj-pnpm
# layer is actually composed in. Corepack ships with the
# runner's preinstalled Node.js and reads package.json's
# `packageManager` field to materialize the exact pinned pnpm
# version, so no extra action or version pin is needed here.
# Deliberately not `pnpm/action-setup`: this layer (pj-base) is
# language-agnostic and has no `vars.actions.pnpm_action_setup`
# pin available — that's seeded only by the optional pj-pnpm
# layer's vars.pnpm.toml, and referencing it unconditionally
# here would break Rust-only consumers that never seed it.
- if: hashFiles('pnpm-lock.yaml') != ''
run: corepack enable
- uses: anthropics/claude-code-action@v1.0.195
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# The same list `claude-review.yml.tera` carries, and for the
# same reason: asked "is this claim true?", the responder had
# no way to find out. It read the diff and reasoned, which is
# worth something and is not worth as much as running the
# thing — a review that verified by tracing missed a test
# whose assertion was satisfied by an unrelated line, and only
# caught it on the repo where it could edit a file and look.
#
# Deliberately no `git`, no `gh pr edit`, no writes: answering
# a mention must not be able to change the branch it is
# answering about. Compiling still executes `build.rs` and
# test code, so this list bounds intent rather than
# capability — which is why the job now checks who is asking.
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(cargo make:*),Bash(cargo test:*),Bash(cargo clippy:*),Bash(cargo check:*),Bash(cargo fmt:*),Bash(bun install:*),Bash(bun test:*),Bash(bun run build:*),Bash(bun run test:*),Bash(bun run lint:*),Bash(bun run check:*),Bash(bun run typecheck:*),Bash(pnpm install:*),Bash(pnpm build:*),Bash(pnpm lint:*),Bash(pnpm test:*)"