-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaction.yml
More file actions
2790 lines (2560 loc) · 207 KB
/
Copy pathaction.yml
File metadata and controls
2790 lines (2560 loc) · 207 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"
description: "Runs Claude Code for pull-request review, issue triage, or interactive @claude assistance using shared org-wide prompts and per-mode tool allowlists."
author: "ExaDev"
branding:
icon: "check-circle"
color: "orange"
inputs:
mode:
description: "Which behaviour to run: review, triage, interactive, or generic. Defaults to interactive -- real consumers of this action's own previous design relied on that default, invoking interactive mode without ever passing mode explicitly."
required: false
default: "interactive"
# Entity-number overrides. All three modes resolve which pull request or issue they're running against from the GitHub event context automatically (github.event.pull_request.number / github.event.issue.number) -- these exist only so a caller with an unusual trigger (a workflow_dispatch, a matrix job with no direct event binding) can supply the number explicitly instead. Leave unset in the common case; an explicit value always wins over auto-detection where both are available, everywhere this action resolves an entity number for an API read or a comment. It does NOT extend to the review-mode automatic fix pass's own git operations ("Prepare fix pass" and downstream): those need a real git branch and repository, not just a number, which only a genuine pull_request event provides -- pr_number alone cannot stand in for that without an extra gh pr view lookup this action does not make, so the fix pass skips loudly, with an explicit message, rather than silently misusing an empty value.
pr_number:
description: "Pull request number. Optional -- auto-detected from the triggering event when unset. Only needed when the event itself carries no pull_request context (e.g. workflow_dispatch)."
required: false
default: ""
issue_number:
description: "Issue number. Optional -- auto-detected from the triggering event when unset. Only needed when the event itself carries no issue context (e.g. workflow_dispatch, a matrix-driven batch run)."
required: false
default: ""
triage_label:
description: "Label that triggers re-triage when applied to an issue."
required: false
default: "needs-triage"
update_issue_body:
description: "Triage mode only. Append the triage summary to the issue description."
required: false
default: "true"
prompt_dir:
description: "Override the prompt directory this run loads from (normally derived from mode). Rarely needed -- for testing a fragment set without changing mode."
required: false
default: ""
prompt_file:
description: |
Generic mode only, and required by it. Path to a file on the runner whose full content becomes
this run's ENTIRE prompt, verbatim -- no prompts/shared/base.md universal preamble, no mode
fragment, no generated "This run" facts block. A deliberate full bypass of "Compose prompt"
below, for a caller that has already built its own complete prompt and wants nothing this action
would otherwise add or infer. Same philosophy as claude_args's own full-override escape hatch:
for a caller building its own fully custom invocation, not for ordinary tuning. resume_sessions
defaults to true and is mode-agnostic: it resumes whichever session file was most recently
modified under this job's own local cache directory, with no awareness of mode, entity, or
prompt_file -- calling generic mode more than once in the same job will silently feed an earlier
call's unrelated conversation into the next one's context unless you set resume_sessions: false
on each call.
required: false
default: ""
# Authentication. At least one of these must be supplied. Each accepts one value, or several newline-separated values to fall through to the next if an earlier one fails -- see the README's "Credential fallback" section.
anthropic_api_key:
description: "Anthropic API key. Supply this or claude_code_oauth_token. One value, or several newline-separated values for fallback."
required: false
default: ""
claude_code_oauth_token:
description: "Claude Code OAuth token. Supply this or anthropic_api_key. One value, or several newline-separated values for fallback."
required: false
default: ""
retry_on_rate_limit:
description: |
When a credential attempt fails with a 429 that looks like a genuine short-lived rate limit rather than a hard quota exhaustion (its error message names a multi-day-scale reset such as a weekly or monthly limit, which retrying cannot fix within this job), wait rate_limit_retry_wait_seconds and retry that same credential once before falling through to the next configured credential (or giving up). This is distinct from the credential-fallback chain above: fallback moves to a different account immediately regardless of cause, which is the right response to a quota exhaustion (a different wait will not help this one) but wastes a perfectly good, momentarily-throttled credential on a transient rate limit that a short wait would have cleared. On by default. This is a bounded, single retry per credential slot -- like the fallback chain itself, a composite action has no native loop, so "retry the same credential N times" is not something a single input can scale arbitrarily; one extra attempt per slot is what the static step graph below actually provides. Set to "false" to restore the previous behaviour (fall through immediately on any failure, genuine rate limit or not).
required: false
default: "true"
rate_limit_retry_wait_seconds:
description: "Seconds to wait before retrying the same credential once, when retry_on_rate_limit judges a failure to be a genuine short-lived rate limit. Default 60. Ignored when retry_on_rate_limit is false."
required: false
default: "60"
# Passthrough inputs. These keep the upstream anthropics/claude-code-action names so a caller who already knows the upstream action does not have to learn new ones.
github_token:
description: "GitHub token used for GitHub API calls. Defaults to the job's own token."
required: false
default: ""
settings:
description: "Claude Code settings, either a JSON string or a path to a settings JSON file in the calling repository."
required: false
default: ""
trigger_phrase:
description: "Phrase that invokes interactive mode. The reusable workflow gates on this before the job starts."
required: false
default: "@claude"
assignee_trigger:
description: "Upstream passthrough for its own assignee-based trigger detection. This action always sets prompt (see 'Compose prompt' in action.yml), which puts the upstream action into agent mode regardless of this input's value -- agent mode never runs upstream's own trigger detection, only tag mode does. Exposed only because it's a harmless passthrough, not because it does anything here (same situation as track_progress/use_sticky_comment below)."
required: false
default: ""
label_trigger:
description: "Upstream passthrough for its own label-based trigger detection. Same situation as assignee_trigger directly above -- has no effect through this action, since agent mode (which this action always uses) never runs upstream's own trigger detection."
required: false
default: ""
allowed_bots:
description: "Comma-separated bot usernames permitted to invoke this action, or '*' for all bots. Empty allows no bots. No spaces around commas -- entries are matched exactly, and GitHub Actions expressions have no trim()."
required: false
default: ""
allowed_non_write_users:
description: |
Comma-separated usernames permitted to invoke this action without repository write access, or '*' for all users.
Leave unset so that only users with write access can trigger a run. Widening this is a deliberate per-repository
decision, not a default: it lets untrusted issue and comment text reach the model.
required: false
default: ""
# Behaviour specific to review depth, beyond the passthrough inputs above.
include_ci_logs:
description: |
Review mode only. When true, also grant the CI-inspection tools so a review can read failing workflow logs
for the pull request. Requires the calling job to hold 'actions: read'. Ignored in triage and interactive modes.
required: false
default: "false"
model:
description: "Model passed to Claude Code as --model: an alias (sonnet, opus, haiku) or a full model name. Defaults to sonnet rather than leaving this unset, since Claude Code's own unset-model default depends on the authenticating account's plan and isn't something this action controls or can predict."
required: false
default: "sonnet"
extra_claude_args:
description: |
Additional Claude Code CLI arguments appended after the arguments this action derives from the mode.
Use it for things like --max-turns. Passing --allowedTools here adds to, rather than replaces, the mode allowlist.
required: false
default: ""
claude_args:
description: |
Full override: when set, replaces this action's own mode-derived --allowedTools/--max-turns/--model
arguments (and, in review mode, the session-resume --resume flag) entirely, rather than adding to them
the way extra_claude_args does. For a caller building its own fully custom invocation -- its own prompt
file via --prompt-file, its own tool allowlist, its own turn limit -- not for ordinary tuning, which
extra_claude_args covers without giving up this action's own mode-derived safety allowlist. Real
consumers of this action's own previous design rely on this exact override semantic.
required: false
default: ""
resume_sessions:
description: |
All modes. Cache Claude Code session files (only ~/.claude/projects/ — never credentials or auth state) across
workflow runs and resume the prior session for the same pull request, issue, or branch. On by default.
A first run for an entity is a cache miss and starts a fresh session; each later run restores the most
recent session and passes --resume <id> to Claude Code, so prior context carries forward. Set "false" to
disable both restore and save. Fork pull requests cannot save caches (GitHub blocks this), so fork PRs
always start fresh. Do not also pass --resume in extra_claude_args when this is on.
required: false
default: "true"
# Progress and comment behaviour. Pure passthrough to the upstream inputs of the same name; defaults match upstream's own so an unset input behaves exactly as calling upstream directly would.
track_progress:
description: "Post a visible progress-tracking comment while Claude works. Most useful for interactive mode, where a human is waiting on a response."
required: false
default: "false"
use_sticky_comment:
description: "Deliver all of a run's comments through one continuously-updated comment instead of a new comment each time."
required: false
default: "false"
post_progress_comment:
description: |
All modes. Post a plain "Claude is working on this" issue comment via `gh api` before the run starts, then edit it once the run (and any optional follow-up pass) finishes -- independent of, and unaffected by, track_progress/use_sticky_comment above: those are pure passthroughs to upstream's own tracking-comment mechanism, which only its tag mode implements, and every mode this action runs always sets `prompt` (see "Compose prompt" below), which upstream's own agent mode requires and tag mode has no use for. Needs no special token identity -- just whatever issues:write/pull-requests:write scope the calling job already grants for its own mode -- so it works in both the reusable-workflow and direct forms. On by default, since a caller with no comment at all otherwise has no visible signal a run has started until the mode's own output (a review, a triage comment, an interactive reply) lands, which for a slow run (CI-log inspection, a fix pass) can be several minutes; set "false" to restore the previous silent-until-output behaviour.
required: false
default: "true"
include_fix_links:
description: "Review mode only. Include 'Fix this' links in review findings that open Claude Code with context to fix the identified issue. Ignored in triage and interactive modes."
required: false
default: "true"
classify_inline_comments:
description: "Review mode only. Buffer inline comments without confirmed=true and classify them (real review vs test/probe) before posting after the session ends. Set to 'false' to post all inline comments immediately (pre-buffering behavior). Ignored in triage and interactive modes."
required: false
default: "true"
include_suggestions:
description: |
Review mode only. Whether review comments carry apply-able GitHub suggestion blocks (the fenced ```suggestion code that renders an "Apply suggestion" button) for findings with a confident, drop-in fix. On by default; set to "false" for prose-only reviews. Ignored in triage and interactive modes.
required: false
default: "true"
resolve_stale_threads:
description: |
Review mode only. On a re-review (the pull request already has a review from this bot on an earlier commit), resolve the bot's own prior review threads whose findings the new revision has addressed, update ones whose finding still holds but whose comment text has drifted out of sync with the diff, and dismiss the bot's own prior CHANGES_REQUESTED or APPROVED reviews regardless of what the new review's own verdict turns out to be -- a stale review from an earlier commit is never a verdict on the current one, whichever direction it pointed, so stale comments and stale reviews alike do not accumulate across pushes. Dismissal also clears the review's own body first, since GitHub's dismissal only changes a review's state and otherwise leaves its write-up fully visible on the timeline -- this runs against every prior review on this pass, including one already left DISMISSED with an unstripped body by an earlier run of this bot before this behaviour existed, so upgrading retroactively cleans up old runs' clutter too, not just new ones. On by default. Adds Bash(gh api:*) and mcp__github__resolve_review_thread to the review allowlist when on -- that widens what a subverted review could do within the token's pull-requests scope: nothing at the API layer restricts a gh api:* call to reviews or comments the bot itself authored, only the prompt's own filter-by-login step does, so set this to "false" on repositories that accept untrusted fork pull requests (see the README's security notes, which also covers why this grant is not restricted to the bot's own reviews at the API layer). Ignored in triage and interactive modes.
required: false
default: "true"
fix_pr_metadata:
description: |
Review mode only. Check whether the pull request's title and description accurately and completely describe the change and, if not, correct them directly with mcp__github__update_pull_request -- a vague title, an empty description, or an unfilled template gets rewritten from the diff; a title and description that already do the job are left untouched. On by default. Adds mcp__github__update_pull_request to the review allowlist when on (mark_draft_if_unready, below, also grants this same tool, independently) -- the prompt restricts its own use of that tool to the title and body fields, but the tool itself also accepts state (can close the pull request), base, draft, maintainer_can_modify, and reviewers, and the allowlist grants the whole tool, not a title/body-only subset of it, so that restriction is enforced by the prompt alone, not by anything technical. Unlike resolve_stale_threads this needs no special token identity and works in both the reusable-workflow and direct forms, but treat it as at least as sensitive: set this to "false" on repositories that accept untrusted fork pull requests (see the README's security notes). Ignored in triage and interactive modes.
required: false
default: "true"
mark_draft_if_unready:
description: |
Review mode only. Off by default, unlike fix_pr_metadata above, even though it shares the same underlying tool: check whether the pull request itself is genuinely unfinished -- an explicit "WIP"/"do not merge"/"not ready for review" marker in the title, description, or commits, or a diff that is self-evidently incomplete (a stub body blocking the change's own stated purpose, unresolved merge-conflict markers, a checkpoint clearly mid-edit) -- and, only then, convert it to draft with mcp__github__update_pull_request, setting draft: true and nothing else. This is never triggered by ordinary review findings: a complete pull request with bugs, missing tests, or Should-fix/Blocker comments is reviewed normally and stays exactly as it was, not converted to draft -- draft is about the author evidently still being mid-work, not about code quality. It never sets draft: false; converting a draft back to ready stays a human decision. Off by default because, unlike a title/description correction, this changes the pull request's own review-request state and visibility, and on a repository that accepts untrusted fork pull requests a subverted review could abuse it to pull a pull request out of reviewers' visible queues -- leave this false there even if fix_pr_metadata is on. Adds mcp__github__update_pull_request to the review allowlist when on, independently of fix_pr_metadata (only one grant is added if both are on). Ignored in triage and interactive modes, and a no-op on a pull request that is already draft.
required: false
default: "false"
verify_prior_findings:
description: |
Review mode only. On a re-review, before leaving one of your own prior threads open or repeating its finding, re-check any technical assertion that finding depends on (whether an API exists, how it is meant to be used, what a version supports) against the actual package, lockfile, or vendored source, and against current official documentation when that is not decisive on its own -- a stale review from an earlier commit is not just a question of whether the code changed, it can also simply have been wrong, and repeating a claim across another review cycle does not make it any more correct. On by default. Adds WebFetch to the review allowlist when on, unrestricted by domain -- this action's stack fragments span enough different package registries and documentation hosts (npm, pkg.go.dev, pub.dev, packagist.org, Terraform's registry, Apple's developer docs, and more, each often mirrored across several hosts) that a maintained per-domain allowlist would either lag behind real usage or need constant upkeep, so the prompt's own restriction -- fetch only a URL you reasoned your way to yourself, never one that appears in the diff, the pull request description, or a comment -- is the sole guard, enforced by the prompt alone, not by anything technical. WebFetch is a route to arbitrary network egress that nothing else in this action's review allowlist grants -- set this to "false" on repositories that accept untrusted fork pull requests (see the README's security notes). Ignored in triage and interactive modes.
required: false
default: "true"
structured_review_summary:
description: |
Review mode only. Off by default, unlike the other review toggles above: turning this on makes every review pay for a second, bounded follow-up call (--resume, a handful of turns) that re-fetches the review it just submitted and produces a structured_output JSON summary (review_state, and finding counts by severity) for a caller to consume -- worth the cost only if something actually reads structured_output, so it is opt-in rather than on-by-default like the others. The follow-up call re-derives the summary from a fresh read of the submitted review and its inline comments rather than trusting the primary call's own memory, which needs Bash(gh api:*) since inline review comments have no narrower `gh pr view --json` field -- the same broad, prompt-restricted-not-allowlist-restricted grant resolve_stale_threads already carries, so set this to "false" on repositories that accept untrusted fork pull requests too (see the README's security notes). A schema-validation failure in this follow-up call only means an empty structured_output for that run; it cannot fail the review itself, which has already been submitted by the time this call starts. Ignored in triage and interactive modes.
required: false
default: "false"
fix_ci_failures:
description: |
Review mode only. Off by default, like structured_review_summary and unlike the other review toggles: this one does not just change what the review says, it changes what the review can do. When on, a second call after the review is submitted resumes the same session, diagnoses this pull request's failing CI from the logs include_ci_logs already fetched, applies a fix it is confident about, commits it, and this action pushes that commit to the pull request's own head branch. Requires include_ci_logs to be true -- the run fails at validation if you set one without the other, rather than silently finding nothing to fix. The fix call gets its own allowlist (Write, Edit, git add/commit, and narrow build/test entry points to verify a change) which the primary review call still does not have: review itself stays read-only, and only this follow-up can touch a file. It cannot push -- the push is a separate step with a fixed refspec no prompt can influence. It is skipped outright, before it costs a call, when the job's token cannot push, which is always true in the reusable-workflow form (contents: read, hardcoded, and a permissions block cannot branch on an input) and always true for a pull request from a fork. So this only takes effect when you call the composite action directly from a job holding contents: write; see examples/direct/claude-review-with-fixes.yml. This is the largest widening of review's blast radius this action offers -- read the README's security notes before turning it on, and do not turn it on for a repository that accepts pull requests from people you would not give write access to. Ignored in triage and interactive modes.
required: false
default: "false"
fix_diff_findings:
description: |
Review mode only. Off by default, for the same reason as fix_ci_failures above, and it shares the same follow-up call, the same allowlist, the same fixed-refspec push, and the same skip-when-we-cannot-push behaviour -- set both to have one pass address both. Where fix_ci_failures acts on a failing workflow, this acts on the review's own findings: the Blocker and Should-fix findings it just raised from the diff, and only those it can fix to the same bar a suggestion block already has to meet (a confident, complete, verified replacement -- never something ambiguous, architectural, or needing a design decision the author should make). Nits are never fixed; they are explicitly optional and not worth a commit. Turning this on does not remove the review comment: the finding is still reported inline exactly as it would be, and the fix arrives as a commit alongside it, so the author sees both the problem and the change. Ignored in triage and interactive modes.
required: false
default: "false"
add_regression_tests:
description: |
Review mode only. Off by default. Modifies the automatic fix pass rather than doing anything on its own, so it requires at least one of fix_ci_failures and fix_diff_findings to be true -- the run fails at validation otherwise, rather than quietly doing nothing. When on, each fix the pass applies carries a test alongside it that fails against the code as it was and passes with the fix, written into the repository's existing test layout and following its conventions. Where the repository has no test suite, or the failure cannot reasonably be expressed as a test, the fix is still applied and the omission is stated in the commit message; the pass never invents a test harness, and never writes a test that merely asserts on a mock. This makes each fix commit larger and the pass slower, which is why it is separate from the two fix flags rather than implied by them. Ignored in triage and interactive modes.
required: false
default: "false"
push_token:
description: |
Review mode only, and only relevant when fix_ci_failures or fix_diff_findings is on. Token used to authenticate the fix commit's own git operations (the pre-flight push probe in "Prepare fix pass", and the real push in "Publish applied fixes") -- distinct from github_token, which governs the review call's own API identity and must stay whatever makes the review run as the identity you want (unset, for the Claude Code App) regardless of what pushes the fix. Falls back to github_token, then to the job's own token, when unset, so leaving this unset reproduces the exact previous behaviour. Set this to a separate GitHub App installation token (minted with actions/create-github-app-token) when you want a fix commit to trigger new workflow runs: a push authenticated with the job's own GITHUB_TOKEN never does, by GitHub's own design, so checks will not automatically re-run on the fix commit unless this is a real App installation token instead. See examples/direct/claude-review-with-fixes.yml.
required: false
default: ""
wrap_up_on_turn_limit:
description: |
All modes. When the primary run hits its turn limit (cut off mid-task rather than finishing cleanly), fire a short follow-on run (3 turns) that resumes the same session and asks Claude to summarise what it found and what remains, so the work is not lost. On by default; set to "false" to skip the follow-on entirely.
required: false
default: "true"
escalate_findings_to_issues:
description: |
Review mode only. Off by default. When the turn-limit wrap-up (above) runs and itself fails -- most often because its own closing reply tried to post somewhere (a PR comment, a review) and that write was blocked or the pull request had already merged or closed underneath it -- fire a further, tightly scoped follow-on that resumes the same session and asks Claude to judge, finding by finding, whether what it already found is severe enough (Blocker or Should-fix; never a Nit) to warrant a standing record even though its normal output channel was unavailable. For each finding that clears that bar, it searches open issues for one already carrying that finding's own stable marker and updates it, or opens a new issue if none exists -- one issue per distinct finding, never one bundled issue for the whole run. This call's allowlist holds only issue read/write tools (Bash(gh issue ...), mcp__github__*_issue, mcp__github__search_issues) -- no PR mutation, no gh api:*, nothing the wrap-up call itself could already do. Requires the calling job to hold issues: write, which review mode's reusable workflow does not request and cannot be made to via a caller-side permissions: block (see "What each mode is allowed to do") -- this only takes effect when you call ExaDev/claude-code-action@v1 directly with your own job permissions: including issues: write, the same constraint fix_ci_failures/fix_diff_findings already have for contents: write. Independent of this input, the wrap-up's own last reply is always additionally written to the job's Step Summary when the wrap-up step fails, at no permission cost -- see "Write wrap-up result to step summary".
required: false
default: "false"
severity_ratings:
description: |
Review mode only. Whether findings also carry an emoji severity rating (🔴 Blocker, 🟠 Should fix, 🟡 Nit)
alongside the existing text label: 'always', 'optional', or 'never'. 'optional' (the default) leaves it to
the model's own judgement per finding. Ignored in triage and interactive modes.
required: false
default: "optional"
allowed_review_states:
description: |
Review mode only. Which GitHub review states the bot may submit, as a comma-separated subset of
'changes_requested', 'comment', and 'approve'. The default 'changes_requested,comment,approve' allows all
three: request changes on any Should-fix or Blocker, approve when only Nits (or nothing) remain, and comment
only as a fallback. An approval can satisfy a required-review rule. Drop 'approve' to keep a human as the
only one who can greenlight a merge; drop 'changes_requested' for comment-only reviews. 'comment' is always
available as the truthful fallback regardless of this list, so the bot never approves a review that raised a
Should-fix or Blocker, and never requests changes on a review with only Nits. Ignored in triage and
interactive modes.
required: false
default: "changes_requested,comment,approve"
include_comments_by_actor:
description: "Comma-separated actor usernames to include as context (supports '*[bot]' wildcards). Empty includes all actors."
required: false
default: ""
exclude_comments_by_actor:
description: "Comma-separated actor usernames to exclude from context (supports '*[bot]' wildcards). Empty excludes none. Takes priority over include_comments_by_actor on conflict."
required: false
default: ""
# Branch and commit behaviour. Only meaningful where the mode can push a branch (interactive) — review and triage hold no write scope, so these are inert there and the reusable workflows for those modes do not expose them.
base_branch:
description: "Base branch for a Claude-created branch. Defaults to the repository's default branch."
required: false
default: ""
branch_prefix:
description: "Prefix for a Claude-created branch."
required: false
default: "claude/"
branch_name_template:
description: "Template for a Claude-created branch's name. See the upstream action's own documentation for the available variables."
required: false
default: ""
use_commit_signing:
description: "Sign commits using GitHub's own commit signature verification (no key management required). Ignored if ssh_signing_key is set, which takes precedence."
required: false
default: "false"
ssh_signing_key:
description: "SSH private key to sign commits with. Optional — leave unset to use use_commit_signing or plain git commits. Wire this from a secret, never a plain value, in the calling repository."
required: false
default: ""
# Bot identity used for git operations (not the same as github_token, which governs API calls and therefore who comments appear from — see the README's adoption steps).
bot_id:
description: "GitHub user ID to use for git commit authorship."
required: false
default: "41898282"
bot_name:
description: "GitHub username to use for git commit authorship."
required: false
default: "claude[bot]"
# Claude Code plugins, installed before the run.
plugins:
description: "Newline-separated Claude Code plugin names to install."
required: false
default: ""
plugin_marketplaces:
description: "Newline-separated plugin marketplace Git URLs to install plugins from."
required: false
default: ""
# Alternative model providers, instead of the direct Anthropic API. NOTE: any of these that rely on GitHub OIDC (workload identity federation, or Bedrock/Vertex/Foundry's own OIDC paths) need the calling job to hold id-token: write. That permission cannot be granted from a job that calls one of this repository's reusable workflows — see step 3 of README.md's "Adopting it in a repository" section for the underlying GitHub limitation — so OIDC-based auth here only works when calling the composite action directly from your own job, not through review.yml/triage.yml/interactive.yml.
use_bedrock:
description: "Use Amazon Bedrock instead of the direct Anthropic API. Needs AWS credentials in the calling job's own environment — see upstream's documentation."
required: false
default: "false"
use_vertex:
description: "Use Google Vertex AI instead of the direct Anthropic API. Needs GCP credentials in the calling job's own environment — see upstream's documentation."
required: false
default: "false"
use_foundry:
description: "Use Microsoft Foundry instead of the direct Anthropic API. Needs Foundry configuration in the calling job's own environment — see upstream's documentation."
required: false
default: "false"
anthropic_federation_rule_id:
description: "Workload identity federation rule ID (fdrl_...). Requires id-token: write — see the note above."
required: false
default: ""
anthropic_organization_id:
description: "Anthropic organisation UUID for workload identity federation."
required: false
default: ""
anthropic_service_account_id:
description: "Service account ID (svac_...) the federated token acts as."
required: false
default: ""
anthropic_workspace_id:
description: "Workspace ID (wrkspc_...) for workload identity federation."
required: false
default: ""
anthropic_oidc_audience:
description: "Audience to request on the GitHub OIDC token for workload identity federation. Defaults to https://api.anthropic.com."
required: false
default: ""
# Context compression, via a local Headroom (github.com/headroomlabs-ai/headroom) proxy. All-mode: this shapes how every anthropics/claude-code-action call in this run talks to Anthropic, not a mode-specific behaviour.
headroom_enabled:
description: |
Run a local Headroom context-compression proxy in front of the Anthropic API for this job, and
point every anthropics/claude-code-action call in it at that proxy via ANTHROPIC_BASE_URL. On
by default. See the README's "Context compression (Headroom)" section.
Mutually exclusive with use_bedrock, use_vertex, use_foundry, and workload identity federation
(anthropic_federation_rule_id) -- each of those routes Claude Code's requests to its own provider
directly, bypassing ANTHROPIC_BASE_URL and the proxy silently. Setting headroom_enabled alongside
any of them fails validation rather than starting a proxy that compresses nothing -- since this
now defaults to true, a job that sets one of those four inputs must also set headroom_enabled:
false explicitly, or validation fails before any provider call is attempted. The same applies to
a self-hosted runner with neither a usable Python nor Docker: set headroom_enabled: false there
too, since neither install method has anywhere to run.
claude_code_oauth_token compatibility is confirmed: a live run authenticated purely by that
credential reached the proxy and reached Anthropic's real API through it, confirmed by a genuine
subscription-tier 429 response coming back through the proxy. This run still checks the proxy's
own request count afterwards and reports headroom_proxied accordingly, as a standing regression
check against a future upstream change, not because the question itself remains open.
required: false
default: "true"
headroom_install_method:
description: |
How to run the Headroom proxy: 'pip' (default) or 'docker'. pip installs headroom-ai[proxy] --
the minimal extra for running just the proxy, without the AST-aware code-compression or Bedrock
backend support the docker image bundles unconditionally -- and runs it as a background process;
ubuntu-latest ships Python 3 already, so no separate runtime install is needed. docker instead
pulls and runs headroom_image as a container, which is heavier but is the only option on a
self-hosted runner that has Docker but not a usable Python (or the reverse situation flipped:
pip is the only option on a self-hosted runner with Python but no Docker). See the README's
"Context compression (Headroom)" section.
required: false
default: "pip"
headroom_version:
description: "pip install method only. Exact headroom-ai version to pin (no leading 'v', matches PyPI's own version string). Ignored when headroom_install_method is docker. See the README for how this pin is tracked and bumped."
required: false
default: "0.37.0"
headroom_image:
description: "docker install method only. Docker image for the Headroom proxy, pinned by digest. Ignored when headroom_install_method is pip. See the README's 'Context compression (Headroom)' section for how this pin is tracked and bumped."
required: false
default: "ghcr.io/headroomlabs-ai/headroom@sha256:35b799e94eef4644cb15a2e695b4b99698fe7668614623e9338cb10c10ececf9"
headroom_port:
description: "Loopback port the Headroom proxy listens on. Change only to avoid a collision with another localhost service already running in the same job. Ignored when headroom_enabled is false."
required: false
default: "8787"
headroom_show_savings:
description: "On by default. Post a short comment on the pull request or issue reporting Headroom's compression stats for this run (requests proxied, tokens saved, compression percentage), in addition to the Step Summary this action always writes when headroom_enabled is true. Skipped when nothing was proxied, or in generic mode, which posts nothing to GitHub by design. Set false to keep the numbers in the Step Summary only. Ignored when headroom_enabled is false."
required: false
default: "true"
# Advanced/debugging overrides. Upstream passthroughs, rarely needed.
path_to_claude_code_executable:
description: "Optional path to a custom Claude Code executable. If provided, skips automatic installation and uses this executable instead. WARNING: using an older version may cause problems if this action begins relying on a newer Claude Code feature."
required: false
default: ""
path_to_bun_executable:
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: an incompatible version may cause problems."
required: false
default: ""
# Output verbosity. Both carry the same warning upstream gives: only enable in trusted, non-sensitive contexts, since they surface Claude-authored and tool-execution content in public GitHub Actions logs/summaries.
display_report:
description: "Display the Claude Code report (turns, tool calls, results) in the GitHub Step Summary. On by default -- the Step Summary is repo-scoped and only visible to whoever can already see the calling repository's own Actions runs, so this carries no more exposure than the run itself already has."
required: false
default: "true"
show_full_output:
description: 'Stream the full Claude Code output (every turn, tool call, and result) to the live Actions log as it runs. On by default. Set to "false" on any repository that accepts public fork pull requests, or is itself public, since tool results may include file contents or secrets that would then be visible in a publicly-readable Actions log.'
required: false
default: "true"
outputs:
conclusion:
description: "Execution status of Claude Code ('success' or 'failure')."
value: ${{ steps.claude.outputs.conclusion }}
execution_file:
description: "Path to the Claude Code execution output file."
value: ${{ steps.claude.outputs.execution_file }}
branch_name:
description: "Branch created by Claude Code during this run, if any."
value: ${{ steps.claude.outputs.branch_name }}
session_id:
description: "Claude Code session ID for this run."
value: ${{ steps.claude.outputs.session_id }}
structured_output:
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (e.g. via extra_claude_args, or review mode's own structured_review_summary). Use fromJSON() to parse: fromJSON(steps.claude.outputs.structured_output).field_name"
value: ${{ steps.structured-summary.outputs.structured_output || steps.claude.outputs.structured_output }}
fixes_pushed:
description: "Review mode only. 'true' if the automatic fix pass (fix_ci_failures / fix_diff_findings) committed and successfully pushed a fix to the pull request's branch this run, 'false' otherwise -- including when the pass is off, found nothing to fix, or could not push. Lets a caller gate a follow-on step on whether a fix actually landed, without parsing comments."
value: ${{ steps.push-fixes.outputs.pushed }}
headroom_proxied:
description: "'true' if headroom_enabled was set and the proxy recorded at least one proxied request by the time this run finished, 'false' otherwise -- including when the feature is off, the proxy never started, or an auth path bypassed it entirely (both anthropic_api_key and claude_code_oauth_token are confirmed compatible; see headroom_enabled's own description). Lets a caller gate on real compression having happened, rather than trusting the input alone."
value: ${{ steps.headroom-verify.outputs.headroom_proxied }}
headroom_tokens_saved:
description: "Total tokens Headroom's compression removed this run (its own /stats summary.compression.total_tokens_removed), or '0' when headroom_enabled is false or nothing was proxied. Also written to the job's Step Summary as a human-readable line, so this isn't only reachable by a caller reading the output -- it's visible on the run itself."
value: ${{ steps.headroom-verify.outputs.headroom_tokens_saved }}
headroom_compression_percent:
description: "Headroom's own average compression percentage this run (summary.compression.avg_compression_pct) -- the unweighted average of each individual request's own compression ratio, or '0' when headroom_enabled is false or nothing was proxied. See headroom_total_percent_saved for the volume-weighted aggregate figure instead."
value: ${{ steps.headroom-verify.outputs.headroom_compression_percent }}
headroom_total_percent_saved:
description: "The aggregate share of this run's total token volume that Headroom removed (summary.compression.total_tokens_removed / total_tokens_before * 100), or '0' when headroom_enabled is false or nothing was proxied. Unlike headroom_compression_percent (an unweighted average of each request's own ratio), this is weighted by actual token volume, so it answers 'what fraction of everything sent was saved' rather than 'what was the typical per-request ratio' -- the two can diverge meaningfully when request sizes vary."
value: ${{ steps.headroom-verify.outputs.headroom_total_percent_saved }}
runs:
using: "composite"
steps:
# Unconditional and first in the job: "Verify triage output landed" further down needs a "before this run touched anything" timestamp that exists regardless of whether post_progress_comment is enabled -- the progress comment's own id was the original baseline, but that comment might never be posted at all under a supported, documented input combination. A plain UTC timestamp captured before any other step runs is a baseline every mode can use without depending on another feature being on.
- name: Record run start time
id: run-start
shell: bash
run: echo "started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Validate inputs
shell: bash
env:
MODE: ${{ inputs.mode }}
HAS_API_KEY: ${{ inputs.anthropic_api_key != '' }}
HAS_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token != '' }}
RETRY_ON_RATE_LIMIT: ${{ inputs.retry_on_rate_limit }}
RATE_LIMIT_RETRY_WAIT_SECONDS: ${{ inputs.rate_limit_retry_wait_seconds }}
SEVERITY_RATINGS: ${{ inputs.severity_ratings }}
ALLOWED_REVIEW_STATES: ${{ inputs.allowed_review_states }}
INCLUDE_SUGGESTIONS: ${{ inputs.include_suggestions }}
RESOLVE_STALE_THREADS: ${{ inputs.resolve_stale_threads }}
FIX_PR_METADATA: ${{ inputs.fix_pr_metadata }}
MARK_DRAFT_IF_UNREADY: ${{ inputs.mark_draft_if_unready }}
VERIFY_PRIOR_FINDINGS: ${{ inputs.verify_prior_findings }}
STRUCTURED_REVIEW_SUMMARY: ${{ inputs.structured_review_summary }}
INCLUDE_CI_LOGS: ${{ inputs.include_ci_logs }}
FIX_CI_FAILURES: ${{ inputs.fix_ci_failures }}
FIX_DIFF_FINDINGS: ${{ inputs.fix_diff_findings }}
ADD_REGRESSION_TESTS: ${{ inputs.add_regression_tests }}
RESUME_SESSIONS: ${{ inputs.resume_sessions }}
POST_PROGRESS_COMMENT: ${{ inputs.post_progress_comment }}
ESCALATE_FINDINGS_TO_ISSUES: ${{ inputs.escalate_findings_to_issues }}
UPDATE_ISSUE_BODY: ${{ inputs.update_issue_body }}
PROMPT_FILE: ${{ inputs.prompt_file }}
HEADROOM_ENABLED: ${{ inputs.headroom_enabled }}
HEADROOM_SHOW_SAVINGS: ${{ inputs.headroom_show_savings }}
HEADROOM_INSTALL_METHOD: ${{ inputs.headroom_install_method }}
USE_BEDROCK: ${{ inputs.use_bedrock }}
USE_VERTEX: ${{ inputs.use_vertex }}
USE_FOUNDRY: ${{ inputs.use_foundry }}
ANTHROPIC_FEDERATION_RULE_ID: ${{ inputs.anthropic_federation_rule_id }}
run: |
set -euo pipefail
case "$MODE" in
review|triage|interactive|generic) ;;
*)
echo "::error::mode must be one of: review, triage, interactive, generic. Got '${MODE}'."
exit 1
;;
esac
if [ "$MODE" = "generic" ] && [ -z "${PROMPT_FILE:-}" ]; then
echo "::error::mode: generic requires prompt_file to be set -- generic mode has no mode fragment or generated prompt of its own; it needs a caller-supplied prompt file to run at all."
exit 1
fi
case "$RETRY_ON_RATE_LIMIT" in
true|false) ;;
*)
echo "::error::retry_on_rate_limit must be true or false. Got '${RETRY_ON_RATE_LIMIT}'."
exit 1
;;
esac
case "$RATE_LIMIT_RETRY_WAIT_SECONDS" in
''|*[!0-9]*)
echo "::error::rate_limit_retry_wait_seconds must be a non-negative integer. Got '${RATE_LIMIT_RETRY_WAIT_SECONDS}'."
exit 1
;;
*) ;;
esac
case "$SEVERITY_RATINGS" in
always|optional|never) ;;
*)
echo "::error::severity_ratings must be one of: always, optional, never. Got '${SEVERITY_RATINGS}'."
exit 1
;;
esac
# Comma-separated: reject empty up front (an empty string splits to zero tokens, so the loop below would iterate zero times and silently pass), then split on ',' and validate each token. So a typo like 'approve,changes_requeted', or an unset variable interpolated to '', fails loudly here rather than silently producing wrong review states at run time.
if [ -z "${ALLOWED_REVIEW_STATES:-}" ]; then
echo "::error::allowed_review_states must not be empty. Set a comma-separated subset of: changes_requested, comment, approve."
exit 1
fi
for _state in ${ALLOWED_REVIEW_STATES//,/ }; do
case "$_state" in
changes_requested|comment|approve) ;;
*)
echo "::error::allowed_review_states entries must be one of: changes_requested, comment, approve. Got '${_state}'."
exit 1
;;
esac
done
case "$INCLUDE_SUGGESTIONS" in
true|false) ;;
*)
echo "::error::include_suggestions must be true or false. Got '${INCLUDE_SUGGESTIONS}'."
exit 1
;;
esac
case "$RESOLVE_STALE_THREADS" in
true|false) ;;
*)
echo "::error::resolve_stale_threads must be true or false. Got '${RESOLVE_STALE_THREADS}'."
exit 1
;;
esac
case "$FIX_PR_METADATA" in
true|false) ;;
*)
echo "::error::fix_pr_metadata must be true or false. Got '${FIX_PR_METADATA}'."
exit 1
;;
esac
case "$MARK_DRAFT_IF_UNREADY" in
true|false) ;;
*)
echo "::error::mark_draft_if_unready must be true or false. Got '${MARK_DRAFT_IF_UNREADY}'."
exit 1
;;
esac
case "$VERIFY_PRIOR_FINDINGS" in
true|false) ;;
*)
echo "::error::verify_prior_findings must be true or false. Got '${VERIFY_PRIOR_FINDINGS}'."
exit 1
;;
esac
case "$STRUCTURED_REVIEW_SUMMARY" in
true|false) ;;
*)
echo "::error::structured_review_summary must be true or false. Got '${STRUCTURED_REVIEW_SUMMARY}'."
exit 1
;;
esac
# No case for include_ci_logs existed before fix_ci_failures needed to depend on it -- worth closing now rather than leaving a second review toggle unvalidated.
case "$INCLUDE_CI_LOGS" in
true|false) ;;
*)
echo "::error::include_ci_logs must be true or false. Got '${INCLUDE_CI_LOGS}'."
exit 1
;;
esac
case "$FIX_CI_FAILURES" in
true|false) ;;
*)
echo "::error::fix_ci_failures must be true or false. Got '${FIX_CI_FAILURES}'."
exit 1
;;
esac
case "$FIX_DIFF_FINDINGS" in
true|false) ;;
*)
echo "::error::fix_diff_findings must be true or false. Got '${FIX_DIFF_FINDINGS}'."
exit 1
;;
esac
case "$ADD_REGRESSION_TESTS" in
true|false) ;;
*)
echo "::error::add_regression_tests must be true or false. Got '${ADD_REGRESSION_TESTS}'."
exit 1
;;
esac
# Each of these would otherwise be a run that starts, costs money, and silently does nothing -- the failure mode this validation step exists to catch before it happens.
if [ "$FIX_CI_FAILURES" = "true" ] && [ "$INCLUDE_CI_LOGS" != "true" ]; then
echo "::error::fix_ci_failures requires include_ci_logs to be true. The fix pass diagnoses a failure from the workflow logs the CI-inspection tools fetch, and has no other way to see why CI failed. Set include_ci_logs: true (the calling job must also hold 'actions: read'), or set fix_ci_failures: false."
exit 1
fi
if [ "$ADD_REGRESSION_TESTS" = "true" ] && [ "$FIX_CI_FAILURES" != "true" ] && [ "$FIX_DIFF_FINDINGS" != "true" ]; then
echo "::error::add_regression_tests modifies the automatic fix pass, which is not enabled. It adds a test alongside a fix rather than adding tests on its own. Set fix_ci_failures and/or fix_diff_findings to true, or set add_regression_tests: false."
exit 1
fi
# These default to false, unlike most other review toggles -- a true here can only be deliberate, so a non-review mode should be told rather than have it silently ignored.
if [ "$MODE" != "review" ]; then
for _flag_pair in "fix_ci_failures:${FIX_CI_FAILURES}" "fix_diff_findings:${FIX_DIFF_FINDINGS}" "add_regression_tests:${ADD_REGRESSION_TESTS}" "escalate_findings_to_issues:${ESCALATE_FINDINGS_TO_ISSUES}" "mark_draft_if_unready:${MARK_DRAFT_IF_UNREADY}"; do
if [ "${_flag_pair#*:}" = "true" ]; then
echo "::error::${_flag_pair%%:*} is a review-mode input and does nothing in '${MODE}' mode. It is off by default, so setting it can only be deliberate: remove it, or set mode: review."
exit 1
fi
done
fi
case "$RESUME_SESSIONS" in
true|false) ;;
*)
echo "::error::resume_sessions must be true or false. Got '${RESUME_SESSIONS}'."
exit 1
;;
esac
case "$POST_PROGRESS_COMMENT" in
true|false) ;;
*)
echo "::error::post_progress_comment must be true or false. Got '${POST_PROGRESS_COMMENT}'."
exit 1
;;
esac
case "$ESCALATE_FINDINGS_TO_ISSUES" in
true|false) ;;
*)
echo "::error::escalate_findings_to_issues must be true or false. Got '${ESCALATE_FINDINGS_TO_ISSUES}'."
exit 1
;;
esac
case "$UPDATE_ISSUE_BODY" in
true|false) ;;
*)
echo "::error::update_issue_body must be true or false. Got '${UPDATE_ISSUE_BODY}'."
exit 1
;;
esac
case "$HEADROOM_ENABLED" in
true|false) ;;
*)
echo "::error::headroom_enabled must be true or false. Got '${HEADROOM_ENABLED}'."
exit 1
;;
esac
case "$HEADROOM_SHOW_SAVINGS" in
true|false) ;;
*)
echo "::error::headroom_show_savings must be true or false. Got '${HEADROOM_SHOW_SAVINGS}'."
exit 1
;;
esac
case "$HEADROOM_INSTALL_METHOD" in
pip|docker) ;;
*)
echo "::error::headroom_install_method must be pip or docker. Got '${HEADROOM_INSTALL_METHOD}'."
exit 1
;;
esac
# Bedrock, Vertex, Foundry, and workload identity federation each make Claude Code call its own provider directly, bypassing ANTHROPIC_BASE_URL entirely and silently. Left unchecked, headroom_enabled alongside any of these would start the proxy, pay its install and startup cost (a pip install or a Docker pull, depending on headroom_install_method), and compress nothing, with no error at all.
if [ "$HEADROOM_ENABLED" = "true" ]; then
if [ "$USE_BEDROCK" = "true" ] || [ "$USE_VERTEX" = "true" ] || [ "$USE_FOUNDRY" = "true" ]; then
echo "::error::headroom_enabled cannot be combined with use_bedrock, use_vertex, or use_foundry. Each of those routes Claude Code's requests to its own provider directly, bypassing the Headroom proxy (and ANTHROPIC_BASE_URL) entirely. Set headroom_enabled: false, or drop the alternative-provider input."
exit 1
fi
if [ -n "${ANTHROPIC_FEDERATION_RULE_ID:-}" ]; then
echo "::error::headroom_enabled cannot be combined with workload identity federation (anthropic_federation_rule_id set). That path authenticates and routes independently of ANTHROPIC_BASE_URL, so the proxy would start and compress nothing. Set headroom_enabled: false, or drop the federation inputs."
exit 1
fi
fi
if [ "$HAS_API_KEY" != "true" ] && [ "$HAS_OAUTH_TOKEN" != "true" ]; then
echo "::error::No Claude credentials supplied. Set either anthropic_api_key or claude_code_oauth_token."
echo "::error::These normally come from this repository's or organisation's own ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN secret."
exit 1
fi
# --- Credential fallback chain --- anthropic_api_key/claude_code_oauth_token each accept one value or several newline-separated values. Every value found is flattened into a single ordered fallback chain -- every oauth token first (in the order given), then every api key -- capped at MAX entries because a composite action has no native loop: "Run Claude Code" further down is statically unrolled into one attempt step per chain position (attempt_1 .. attempt_5), each trying the next credential only if the previous one failed. See the README's "Credential fallback" section, including the known caveats (debug-logging mask exposure, possible duplicate side effects on a mid-run failure).
- name: Parse credential fallback chain
id: credentials
shell: bash
env:
OAUTH_TOKENS: ${{ inputs.claude_code_oauth_token }}
API_KEYS: ${{ inputs.anthropic_api_key }}
run: |
set -euo pipefail
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
declare -a TYPES=()
declare -a TOKENS=()
while IFS= read -r line; do
value="$(trim "$line")"
[ -z "$value" ] && continue
TYPES+=("oauth")
TOKENS+=("$value")
done <<<"$OAUTH_TOKENS"
while IFS= read -r line; do
value="$(trim "$line")"
[ -z "$value" ] && continue
TYPES+=("key")
TOKENS+=("$value")
done <<<"$API_KEYS"
COUNT=${#TOKENS[@]}
MAX=5
if [ "$COUNT" -gt "$MAX" ]; then
echo "::error::${COUNT} credentials were supplied across claude_code_oauth_token and anthropic_api_key combined, but this action only wires up ${MAX} fallback attempts. Trim the list to ${MAX} or fewer, or extend the attempt_N steps in action.yml to raise the cap." >&2
exit 1
fi
# Every stored token is guaranteed non-empty (the blank-line check above), which is what lets "Run Claude Code"'s cred_N_type == 'oauth' && cred_N_token || '' pattern treat a non-empty type as proof of a non-empty token, further down.
for i in "${!TOKENS[@]}"; do
idx=$((i + 1))
echo "::add-mask::${TOKENS[$i]}"
echo "cred_${idx}_type=${TYPES[$i]}" >>"$GITHUB_OUTPUT"
echo "cred_${idx}_token=${TOKENS[$i]}" >>"$GITHUB_OUTPUT"
done
echo "credential_count=${COUNT}" >>"$GITHUB_OUTPUT"
# --- Progress comment --- A plain, ordinary issue comment posted and later deleted (on success) or edited (on failure) entirely by this action's own steps via `gh api`, independent of track_progress/use_sticky_comment above: those are pure passthroughs to upstream's own tracking-comment mechanism, which only its tag mode implements, and every mode this action runs (review, triage, interactive) always sets `prompt` (see "Compose prompt" below) -- upstream's detectMode treats a non-empty prompt as agent mode, which has no tracking-comment support at all. Placed first, before stack detection or prompt composition, so the comment lands as early as possible rather than waiting on work this comment doesn't depend on.
- name: Post progress comment
id: progress-comment
if: ${{ inputs.post_progress_comment == 'true' }}
# A transient failure to post this best-effort status comment (a rate limit, a network blip, a gh api hiccup) must not fail the whole job -- the real work below hasn't started yet, and this step running first means an unguarded failure here would block every mode's actual review/triage/interactive work over a "nice to have" visual signal, a worse outcome than not having this feature at all. Same reasoning as "Prepare fix pass"/"Publish applied fixes" further down.
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token || github.token }}
REPOSITORY: ${{ github.repository }}
MODE: ${{ inputs.mode }}
PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }}
ISSUE_NUMBER: ${{ inputs.issue_number || github.event.issue.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# GitHub issues and PRs share one number space and one comments endpoint -- mirrors "Resolve cache scope" below's own PR-then-issue-then-neither resolution.
if [ -n "${PR_NUMBER:-}" ]; then
ENTITY_NUMBER="$PR_NUMBER"
elif [ -n "${ISSUE_NUMBER:-}" ]; then
ENTITY_NUMBER="$ISSUE_NUMBER"
else
echo "No pull request or issue in this event; nothing to post a progress comment on."
exit 0
fi
case "$MODE" in
review) VERB="reviewing this pull request" ;;
triage) VERB="triaging this issue" ;;
*) VERB="working on this" ;;
esac
# Delete every terminal-FAILED progress comment this bot left behind on a now-finished previous run, then always create a fresh comment of this run's own -- never adopt an existing one's identity by editing it in place. Adopting via lookup-then-patch is a check-then-act race: interactive mode has no concurrency group, so two overlapping runs (or a stale failed comment plus a fresh run starting moments apart) can both find the same candidate before either has claimed it, both patch it, and both believe they own it -- whichever finishes last then loses its own outcome to a 404 when the comment underneath it has already been deleted or reused by the other. Deleting has no such race: a delete that loses to a sibling's own delete of the same comment just 404s, a harmless no-op, and creating always mints a comment only this run's own id will ever point at, so its own eventual delete-on-success or patch-to-failed below can never step on another run's.
MARKER_FAILED="<!-- claude-progress-comment:failed -->"
JQ_STALE_FILTER='map(select(.user.type == "Bot" and (.body | contains("'"$MARKER_FAILED"'")))) | .[].id'
STALE_IDS=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" --paginate -q "$JQ_STALE_FILTER" 2>/dev/null || echo "")
for id in $STALE_IDS; do
gh api -X DELETE "repos/${REPOSITORY}/issues/comments/${id}" >/dev/null 2>&1 || true
done
BODY=$(printf '🔄 Claude is %s… [View job run](%s)' "$VERB" "$RUN_URL")
# Three attempts with a short backoff before falling back on continue-on-error above -- a transient rate limit or network blip shouldn't cost the comment even though a sustained failure (bad permissions, a real outage) still must not fail the job.
COMMENT_JSON=""
for attempt in 1 2 3; do
if COMMENT_JSON=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" -f body="$BODY"); then
break
fi
COMMENT_JSON=""
echo "Failed to post progress comment (attempt ${attempt}/3)."
[ "$attempt" -lt 3 ] && sleep 2
done
if [ -z "$COMMENT_JSON" ]; then
echo "Giving up on posting a progress comment after 3 attempts."
exit 1
fi
COMMENT_ID=$(jq -r '.id' <<<"$COMMENT_JSON")
echo "comment_id=${COMMENT_ID}" >> "$GITHUB_OUTPUT"
# The actual identity GH_TOKEN just posted as, read straight from this real API response rather than assumed -- "Verify triage output landed" further down uses this (when it exists) as the authoritative answer to "which comments are this run's own output", since it's the one point in the job that observes the true poster identity firsthand.
echo "actor_login=$(jq -r '.user.login' <<<"$COMMENT_JSON")" >> "$GITHUB_OUTPUT"
# Threaded through to "Update progress comment" below as the single source of truth for the marker text, rather than a second hardcoded literal that has to be kept in sync by hand.
echo "marker_failed=${MARKER_FAILED}" >> "$GITHUB_OUTPUT"
echo "Posted progress comment ${COMMENT_ID}."
- name: Detect stack
id: stack
if: inputs.mode == 'review'
shell: bash
env:
FRAGMENT_DIR: ${{ github.action_path }}/prompts/review/stacks
run: |
set -euo pipefail
# Depth 4 reaches the manifests of a conventional monorepo (packages/<name>/package.json, services/<name>/go.mod) without walking the whole tree.
MAX_DEPTH=4
PRUNE=( -name .git -o -name node_modules -o -name vendor -o -name .terraform -o -name Pods
-o -name .build -o -name build -o -name dist -o -name .next -o -name .svelte-kit
-o -name .dart_tool -o -name .gradle )
first_file() {
find . -maxdepth "$MAX_DEPTH" \( "${PRUNE[@]}" \) -prune -o -type f -name "$1" -print -quit 2>/dev/null
}
first_dir() {
find . -maxdepth "$MAX_DEPTH" \( "${PRUNE[@]}" \) -prune -o -type d -name "$1" -print -quit 2>/dev/null
}
all_files() {
find . -maxdepth "$MAX_DEPTH" \( "${PRUNE[@]}" \) -prune -o -type f -name "$1" -print 2>/dev/null
}
have_file() { [ -n "$(first_file "$1")" ]; }
have_dir() { [ -n "$(first_dir "$1")" ]; }
# Whether any package.json at a matched depth declares something matching an extended regex.
package_json_mentions() {
local pattern="$1" pkg
while IFS= read -r pkg; do
[ -n "$pkg" ] || continue
if grep -Eq -- "$pattern" "$pkg"; then
return 0
fi
done < <(all_files package.json)
return 1
}
# Whether any Gradle build script or version catalogue mentions an extended regex.
gradle_mentions() {
grep -REq -- "$1" --include='*.gradle' --include='*.gradle.kts' --include='*.toml' . 2>/dev/null
}
# Every branch below is an if-statement rather than `check && add`: under `set -e` a failing AND-list is itself a failing command and would abort the step.
matched=()
add() { matched+=( "$1" ); }
if have_file build.gradle || have_file build.gradle.kts; then
if have_file 'ktlint*.gradle' || gradle_mentions 'ktlint|detekt'; then
add kotlin
fi
fi
if have_file Package.swift || have_dir '*.xcodeproj' || have_dir '*.xcworkspace'; then
if have_file .swiftlint.yml || have_file .swiftlint.yaml; then
add swift
fi
fi
if have_file composer.json; then add php; fi
if have_file go.mod; then add go; fi
if have_file pubspec.yaml; then add flutter; fi
if have_file '*.tf'; then add terraform; fi
if have_file tsconfig.json; then add typescript; fi
if have_file wrangler.jsonc || have_file wrangler.json || have_file wrangler.toml; then
add cloudflare-workers
fi
if package_json_mentions '@sveltejs/kit'; then
add sveltekit
fi
if have_file package.json; then
if have_file schema.prisma || package_json_mentions '"(@prisma/[a-z]+|prisma)"'; then
add node-prisma
fi
fi
if [ "${#matched[@]}" -eq 0 ]; then
echo "No stack signal matched. Review will use the shared and review base prompts only."
echo "fragments=" >> "$GITHUB_OUTPUT"
echo "names=" >> "$GITHUB_OUTPUT"
exit 0
fi
# A detected stack with no fragment on disk means this action's own detection and prompts/review/stacks/ have drifted apart. Fail rather than quietly reviewing without the guidance the detection promised.
paths=()
for name in "${matched[@]}"; do
path="${FRAGMENT_DIR}/${name}.md"
if [ ! -f "$path" ]; then
echo "::error::Detected stack '${name}' has no prompt fragment at ${path}. Add the fragment, or remove the detection for it in action.yml."
exit 1
fi
paths+=( "$path" )
done
# One absolute fragment path per line.
delim="FRAGMENTS_$(openssl rand -hex 16)"
{
echo "fragments<<${delim}"
printf '%s\n' "${paths[@]}"
echo "${delim}"
} >> "$GITHUB_OUTPUT"
printf 'names=%s\n' "$(IFS=,; echo "${matched[*]}")" >> "$GITHUB_OUTPUT"
echo "Detected stacks: ${matched[*]}"
- name: Compose prompt
id: prompt
shell: bash
env:
MODE: ${{ inputs.mode }}
ACTION_PATH: ${{ github.action_path }}
STACK_FRAGMENTS: ${{ steps.stack.outputs.fragments }}
STACK_NAMES: ${{ steps.stack.outputs.names }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
EVENT_ACTION: ${{ github.event.action }}
ENTITY_NUMBER: ${{ inputs.pr_number || inputs.issue_number || github.event.pull_request.number || github.event.issue.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
SEVERITY_RATINGS: ${{ inputs.severity_ratings }}
ALLOWED_REVIEW_STATES: ${{ inputs.allowed_review_states }}
INCLUDE_SUGGESTIONS: ${{ inputs.include_suggestions }}
RESOLVE_STALE_THREADS: ${{ inputs.resolve_stale_threads }}
FIX_PR_METADATA: ${{ inputs.fix_pr_metadata }}
MARK_DRAFT_IF_UNREADY: ${{ inputs.mark_draft_if_unready }}
VERIFY_PRIOR_FINDINGS: ${{ inputs.verify_prior_findings }}
FIX_CI_FAILURES: ${{ inputs.fix_ci_failures }}
FIX_DIFF_FINDINGS: ${{ inputs.fix_diff_findings }}
ADD_REGRESSION_TESTS: ${{ inputs.add_regression_tests }}
PROMPT_DIR: ${{ inputs.prompt_dir }}
PROMPT_FILE: ${{ inputs.prompt_file }}
TRIAGE_LABEL: ${{ inputs.triage_label }}
UPDATE_ISSUE_BODY: ${{ inputs.update_issue_body }}
run: |
set -euo pipefail
OUT="${RUNNER_TEMP}/claude-code-action-prompt.md"
: > "$OUT"
# Full bypass: PROMPT_FILE's content becomes the entire prompt, verbatim. Skips every fragment/stack/conventions/"This run" composition step below. "Validate inputs" already requires this to be set for mode: generic; nothing stops a caller in another mode from setting it too (same escape-hatch philosophy as claude_args), it just isn't documented or exercised as a supported combination there.
if [ -n "${PROMPT_FILE:-}" ]; then
cat "$PROMPT_FILE" > "$OUT"
echo "Composed prompt: verbatim from ${PROMPT_FILE} ($(wc -c < "$OUT") bytes, $(wc -l < "$OUT") lines)."
delim="PROMPT_$(openssl rand -hex 16)"
{
echo "text<<${delim}"
cat "$OUT"
echo "${delim}"
} >> "$GITHUB_OUTPUT"
exit 0
fi
DIR="${PROMPT_DIR:-$MODE}"
append_file() {
local path="$1"
[ -f "$path" ] || return 0
cat "$path" >> "$OUT"
printf '\n\n' >> "$OUT"
}
# 1. Universal instructions, then the mode's own instructions (prompt_dir overrides which directory the latter loads from; unset in the common case, where it's just MODE).
append_file "${ACTION_PATH}/prompts/shared/base.md"
append_file "${ACTION_PATH}/prompts/${DIR}/base.md"
# 2. Review mode only: every stack fragment whose signal was detected.
if [ -n "${STACK_FRAGMENTS:-}" ]; then
printf '# Stack-specific review guidance\n\n' >> "$OUT"
while IFS= read -r fragment; do
[ -n "$fragment" ] || continue
append_file "$fragment"
done <<< "$STACK_FRAGMENTS"
fi