-
Notifications
You must be signed in to change notification settings - Fork 15
1124 lines (1075 loc) · 63.1 KB
/
Copy pathclaude.yml
File metadata and controls
1124 lines (1075 loc) · 63.1 KB
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
# Serialize @claude sessions per issue/PR. Multiple rapid @claude
# comments used to fire concurrent runs that all tried to push to the
# same branch; the action's git-push.sh refuses non-fast-forward
# pushes, so the loser-on-the-race silently lost its work (see PR #706
# 2026-05-19 18:31–18:39 window, where 2 of 4 races were blocked).
# `cancel-in-progress: false` means each new comment QUEUES behind the
# running session rather than starting a competing one.
#
# Known limitation — queued runs may re-handle absorbed comments:
# if comment 2 arrives while run A (triggered by comment 1) is
# active, run A's polling step (see `prompt:` below) addresses
# comment 2 in-session — but run B is also queued and will still
# start once A finishes, re-addressing comment 2 as its triggering
# event. In practice this costs at most an extra commit or a
# no-op session; the tasks `@claude` performs here are idempotent
# enough that the cost is acceptable. The alternative
# (`cancel-in-progress: true`) would throw away in-flight work,
# which is worse than the occasional duplicate.
concurrency:
# `github.event.pull_request.number` is already populated for
# `pull_request_review` and `pull_request_review_comment` events, so
# the two issue/PR-number expressions below cover all four trigger
# types. The `|| github.run_id` tail is a defensive fallback: if
# both numbers are ever undefined (shouldn't happen with the four
# `on:` triggers above), the group would otherwise collapse to
# `claude-`, serializing every session globally. Falling back to
# `run_id` keeps the unknown case from blocking unrelated PRs.
# A stray `github.event.review.pull_request_url` fallback that lived
# here previously was dropped: it's a full API URL string, not a
# number, so it would have produced a different group than the
# `claude-N` used by `issue_comment` events on the same PR.
group: claude-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: false
jobs:
claude:
# Only a HUMAN @claude mention should invoke the agent. Skip events
# whose sender is a bot, otherwise the agent self-triggers: a comment
# posted by claude[bot] (the agent's own GitHub App identity) that
# merely *contains* `@claude` — quoting a prior `@claude ...` command,
# or a "posted by @claude" signature — re-fires this workflow, which
# posts another such comment, which re-fires it again, unbounded
# (observed on PR #900, 2026-06-16). The `Acknowledge @claude mention`
# ack below is posted via GITHUB_TOKEN (github-actions[bot]), and
# GITHUB_TOKEN-authored events cannot trigger workflows — but claude[bot]
# comments are posted with the Claude GitHub App token, which DOES fire
# `issue_comment`, so guarding on the token kind is not enough; we guard
# on the author. `github.event.sender` is the actor that raised the
# event (comment/review author or issue opener) for all four trigger
# types; this mirrors the `sender.type != 'Bot' && !endsWith(actor,
# '[bot]')` double guard in claude-code-review.yml. The second clause
# is belt-and-suspenders: it also blocks a bot actor whose
# `sender.type` is somehow not reported as 'Bot' (old/misconfigured
# integrations), and keeps the two workflows idiomatically identical.
if: |
github.event.sender.type != 'Bot' && !endsWith(github.actor, '[bot]') && (
(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 == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
# `write` (not `read`) is required: besides letting Claude read CI
# results on PRs, the post-Claude steps below call `gh workflow run
# claude-code-review.yml` (the workflow_dispatch REST endpoint
# `POST /actions/workflows/{id}/dispatches`), which GITHUB_TOKEN can
# only reach with `actions: write`. With `actions: read` every
# dispatch 403s ("Resource not accessible by integration") and is
# swallowed by the `|| echo "::warning::"` fallback, so the
# code-review never auto-runs (observed on PR #900, run
# 27598978484: the @claude-review dispatch 403'd silently). `write`
# is a superset of `read`, so CI reads still work.
actions: write
# Expose the ucdavis/epi202 and ucdavis/epi204 fine-grained PATs to
# every step in this job, including the Claude action's subprocess.
# See .github/copilot-instructions.md ("Accessing the private
# ucdavis/epi202 repository" and "Accessing the private ucdavis/epi204
# repository") for usage. Empty if the secret isn't set, in which case
# the variable is simply unavailable to Claude — no error.
env:
EPI202_TOKEN: ${{ secrets.EPI202_TOKEN }}
EPI204_TOKEN: ${{ secrets.EPI204_TOKEN }}
steps:
# Post a visible acknowledgment on the triggering PR/issue BEFORE the
# multi-minute apt/R/Quarto/renv setup chain begins. Without this, a
# failure during setup (see the renv auth issue fixed in #777) leaves
# the user with no signal that the @claude mention was even received.
# `continue-on-error: true` keeps a transient comment-API hiccup from
# killing the whole workflow. The late-comment polling step in the
# `Run Claude Code` prompt below filters comments where
# `user.type == "Bot"`, so this ack — posted by github-actions[bot]
# via GITHUB_TOKEN — does not register as a new @claude request and
# cannot trigger a self-loop.
- name: Acknowledge @claude mention
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NUM: ${{ github.event.issue.number || github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh issue comment "$NUM" --repo "${{ github.repository }}" \
--body ":eyes: Picked up by [workflow run #${{ github.run_id }}]($RUN_URL). R/Quarto/renv setup runs first (~3-5 min); Claude itself responds after that."
# zizmor flags the persisted credential here (artipacked). Keeping it,
# deliberately: the @claude agent commits to the branch and a later step
# pushes it, so the credential is load-bearing. This job uploads no
# artifact, which is the leak vector artipacked is actually about.
- name: Checkout repository # zizmor: ignore[artipacked]
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Checkout submodules
run: |
git config --global url."https://x-access-token:${{ secrets.SUBMODULES_TOKEN }}@github.com/".insteadOf "https://github.com/"
git submodule update --init --recursive --depth 1
# Install system dependencies required for R packages and tools
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
jags \
libcurl4-openssl-dev \
libssl-dev \
libxml2-dev \
libfontconfig1-dev \
libharfbuzz-dev \
libfribidi-dev \
libfreetype6-dev \
libpng-dev \
libtiff5-dev \
libjpeg-dev \
libglpk-dev \
poppler-utils \
tesseract-ocr \
tesseract-ocr-eng \
maxima \
python3-pip
- name: Install SymPy (Python CAS)
run: pip3 install --break-system-packages sympy
- name: Set up Pandoc
uses: r-lib/actions/setup-pandoc@v2
- name: Set up R
uses: r-lib/actions/setup-r@v2
with:
r-version: 'release'
use-public-rspm: true
- name: Set up Quarto
uses: quarto-dev/quarto-actions/setup@v2
with:
tinytex: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install R dependencies via renv
uses: r-lib/actions/setup-renv@v2
env:
# Without GITHUB_PAT, renv can't hit the GitHub API to resolve
# GitHub-sourced packages (e.g. `ddsjoberg/gtsummary`) and
# `renv::restore()` aborts with "GitHub authentication
# credentials are not available." Matches publish.yml.
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
with:
cache-version: 1
- name: Capture PR head SHA before Claude
id: head_before
if: github.event.pull_request.number || github.event.issue.pull_request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}"
SHA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq '.head.sha')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
# Create a feature branch for issue triggers so Claude's commits
# land somewhere durable.
#
# Background: claude-code-action runs in `agent` mode here (any time
# `prompt:` is set, which we do below for the late-comment polling
# loop). Agent mode skips the action's built-in setup-branch step
# and its post-session `git-push.sh` wrapper — the machinery that
# tag mode uses to create `claude/issue-N-<timestamp>`, push, and
# link a PR back from the triggering issue. Without that machinery,
# an `@claude` mention on an issue leaves Claude editing files on
# the ephemeral `main` checkout; the commits are discarded at
# runner cleanup and no PR ever appears (see issue #786 history
# 2026-05-22: two consecutive @claude runs both produced zero
# branch and zero PR despite Claude believing it had pushed).
#
# The paired "Push branch and open draft PR for issue trigger"
# step below pushes this branch and opens the PR. PR-trigger runs
# already work today (Claude commits to the PR's own checkout
# branch and the existing post-step at the bottom of this file
# re-requests review when the head SHA changes), so this only
# fires for issue/issue-comment-on-issue events.
- name: Set up branch for issue trigger
id: issue_branch
if: |
github.event_name == 'issues' ||
(github.event_name == 'issue_comment' && !github.event.issue.pull_request)
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
BRANCH="claude/issue-${ISSUE_NUMBER}-$(date -u +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "starting_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "Created and switched to $BRANCH"
# Build a WebFetch permission list from the shared stats-allowlist repo
# so Claude can read pages from d-morrison.github.io and other approved
# sites referenced in the book. Trust model: anyone with push access to
# d-morrison/stats-allowlist@main can expand Claude's WebFetch scope on
# the next workflow run here. The hostname regex below ensures even a
# tampered entry can only widen WebFetch coverage, not inject other
# claude_args.
- name: Build allowed-tools list from stats-allowlist
id: tools
run: |
# Initialize to empty so a curl failure degrades gracefully to the
# base toolset (Bash only) rather than failing the whole @claude
# workflow with no PR feedback.
: > /tmp/allowlist.txt
curl -fsSL https://raw.githubusercontent.com/d-morrison/stats-allowlist/main/allowlist.txt \
-o /tmp/allowlist.txt \
|| echo "::warning::Could not fetch stats-allowlist; running Claude without WebFetch permissions."
# Reject anything that isn't a strictly valid hostname so a typo or
# tampered upstream entry can't inject extra claude_args via " or
# other shell metacharacters.
WEBFETCH=$(tr -d '\r' < /tmp/allowlist.txt \
| grep -v '^[[:space:]]*$' \
| grep -v '^[[:space:]]*#' \
| sed -E 's#^[[:space:]]*https?://##' \
| sed -E 's#/.*$##' \
| sed -E 's#[^A-Za-z0-9.-]+$##' \
| tr '[:upper:]' '[:lower:]' \
| grep -E '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' \
| sort -u \
| sed 's#.*#WebFetch(domain:&)#' \
| paste -sd, -)
# `Bash(gh api repos/<this-repo>/*)` is needed by the
# polling-for-new-comments prompt below: the session checks
# for late-arriving @claude comments before declaring done,
# so multi-comment bursts get handled in a single session
# instead of racing parallel runs.
#
# The pattern is scoped to the current repository's API
# surface, which covers the polling step's endpoints
# (`repos/<repo>/issues/N/comments`, `repos/<repo>/pulls/N/comments`,
# `repos/<repo>/pulls/N/reviews`) and the routine read-only
# session uses (fetching file SHAs, reading PR metadata,
# checking branch contents). It excludes cross-repo and
# cross-org `gh api` calls (`/user`, `/orgs/...`, `/repos/OTHER/...`)
# that the session doesn't need.
#
# Within the repo scope the pattern still matches write
# endpoints (`gh api -X POST/PATCH/DELETE repos/<repo>/...`),
# not just reads, because allowed-tools doesn't support
# HTTP-method filtering. The ultimate bound on what's
# possible is `GITHUB_TOKEN`'s scopes (`contents: write`,
# `pull-requests: write`, `issues: write`), not this
# allowlist.
#
# The `gh pr/issue/run` read-only CLI patterns below mirror
# the committed `.claude/settings.json` allowlist so the CI
# agent and local sessions grant the same inspection commands
# (these CLI subcommands hit the same API as `gh api` but
# under different command strings, so the `gh api repos/*`
# entry alone doesn't cover them). Kept to `view`/`list`
# (read-only) — no `gh pr merge`/`close`/`edit` etc.
GH_TOOLS="Bash(gh api repos/${{ github.repository }}/*),Bash(gh pr view *),Bash(gh pr list *),Bash(gh issue view *),Bash(gh issue list *),Bash(gh run view *),Bash(gh run list *)"
# Git commands Claude needs to commit work in agent mode.
# In tag mode the action auto-injects these; in agent mode
# (which we're in whenever `prompt:` is set) it doesn't, and
# without them Claude's `git commit` calls get denied — that
# was the root cause of the 24 `permission_denials_count` in
# issue #786's runs (Claude believed it committed but every
# commit attempt was actually blocked).
#
# Several operations are deliberately omitted from this list:
# - `git push`: the post-Claude step pushes, and keeping push
# out of Claude's hands avoids races with that step and
# prevents accidental pushes to weird branches.
# - `git checkout` for branch-switching: only the
# file-restoration form (`git checkout -- <file>`) is
# allowed. If Claude switched off the issue branch the
# pre-step prepared, the post-step's `git rev-parse HEAD ==
# STARTING_SHA` check would compare against the wrong tip
# and silently skip the push, losing Claude's work without
# warning. Pinning Claude to the branch we set up is
# safer than trying to detect-and-recover after the fact.
# - `git branch`: the broad `Bash(git branch*)` form also
# matches `git branch -D` (delete) and `git branch -f`
# (force-move). Claude doesn't need branch management in
# this workflow — the pre-step puts it on the right branch
# and the prompt tells it to stay there — and `git status`
# (allowed below) already reports the current branch name,
# covering the only legitimate read-only use case.
GIT_TOOLS="Bash(git add *),Bash(git commit *),Bash(git rm *),Bash(git mv *),Bash(git status*),Bash(git diff*),Bash(git log*),Bash(git show*),Bash(git restore *),Bash(git checkout -- *)"
# File-editing tools. claude-code-action ALREADY allows file
# reads/edits by default (see the action's configuration docs:
# "By default, Claude only has access to: File operations
# (reading, committing, editing files, ...)"), and `claude_args
# --allowed-tools` is ADDITIVE to that default rather than a
# replacement — which is why prior @claude runs committed file
# edits fine (e.g. claude[bot] commit 05318e02 on PR #843).
#
# We still list the file tools explicitly because a session
# that inspects its own --allowed-tools string and sees only
# Bash/gh/git/WebFetch entries can wrongly conclude file
# editing is disallowed and bail out — posting its diff as a
# comment instead of committing it (PR #843 run 27032044840,
# 2026-06-05: a 27-min session designed probability-based
# example rewrites, then declined to apply them claiming
# "Edit/Write require approval not configured in the current
# session's --allowed-tools list"). Naming the tools here makes
# the grant unambiguous in the string the agent can see, and
# guards against the action's additive behaviour ever changing.
# Read/Glob/Grep are listed alongside the write tools so the
# same allowlist-inspection logic can't talk a session out of
# reading or searching files either (they're read-only and,
# like the edit tools, already allowed by default).
FILE_TOOLS="Read,Glob,Grep,Edit,Write,MultiEdit"
if [ -n "$WEBFETCH" ]; then
echo "allowed=Bash(Rscript *),Bash(quarto *),Bash(curl *),${FILE_TOOLS},${GH_TOOLS},${GIT_TOOLS},${WEBFETCH}" >> "$GITHUB_OUTPUT"
else
echo "allowed=Bash(Rscript *),Bash(quarto *),Bash(curl *),${FILE_TOOLS},${GH_TOOLS},${GIT_TOOLS}" >> "$GITHUB_OUTPUT"
fi
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Install the Morrison-Lab/ai-config plugin so this agent gets the
# shared skills, commands, and hooks.
#
# Both inputs are required: adding a marketplace alone installs
# nothing. Per Claude Code's team-marketplace docs, "as of v2.1.195,
# adding the marketplace doesn't install plugins that come from an
# external source, on any path that loads plugins" — so declaring
# the marketplace in .claude/settings.json is NOT sufficient here,
# and a non-interactive CI run can never hit the trust prompt that
# mechanism depends on.
#
# This mirrors the reviewer's copy in Morrison-Lab/gha's
# .github/actions/run-claude-review-attempt/action.yml, which spells
# the same URL and plugin ref out for the same reason. Update both
# sites together.
plugin_marketplaces: |
https://github.com/Morrison-Lab/ai-config.git
plugins: |
ai-config@Morrison-Lab
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Pick up late-arriving @claude comments before declaring done.
# Background: until the workflow's `concurrency:` block was added,
# rapid @claude comments fired concurrent runs that raced to push
# to the same branch; the action's git-push.sh refuses non-
# fast-forward pushes, so the losers silently lost their work
# (PR #706 2026-05-19 18:31–18:39). The concurrency block now
# serializes runs, but a long-running session can still miss a
# comment posted while it's working. This prompt has Claude
# explicitly poll for newer @claude mentions and address them
# in the same session before finishing.
#
# claude-code-action behaviour note: when `prompt:` is set, the
# action APPENDS it (wrapped in `<custom_instructions>`) to the
# default tag-mode prompt — it does NOT replace the default.
# The default prompt already provides the triggering content via
# `<trigger_comment>` (or `<pr_or_issue_body>` for `issues`
# events) plus the formatted PR/issue body, comments, review
# comments, and changed files. The instructions below therefore
# only need to describe the polling behaviour; the request
# itself is already visible to Claude.
# (verified against
# https://github.com/anthropics/claude-code-action/blob/main/src/create-prompt/index.ts —
# see `generatePrompt`'s `if (context.githubContext?.inputs?.prompt)` branch.)
prompt: |
You were triggered by an @claude mention in
${{ github.event_name }}. Address the request in that
comment/issue/review.
**If your reply is prose (a question answered, a
recommendation, a design discussion) rather than a code
change**, write your final assistant message as if you
were posting a reply on the triggering PR/issue thread —
because that's what happens: a post-Claude workflow step
takes your last assistant message and posts it back to
the source thread when you didn't commit any code. (When
you DO commit code, the commits themselves are the
deliverable and the post-step skips posting your text to
avoid noise.) Don't write it as an internal log
("Investigated foo, found bar, will do baz") — write it
as the actual reply you want the requester to read. When the
trigger was an inline review comment on the diff (a
`pull_request_review_comment`), your reply is posted as a threaded
reply to that exact comment — keep it focused on the line(s) that
comment is about.
**Before declaring the task complete, check for additional
@claude requests that arrived after the triggering event.**
The three event types that trigger this workflow each live at
a different GitHub API endpoint, and `@claude` can appear in
any of them. Poll the endpoints that apply to your trigger
type before finishing.
This run was triggered by a `${{ github.event_name }}` event.
Pre-resolved context (you don't need to figure these out yourself):
- PR context? `${{ (github.event.pull_request.number || github.event.issue.pull_request) && 'yes' || 'no' }}`
(`issue_comment` fires for both issues and PRs; this
flag disambiguates without you guessing.)
- Entity number: `${{ github.event.issue.number || github.event.pull_request.number }}`
- Session-start SHA (the tip of the branch when your
session started): `${{ steps.head_before.outputs.sha || steps.issue_branch.outputs.starting_sha }}`
For PR triggers this is the PR head before you ran; for
issue triggers it's the tip of the freshly-created
`claude/issue-N-<timestamp>` branch. Use it as the base
for the step-5 commit-presence check below.
- **PR triggers** (`issue_comment` on a PR,
`pull_request_review`, `pull_request_review_comment`):
poll all three endpoints listed below.
- **Issue triggers** (`issue_comment` on an issue,
`issues`): poll **only** `/issues/N/comments`; the
`/pulls/...` endpoints don't apply, and the issue body
itself doesn't admit late-arriving `@claude` content
(there's typically nothing else to poll anyway).
Endpoints:
- `/issues/N/comments` — PR/issue **timeline** comments
(covers `issue_comment` events; this endpoint does NOT
contain the issue body itself, which lives at
`/issues/N` and is already supplied to you by the
action's default prompt — no need to re-fetch).
- `/pulls/N/comments` — **inline review-thread** comments
on a PR (covers `pull_request_review_comment`).
- `/pulls/N/reviews` — PR **review submissions**; the
`body` field here is where `@claude` lives for a
`pull_request_review` trigger.
1. Fetch each endpoint (skip the `/pulls/...` ones for
`issues` triggers — the GitHub-Actions expression
`${{ github.event.issue.number || github.event.pull_request.number }}`
is already substituted in below):
(the URLs are NOT quoted — they contain no shell-special
characters after substitution, and the allowed-tools pattern
`Bash(gh api repos/<repo>/*)` only matches the unquoted form;
a leading quote like `gh api 'repos/...` fails the match and
the call is denied — see PR #806 comment 4539906359):
```
gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number || github.event.pull_request.number }}/comments --paginate
gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number || github.event.pull_request.number }}/comments --paginate # PRs only
gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number || github.event.pull_request.number }}/reviews --paginate # PRs only
```
2. From the combined response, identify entries where ALL of:
- the relevant timestamp (`created_at` for comments,
`submitted_at` for reviews) is strictly greater than
the triggering event's timestamp. The fallback chain
below picks the most-specific signal available for
each event type:
(`${{ github.event.comment.created_at || github.event.review.submitted_at || (github.event_name == 'issues' && github.event.action == 'opened' && github.event.issue.created_at) || github.event.issue.updated_at }}`)
Per the current `on:` triggers, that resolves to:
`comment.created_at` for `issue_comment` and
`pull_request_review_comment`; `review.submitted_at`
for `pull_request_review`; `issue.created_at` for
`issues:opened`; and the final `issue.updated_at`
fallback for `issues:assigned`. The chain is a
`||`-cascade, so any future event type added to
`on:` without matching logic will also land on
`issue.updated_at` — reassess this expression when
changing the trigger list.
- the relevant text field (`body` for comments and
reviews) contains `@claude`
- `user.type` is not `"Bot"` — this generically excludes
GitHub App accounts (claude[bot], github-actions[bot],
dependabot[bot], etc.) and prevents the polling loop
from being driven by an arbitrary bot that learns to
say `@claude`. A human who has somehow set their
`user.type` to `Bot` would also be excluded, but that
isn't a real scenario.
Note: for `issues` events the triggering timestamp falls
back to `issue.updated_at`, which is a coarse signal
(any update — assignment, label change, etc. — bumps it).
Comments posted in the same second as another issue
update could be missed; this is rare but not impossible.
3. If any matching entries exist, address each one in this
same session (in chronological order), then repeat from
step 1. Stop when no new @claude requests remain, or
after **at most 5 additional iterations of steps 1–3**
(i.e. the cap applies to the polling loop, not to the
initial task you were triggered for) — whichever comes
first. The cap prevents a pathological ping-pong
between a user (or two bots) and this session from
keeping the run alive indefinitely; if you hit the cap
with new comments still arriving, post a comment saying
so and exit, and the next workflow run will pick up
from there via the serialize-per-PR concurrency group.
4. **Commit all changes before finishing — this is the
single most common way these runs silently fail.**
Editing files with Edit/Write changes the working tree
but does NOT change the git history; only `git add` +
`git commit` does. If you describe work as "done" in a
comment but never committed, the post-step sees the
PR head SHA unchanged and your work is lost when the
runner is reclaimed. The user has no way to recover it.
`git add`, `git commit`, `git rm`, and read-only git
inspection commands are in your allowed-tools list;
`git push` is NOT, and any attempt to call it will
fail with a permission denial. **Do not attempt
`git push`** — a post-Claude workflow step performs
the push for you:
- For PR triggers, your commits go on the PR's head
branch — the post-step re-requests review and
dispatches code review when the head SHA moves.
- For issue triggers, the workflow pre-creates a
`claude/issue-N-<timestamp>` branch and checks it
out before your session starts; the post-step
pushes that branch and opens a draft PR linking
back to the issue. (Do not create a branch yourself
— one is already checked out for you. Do not try to
`gh pr create` yourself — the post-step does it.)
The concurrency group guarantees no parallel session
is racing yours, so the push should succeed unless
someone pushed by hand.
5. **Pre-finish self-check (mandatory).** Immediately
before you stop, run these and verify the output
(substitute the Session-start SHA from the context
block above for `<START>`):
```
git status --porcelain # MUST be empty
git log --oneline <START>..HEAD # check whether you committed
```
If `git status --porcelain` shows ANY output, you have
uncommitted edits — stage and commit them before
stopping. If you intended to make code changes but the
`git log <START>..HEAD` range is empty, you have NOT
done the task — the edits live only in the working
tree and will be discarded. Treat this as a task
failure: commit the work, or if you've decided not to
change anything, say so explicitly in your final
comment ("no changes needed because X") rather than
describing edits that weren't committed.
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# Allow the pre-commit checklist tools from CLAUDE.md (Rscript for
# lint/spell-check, quarto render for chapter previews), curl for
# downloading source PDFs too large for WebFetch (issue #740), the
# gh CLI for the polling step in the prompt above, plus WebFetch
# for every host in the shared stats-allowlist.
claude_args: '--allowed-tools "${{ steps.tools.outputs.allowed }}"'
# Fetch the PR's head SHA once, post-Claude, for the two
# downstream steps that both need it: the prose-post step's
# COMMITTED check and the "Re-request review" step's
# SHA_AFTER comparison. Without this shared step each would
# call `gh api pulls/$N` independently — two round-trips per
# PR-trigger run where one suffices. PR-context only (mirrors
# the "Capture PR head SHA before Claude" step above).
- name: Capture PR head SHA after Claude
id: head_after
if: always() && (github.event.pull_request.number || github.event.issue.pull_request)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}"
SHA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq '.head.sha')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
# Post Claude's final assistant message back to the source thread
# when no code was committed.
#
# Background: claude-code-action runs in `agent` mode whenever
# `prompt:` is set (we set it for the late-comment polling loop
# above). Agent mode skips the action's built-in machinery that
# tag mode uses to surface Claude's final response as a sticky
# GitHub comment — the action just runs the agent and assumes
# the workflow author handles output surfacing. We already added
# explicit pre/post steps for the analogous setup-branch +
# git-push gap (#788 / #794); this step closes the remaining
# gap for **prose** responses, where Claude's deliverable is
# text rather than commits and there's nothing else to surface.
# See PR #755 comment 4530859000 (2026-05-25): a math question
# ran a successful 60s / 16-turn Claude session that produced
# ~$0.19 of thinking and zero visible output, because the
# response lived in /home/runner/work/_temp/claude-execution-output.json
# until the runner tore down.
#
# When Claude HAS committed code, the commits themselves convey
# the work and the existing `Re-request review` / `Push branch
# and open draft PR` steps surface it; in that case posting
# Claude's running text on top would be noise — skipped via
# the COMMITTED check below.
- name: Post Claude's response if no code was committed
# The `steps.claude.outcome == 'success'` guard is deliberate:
# we only surface a *completed* session's response. A
# cancelled session (concurrency preemption — another run is
# taking over and will post its own response) or a timed-out
# one (partial, possibly-misleading output) is intentionally
# NOT posted. If surfacing partial output on cancellation
# ever proves useful, broaden to `!= 'skipped'` with a
# "(session interrupted)" footer.
#
# Skip on `@claude review` triggers that have PR context — the
# dispatch step below will fire claude-code-review.yml, which
# posts its own sticky review comment. Posting Claude's
# agent-session prose on top would double-comment the PR
# (the agent's internal "I'll review this PR" monologue, then
# the real review).
#
# The PR-context guard (`pull_request.number || issue.pull_request`)
# is load-bearing: without it, `@claude review` on a plain
# *issue* would skip the prose-post AND skip the dispatch
# (which has its own PR-context guard), losing the response
# entirely — the exact bug this PR fixes, re-introduced for
# this one trigger pattern. With the guard, plain-issue
# triggers fall through to the prose-post regardless of body.
#
# The `@claude review` predicate MUST stay in sync with the
# dispatch step's `if:` predicate. Note: `contains()` is
# case-sensitive, so `@Claude review` would not match here
# (or in the dispatch step). Standard GitHub `@`-mentions
# are lowercase so this matches the common case.
if: |
always() &&
steps.claude.outcome == 'success' &&
!(
(
contains(github.event.comment.body, '@claude review') ||
contains(github.event.review.body, '@claude review')
) &&
(github.event.pull_request.number || github.event.issue.pull_request)
)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENTITY_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
ISSUE_BRANCH_STARTING_SHA: ${{ steps.issue_branch.outputs.starting_sha }}
PR_HEAD_BEFORE: ${{ steps.head_before.outputs.sha }}
PR_HEAD_AFTER: ${{ steps.head_after.outputs.sha }}
run: |
# Did Claude commit anything? Two cases:
# - Issue triggers: the pre-step put us on a fresh
# `claude/issue-N-*` branch and recorded its tip in
# ISSUE_BRANCH_STARTING_SHA. Local HEAD vs. that SHA
# tells us whether Claude committed.
# - PR triggers: PR_HEAD_BEFORE captured the PR's remote
# head SHA pre-Claude; PR_HEAD_AFTER (from the shared
# "Capture PR head SHA after Claude" step) is the head
# now. If they differ, Claude pushed. (Local HEAD is
# `main` on PR triggers — actions/checkout pulls the
# default branch for `issue_comment` events — so the
# local-SHA check from the issue branch doesn't apply
# here.)
COMMITTED=false
if [ -n "$ISSUE_BRANCH_STARTING_SHA" ]; then
CURRENT_LOCAL=$(git rev-parse HEAD)
if [ "$CURRENT_LOCAL" != "$ISSUE_BRANCH_STARTING_SHA" ]; then
COMMITTED=true
fi
fi
# Require a non-empty PR_HEAD_AFTER: if the "Capture PR head
# SHA after Claude" step's `gh api` failed (transient 5xx,
# rate limit), its output is "". Without the `-n` guard,
# "" != "$PR_HEAD_BEFORE" would be true and we'd wrongly
# conclude Claude committed and SKIP posting the prose —
# the exact data-loss this step exists to prevent. Defaulting
# to "not committed" on an unknown AFTER SHA errs toward
# posting (safe) rather than swallowing (lossy).
if [ -n "$PR_HEAD_BEFORE" ] && [ -n "$PR_HEAD_AFTER" ] && [ "$PR_HEAD_AFTER" != "$PR_HEAD_BEFORE" ]; then
COMMITTED=true
fi
if [ "$COMMITTED" = "true" ]; then
echo "Claude committed code; commits are the deliverable, skipping response-text post."
exit 0
fi
# Locate the execution-output JSON. Prefer the action's
# exported step output; fall back to the known default path
# in case the action version changes the output name.
FILE="${EXECUTION_FILE:-/home/runner/work/_temp/claude-execution-output.json}"
if [ ! -f "$FILE" ]; then
echo "::warning::No Claude execution output found at $FILE; nothing to post."
exit 0
fi
# Extract the assistant events from the execution-output JSON.
#
# Format robustness: the action's execution_file is a single
# JSON array of event objects (`[{...},{...}]`), NOT
# newline-delimited JSON as an earlier version of this step
# assumed. With `jq -s` (slurp), a single-array file becomes
# `[[...]]`, so a bare `.[] | select(.type==...)` indexes the
# inner *array* with `.type` and dies with "Cannot index array
# with string" (see run 26417479726, the first real run of
# this step after #805 merged). `flatten(1)` collapses that
# outer wrap so we iterate event objects either way — and it
# also tolerates true NDJSON (slurps to a flat array,
# flatten is a no-op) and a stray array element. The
# `type=="object"` guard then skips any non-object entry
# before `.type` is accessed (jq's `and` short-circuits).
# The same `flatten(1) | select(type=="object" ...)` prelude
# is repeated in the RESPONSE filter below — keep them in sync.
# Sanity-check that the file actually contains assistant-typed
# events. If it doesn't, the likely cause is an upstream
# schema change (the action renaming the event type), not a
# genuinely empty session — surface that distinctly rather
# than silently posting nothing.
ASSISTANT_COUNT=$(jq -rs 'flatten(1) | [.[] | select(type == "object" and .type == "assistant")] | length' < "$FILE")
if [ "$ASSISTANT_COUNT" = "0" ]; then
echo "::warning::No assistant-typed events in $FILE — the action's output schema may have changed (expected .type == \"assistant\"). Nothing to post."
exit 0
fi
# Grab the LAST assistant message's text-typed content blocks
# (a single message may interleave text and tool_use blocks;
# we want only the prose).
#
# ...with one carve-out. Since the ai-config plugin started
# being installed here (#1076), the agent follows that
# corpus's flag-session-boundaries.md, which requires the
# LAST message it writes to be a `**Stopping Point**`
# declaration. This step also wants the last message. Two
# rules, one slot, and the declaration always wins because it
# is by construction written last -- so every prose reply was
# being replaced by a one-line status marker and the actual
# answer was discarded, unrecoverably (the run log does not
# carry the conversation, and no execution-file artifact is
# published). See #1081; the run that first diagnosed this
# had its own diagnosis swallowed the same way.
#
# So: when the final message leads with such a declaration, post
# everything from the last substantive message onward, keeping
# both. A message that merely *ends* with a stopping-point line
# does not lead with one, so it still posts unchanged.
#
# The selection is a SLICE-AND-JOIN rather than a pick, and that
# is the point. Any rule for "is this message just a
# declaration?" will misjudge some message, and the two ways of
# being wrong are not symmetric: including a message that did not
# need including costs some redundant text, visibly, whereas
# excluding one that did costs the answer, silently and
# unrecoverably. Joining the whole tail can only ever add text,
# never drop it, so a misjudgement degrades into noise instead of
# data loss. Do not "optimize" this back into picking one message.
#
# This was briefly two deliberately different tests. It is now
# exactly ONE, `is_decl`, a prefix match, used for both jobs:
#
# - gating whether to look back at all. A false positive here
# starts a slice at this same message; a false negative posts
# the declaration alone and drops the answer behind it.
# - choosing where the slice STARTS. A message that looks like a
# declaration is never chosen as the start, so a false
# positive only moves the start EARLIER and includes more.
#
# An earlier revision refined the second job with "single
# paragraph, or under 400 characters", to trim redundant leading
# context. That was a mistake, and an instructive one: it made the
# test non-eager, so a long multi-paragraph declaration failed it,
# got treated as substantive, became the slice start, and excluded
# a real answer sitting behind it -- the exact loss this design is
# built to prevent, reintroduced by an optimization for tidiness.
# A single eager test is what makes "the slice only ever grows"
# actually true rather than merely intended. Any refinement here
# must preserve that property, which means it may only ever match
# MORE messages, never fewer.
#
# The marker tolerates leading blockquote, heading, and list
# decoration (including numbered lists) and makes the bold
# markers optional. The convention spells it `**Stopping Point**`,
# but `> **Stopping Point**`, `## Stopping Point`, and
# `1. **Stopping Point**` are the same declaration, and failing to
# recognize one reproduces exactly the bug this fixes.
#
# The slice steps over a RUN of declarations, not just one,
# because the polling loop in the prompt above can produce a
# second declaration right after the first (the poll finds no new
# @claude requests, and the agent re-declares).
#
# An empty-text final turn matches nothing, so it still yields ""
# and still trips the existing "nothing to post" warning below.
RESPONSE=$(jq -rs '
def is_decl($m): test($m; "i");
"^[\\s>#*+.)0-9-]*\\*{0,2}Stopping Point\\*{0,2}" as $mark
| flatten(1)
| [ .[]
| select(type == "object" and .type == "assistant")
| (.message.content // [])
| map(select(.type == "text") | .text)
| join("\n\n")
] as $texts
| if ($texts | length) == 0 then ""
elif ($texts[-1] | is_decl($mark) | not) then $texts[-1]
else
( [ range(0; $texts | length)
| select($texts[.] != ""
and (($texts[.] | is_decl($mark)) | not))
] | last ) as $start
| (if $start == null then $texts else $texts[$start:] end)
| [ .[] | select(. != "") ]
| join("\n\n")
end
' < "$FILE")
if [ -z "$RESPONSE" ] || [ "$RESPONSE" = "null" ]; then
echo "::warning::Assistant events present but no text content; nothing to post."
exit 0
fi
# GitHub comment bodies are capped at 65,536 *bytes* in the
# stored value, so truncate by bytes (not characters). 60k
# leaves ~5.5k headroom for the footer.
#
# Both the guard AND the truncation are byte-based:
# - Guard: `wc -c` counts bytes. Bash's `${#var}` counts
# characters under UTF-8 locales (the default on GitHub
# runners), so a response with 30k Chinese characters
# (~90k bytes) would not trip a 60k character guard,
# and the truncation below would never fire — but the
# subsequent `gh issue comment` would 422 on the
# oversized body. Counting bytes here closes that hole.
# - Truncation: `head -c` is strictly byte-based, so the
# cut respects the 65,536-byte cap.
# Caveat: a byte cut may slice the final character mid-
# codepoint, producing a garbled trailing byte sequence
# right before the footer. Acceptable for a corner case
# (responses >60k bytes are rare); the footer still
# renders and the full text is one click away in the
# workflow run log.
MAX_LEN=60000
BYTE_LEN=$(printf '%s' "$RESPONSE" | wc -c)
if [ "$BYTE_LEN" -gt "$MAX_LEN" ]; then
RESPONSE="$(printf '%s' "$RESPONSE" | head -c "$MAX_LEN")
… (response truncated at ${MAX_LEN} bytes; full text in the [workflow run](${RUN_URL}))"
fi
BODY="$(printf '%s\n\n<sub>— posted by @claude post-step from [workflow run](%s)</sub>\n' "$RESPONSE" "$RUN_URL")"
# When the trigger was an inline review comment, reply IN-THREAD to
# it (pulls/.../comments/<id>/replies) so the conversation stays
# anchored to the diff line. Every other trigger gets a top-level
# `gh issue comment` (issue and PR-conversation comments share the
# same endpoint). Fall back to top-level if the reply API fails
# (e.g. the parent review comment was deleted).
#
# The /replies endpoint requires the THREAD-ROOT comment id; a
# reply id returns 422. When the trigger is itself a reply,
# `in_reply_to_id` holds the root id (it is null at the thread
# root), so prefer it and fall back to `comment.id`.
REPLY_TARGET_ID="${{ github.event.comment.in_reply_to_id || github.event.comment.id }}"
if [ "${{ github.event_name }}" = "pull_request_review_comment" ]; then
if jq -n --arg b "$BODY" '{body: $b}' \
| gh api --method POST "repos/${{ github.repository }}/pulls/$ENTITY_NUMBER/comments/$REPLY_TARGET_ID/replies" --input - >/dev/null; then
exit 0
fi
echo "::warning::In-thread reply failed; falling back to a top-level comment."
fi
gh issue comment "$ENTITY_NUMBER" --body "$BODY" \
|| echo "::warning::Could not post Claude's response back to the source thread."
# Dispatch the dedicated reviewer workflow when the triggering
# comment looks like an explicit review request.
#
# Background: `claude-code-review.yml` is the dedicated reviewer
# (sticky-comment + delete-prior-sticky pattern, inline review
# threads, etc.) and only triggers on `pull_request` events
# (opened/synchronize/ready_for_review/reopened) and explicit
# `workflow_dispatch`. `issue_comment` is NOT in its trigger
# list. So when a user types "@claude review" on a PR, the
# event fires *this* workflow (an agent-mode run that emits
# prose via the post-step above) rather than the reviewer.
# See PR #802 comment 4530845824 (2026-05-25): "@claude review"
# produced a 125s agent session with no visible review output
# — nothing was dispatched, nothing was posted.
#
# The earlier `Re-request review and dispatch code review if
# Claude pushed commits` step below dispatches the reviewer
# only when Claude pushes new commits — useful for the
# iterate-on-the-diff loop, but it doesn't cover the case where
# the user just wants a fresh review without code changing
# hands. This step closes that gap by dispatching the reviewer
# whenever the triggering comment body literally contains
# `@claude review`. Both paths can fire on the same run if a
# `@claude review` comment also produces commits; the
# reviewer's own concurrency group cancels the duplicate so
# only the freshest diff is reviewed.
#
# `contains()` is a SUBSTRING match (not word-boundary): bodies
# like `@claude review again` or `@claude reviewer` also match.
# That's intentionally lenient — the cost of a spurious match is
# one extra reviewer run, which the reviewer's concurrency group
# cancels. Do NOT tighten this to `startsWith` or a word-boundary
# check without re-checking the paired skip-predicate on the
# prose-post step above, which must stay in sync.
#
# No `steps.claude.outcome == 'success'` guard here (unlike the
# prose-post step): a review dispatch doesn't depend on the
# agent session's output, so we want it to fire on `@claude
# review` even if the agent step was skipped or cancelled
# (e.g., a concurrency cancellation). The reviewer reviews the
# PR's current diff regardless of what the agent run did.
- name: Dispatch claude-code-review.yml on @claude review comment
if: |
always() &&
(github.event.pull_request.number || github.event.issue.pull_request) &&
(
contains(github.event.comment.body, '@claude review') ||
contains(github.event.review.body, '@claude review')
)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}"
echo "Dispatching claude-code-review.yml for PR #$PR_NUMBER (@claude review comment)."
gh workflow run claude-code-review.yml -f pr_number="$PR_NUMBER" \
|| echo "::warning::Could not dispatch claude-code-review.yml; review will not auto-run."
- name: Re-request review and dispatch code review if Claude pushed commits
if: always() && (github.event.pull_request.number || github.event.issue.pull_request) && steps.head_before.outputs.sha != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA_BEFORE: ${{ steps.head_before.outputs.sha }}
# Reuse the SHA already fetched by "Capture PR head SHA after
# Claude" rather than calling `gh api pulls/$N` a second time.
SHA_AFTER: ${{ steps.head_after.outputs.sha }}
run: |
PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}"
echo "before=$SHA_BEFORE after=$SHA_AFTER"
# Require a non-empty SHA_AFTER: if "Capture PR head SHA
# after Claude" failed, its output is "" and a bare
# `"" != "$SHA_BEFORE"` would be true, dispatching a
# spurious review against an unchanged diff. Defaulting to
# "no new commits" on an unknown AFTER SHA errs toward not
# dispatching (cheap miss) rather than a wasted review run.
if [ -n "$SHA_AFTER" ] && [ "$SHA_AFTER" != "$SHA_BEFORE" ]; then
echo "Claude pushed new commits; re-requesting review and dispatching code review."
gh api -X POST \
"repos/${{ github.repository }}/pulls/$PR_NUMBER/requested_reviewers" \
-f "reviewers[]=d-morrison" || true
# Fire claude-code-review.yml via workflow_dispatch. GITHUB_TOKEN
# may trigger workflow_dispatch (unlike push, which is blocked to
# avoid recursion) — but ONLY with `actions: write` in this job's
# `permissions:` (see the note there); with `actions: read` the
# dispatch 403s silently. The review workflow's own `concurrency`
# group then cancels any in-flight review for this PR so the
# freshest diff wins.
gh workflow run claude-code-review.yml -f pr_number="$PR_NUMBER" || \
echo "::warning::Could not dispatch claude-code-review.yml; review will not auto-run."
else
echo "No new commits on PR head; skipping re-request and review dispatch."
fi
# Pair to "Set up branch for issue trigger" above. Pushes the
# branch Claude was working on and opens a draft PR. Runs even
# on Claude failure (`always()`) so that any partial work is
# preserved on a branch rather than discarded with the runner —
# the user can then decide whether to keep it. The `branch !=