-
Notifications
You must be signed in to change notification settings - Fork 269
1560 lines (1478 loc) · 96.1 KB
/
Copy pathclaude-code-review.yml
File metadata and controls
1560 lines (1478 loc) · 96.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: Pre-merge Review (main)
# Full review is chained to complete AFTER Claude Triage, so the review
# sees the freshly-applied state labels. Listening to the same
# ready_for_review event as triage produced a race: review read labels
# at workflow-start time, before triage wrote them, so review:trivial
# and review:frontmatter-only short-circuits were broken on initial runs.
#
# Triage runs on [opened, reopened, ready_for_review]. When it completes,
# the workflow_run event fires here. A runtime pr-context step then
# decides whether this particular PR is eligible (skip drafts, trivial
# PRs, and bot authors).
#
# Synchronize events keep the mark-stale behavior on pull_request.
on:
pull_request:
types: [synchronize]
workflow_run:
workflows: ["Pre-merge Review (triage)"]
types: [completed]
# Manual dispatch entry point used by claude-new.yml when an authorized
# user invokes `@claude #new-review` to regenerate the pinned review
# from scratch. force=true bypasses the trivial / frontmatter-only /
# draft / bot-author skip-reason heuristics — explicit user request
# overrides the auto-skip path.
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
required: true
type: string
force:
description: 'Bypass skip-reason heuristics (trivial/fmonly/draft/bot-author)'
required: false
type: boolean
default: false
head_sha:
description: 'PR head SHA (passed by claude-new.yml dispatcher so checkout sees PR content, not base)'
required: true
type: string
dispatcher_comment_id:
description: 'ID of the dispatcher confirmation comment to delete on completion (claude-new.yml only; empty for other dispatchers)'
required: false
type: string
default: ''
mention_author:
description: 'Author to @-mention in the terminal "Review regenerated" comment on success (claude-new.yml only; empty suppresses the terminal post)'
required: false
type: string
default: ''
jobs:
# synchronize → mark the existing pinned review stale, and record whether
# the prior state was outstanding-issues so the auto-refresh job below can
# decide whether the push is a candidate for a scoped automatic update.
mark-stale:
if: |
github.event_name == 'pull_request' &&
github.event.action == 'synchronize' &&
github.event.pull_request.user.login != 'pulumi-bot' &&
github.event.pull_request.user.login != 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
outputs:
was_outstanding: ${{ steps.transition.outputs.was_outstanding }}
steps:
- name: Mark previous Claude review as stale
id: transition
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
run: |
# Only mark stale if a prior review actually completed — i.e. the
# PR carries one of the two terminal-success state labels. The
# transition is an inline gh pr edit (this job has no checkout,
# so we can't shell out to set-review-label.sh); the labels are
# mutually exclusive by convention. in-progress / error / stale
# are not transitioned: in-progress means a run is mid-flight,
# error is a workflow failure (separate triage), and stale is
# already terminal.
WAS_OUTSTANDING=false
LABELS=$(gh pr view "$PR" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name] | join(",")')
if [[ ",$LABELS," == *",review:outstanding-issues,"* ]]; then
gh pr edit "$PR" --repo "${{ github.repository }}" \
--add-label "review:stale" --remove-label "review:outstanding-issues"
WAS_OUTSTANDING=true
elif [[ ",$LABELS," == *",review:no-blockers,"* ]]; then
gh pr edit "$PR" --repo "${{ github.repository }}" \
--add-label "review:stale" --remove-label "review:no-blockers"
fi
echo "was_outstanding=$WAS_OUTSTANDING" >> "$GITHUB_OUTPUT"
# Auto-refresh a just-staled review when the push delta is trivial — the
# "I fixed what you flagged" case. A deterministic gate
# (auto-refresh-gate.py) checks that every hunk of the push diff lands on
# (or within a few lines of) a 🚨 Outstanding finding's [L...] anchor and
# that the push is small; only then is claude-update.yml dispatched to run
# the scoped Sonnet fix-response pass. Anything the gate can't prove —
# large pushes, new files, hunks outside flagged lines, force-pushes,
# unparsable pinned comments — leaves the PR in review:stale exactly as
# before, with the documented explicit refresh paths
# (`@claude #update-review` / `#new-review` / draft→ready).
#
# Scope guards:
# - Only fires when mark-stale transitioned OFF review:outstanding-issues.
# A stale no-blockers review has nothing to re-verify.
# - Same-repo head branches only. Fork PRs run pull_request with a
# read-only token that can't dispatch workflows; they keep the manual
# refresh flow.
# - Once per push comes free: one synchronize event per push, and
# claude-update.yml's per-PR concurrency group (cancel-in-progress)
# collapses stacked pushes onto the newest head.
auto-refresh:
needs: mark-stale
if: |
needs.mark-stale.outputs.was_outstanding == 'true' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
actions: write # gh workflow run claude-update.yml
steps:
# The gate scripts come from the BASE ref, never the PR head — the
# decision to spend model budget must not be steerable by the PR
# under review. Sparse checkout: this job only needs the scripts.
- name: Checkout gate scripts (base ref)
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.ref }}
fetch-depth: 1
sparse-checkout: .claude/commands/docs-review/scripts
- name: Evaluate auto-refresh gate
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.after }}
run: |
SCRIPTS=.claude/commands/docs-review/scripts
no_fire() {
echo "fire=false" >> "$GITHUB_OUTPUT"
echo "auto-refresh gate: no fire — $1" | tee -a "$GITHUB_STEP_SUMMARY"
exit 0
}
bash "$SCRIPTS/pinned-comment.sh" fetch --pr "$PR" --repo "$REPO" \
> /tmp/pinned-body.md || true
[ -s /tmp/pinned-body.md ] || no_fire "no pinned review on PR #$PR"
LAST_SHA=$(bash "$SCRIPTS/pinned-comment.sh" last-reviewed-sha \
--pr "$PR" --repo "$REPO" 2>/dev/null || true)
[ -n "$LAST_SHA" ] || no_fire "no last-reviewed SHA in review history"
# Push delta via the compare API — no checkout depth games. The
# three-dot compare diffs merge-base..head: on a normal push the
# merge-base IS the last-reviewed SHA (exact push delta); after a
# force-push the merge-base drifts older, the diff inflates, and
# the gate fails closed downstream.
gh api -H "Accept: application/vnd.github.v3.diff" \
"repos/$REPO/compare/$LAST_SHA...$HEAD_SHA" > /tmp/push.diff \
|| no_fire "compare $LAST_SHA...$HEAD_SHA failed (history rewritten?)"
gh pr view "$PR" --repo "$REPO" --json files \
--jq '[.files[].path]' > /tmp/pr-files.json \
|| no_fire "could not list PR files"
RESULT=$(python3 "$SCRIPTS/auto-refresh-gate.py" \
--pinned-body /tmp/pinned-body.md \
--push-diff /tmp/push.diff \
--pr-files /tmp/pr-files.json) \
|| no_fire "gate script error (fail closed)"
FIRE=$(echo "$RESULT" | jq -r '.fire')
REASON=$(echo "$RESULT" | jq -r '.reason')
echo "fire=$FIRE" >> "$GITHUB_OUTPUT"
echo "auto-refresh gate: fire=$FIRE — $REASON" | tee -a "$GITHUB_STEP_SUMMARY"
# Dispatch with GITHUB_TOKEN — same pattern as claude-new.yml's
# dispatch of this workflow. The dispatched run's actor becomes
# github-actions[bot]; claude-update.yml opts in via allowed_bots.
- name: Dispatch scoped update review
if: steps.gate.outputs.fire == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run claude-update.yml \
--repo "${{ github.repository }}" \
-f pr_number="${{ github.event.pull_request.number }}" \
-f head_sha="${{ github.event.after }}" \
-f auto=true
claude-review:
# Fire only for workflow_run events from Claude Triage that were
# themselves triggered by a pull_request AND completed successfully.
# The conclusion gate matters: triage is now skipped on draft opens
# (see claude-triage.yml's !draft guard). Without this gate, the
# skipped triage workflow_run still fires this job, which then races
# the ready_for_review-triggered run and gets cancelled by the
# concurrency group — orphaning a CLAUDE_PROGRESS comment.
# The pull_requests array is populated by GitHub when the originating
# workflow ran in a PR context on the same repo.
if: |
(github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.pull_requests != null &&
github.event.workflow_run.pull_requests[0] != null) ||
github.event_name == 'workflow_dispatch'
concurrency:
group: claude-review-${{ github.event.workflow_run.pull_requests[0].number || github.event.inputs.pr_number }}
cancel-in-progress: true
runs-on: ubuntu-latest
# A review that genuinely hangs (one observed stall ran ~18 min with no
# output before being cancelled) would otherwise sit on the runner for
# GitHub's 6-hour default. The hang guard is the step-level
# timeout-minutes on the Opus step (18 min, comfortably above the
# slowest real reviews — blog reviews run 9-12+ min); this job-level
# value only backstops the deterministic pre/post steps. It's sized so
# the Opus step's budget survives pre-step variance (one observed
# checkout took 10 minutes on its own — PR #20560, run 30557604289;
# under the old job-level-only 25-min timeout that run gave Opus just
# 12 minutes before the axe fell, and the axe landed on the whole job,
# skipping every finalize step gated on step outcomes). Keep the two
# values in sync with the *_BUDGET_S env of the classify-outcome step.
timeout-minutes: 40
# No `environment: production` — this job never pushes commits
# (allowed_tools below excludes git push / commit / add and gh pr
# edit), so it doesn't need a PULUMI_BOT_TOKEN-authenticated
# checkout. The pinned-review upsert downstream runs against
# GITHUB_TOKEN, same as every other gh API call in this job.
# claude.yml / claude-update.yml / claude-social-review.yml still use
# ESC because their claude-code-action invocations push fix commits
# that need to be authored as pulumi-bot so downstream workflows
# (build-and-deploy, social review, etc.) re-fire.
# `id-token: write` IS still required — anthropics/claude-code-action@v1
# requests an OIDC token of its own (independent of ESC). Without
# this permission the action errors at startup with "Could not fetch
# an OIDC token. Did you remember to add 'id-token: write' to your
# workflow permissions?".
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
checks: write
steps:
# Wall-clock anchor for the classify-outcome step at job end: a job
# cancelled ~timeout-minutes after this instant was killed by its own
# timeout, not superseded by a newer run. Must be the first step —
# checkout time (observed up to 10 min) counts against the job budget.
- name: Record job start
id: job-start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
# Check out the PR head, not the base. workflow_run carries the
# originating commit on the event payload; workflow_dispatch
# doesn't, so claude-new.yml passes it through as an input.
# Without this, Vale below ran against base prose and produced
# empty findings.
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha || github.event.inputs.head_sha }}
fetch-depth: 1
# Install mise-managed tools (Vale, Node, etc.) so the prose-lint
# step below has the pinned vale binary on PATH. Cache speeds up
# subsequent runs.
- name: Install mise-managed tools
uses: jdx/mise-action@v4
with:
cache: true
# Resolve all PR state freshly via gh pr view so we see labels
# that triage just wrote. Decides eligibility and skip reasons
# in one place.
- name: Resolve PR context
id: pr-context
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# PR number comes from the workflow_run pull_requests array on
# the triage-chained path, or from the workflow_dispatch input
# on the #new-review path.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR="${{ github.event.inputs.pr_number }}"
else
PR="${{ github.event.workflow_run.pull_requests[0].number }}"
fi
FORCE="${{ github.event.inputs.force || 'false' }}"
REPO="${{ github.repository }}"
# GitHub re-evaluates a PR's diff lazily after force-pushes to
# head or base. Right after a rebase the API can briefly return
# 0 files / 0 additions / 0 deletions even though the PR has
# real changes. Retry once after a pause to catch the race
# before falling through to the empty-diff skip.
fetch_pr() {
gh pr view "$PR" --repo "$REPO" --json isDraft,labels,author,headRefName,baseRefName,headRefOid,additions,deletions,changedFiles,files,title
}
# Retry up to 3 times on transient server-side errors (5xx / 503).
# The empty-diff retry below is a separate, orthogonal concern.
fetch_pr_with_retry() {
local attempt delay out
for attempt in 1 2 3; do
if out=$(fetch_pr 2>&1); then
echo "$out"
return 0
fi
if [[ "$attempt" -lt 3 ]]; then
delay=$(( (attempt - 1) * 15 ))
echo "review: fetch_pr attempt $attempt failed (transient error?), retrying in ${delay}s" >&2
sleep "$delay"
fi
done
echo "$out" >&2
return 1
}
DATA=$(fetch_pr_with_retry)
if [[ "$(echo "$DATA" | jq -r '.changedFiles')" == "0" ]]; then
echo "review: pr=$PR file_count=0 on first read, retrying after 30s (likely post-force-push race)"
sleep 30
DATA=$(fetch_pr)
fi
IS_DRAFT=$(echo "$DATA" | jq -r '.isDraft')
AUTHOR=$(echo "$DATA" | jq -r '.author.login')
LABELS_JSON=$(echo "$DATA" | jq -c '[.labels[].name]')
LABELS_CSV=$(echo "$DATA" | jq -r '[.labels[].name] | join(",")')
# Pre-compute PR metadata so the review model doesn't burn turns
# re-deriving it via gh pr view / git remote / etc. The 2026-04-28
# cost-optimization measurement showed ~85% denial reduction and
# ~51% cost reduction stacked with the broadened allowed-tools
# list (see scratch/2026-04-28-pipeline-comparison/SONNET-EVERYWHERE-ANALYSIS.md).
HEAD_SHA=$(echo "$DATA" | jq -r '.headRefOid')
HEAD_SHA_SHORT="${HEAD_SHA:0:7}"
HEAD_BRANCH=$(echo "$DATA" | jq -r '.headRefName')
BASE_BRANCH=$(echo "$DATA" | jq -r '.baseRefName')
ADDITIONS=$(echo "$DATA" | jq -r '.additions')
DELETIONS=$(echo "$DATA" | jq -r '.deletions')
TITLE=$(echo "$DATA" | jq -r '.title')
# `.changedFiles` is the true total; `.files` is capped at 100 by the
# GraphQL page `gh pr view` requests, so `.files | length` under-reports
# (and stalls at 100) on a large PR.
FILE_COUNT=$(echo "$DATA" | jq -r '.changedFiles')
FILES_LIST=$(echo "$DATA" | jq -r '.files[] | " - \(.path) (+\(.additions)/-\(.deletions))"')
if [[ "$FILE_COUNT" =~ ^[0-9]+$ ]] && [ "$FILE_COUNT" -gt 100 ]; then
FILES_LIST="$FILES_LIST"$'\n'" - … and $((FILE_COUNT - 100)) more (list truncated at 100 by the API)"
fi
# Bare paths, one per line, for path matching. Two reasons this can't
# reuse FILES_LIST: that is the human-readable " - path (+n/-m)" form
# rendered into the prompt, whose two-space bullet prefix means an
# anchored ^path/ pattern never matches it; and it is capped at 100,
# so a templating change sorting past that position would be missed.
# The paginated REST endpoint returns every file.
FILES_PATHS=$(gh api --paginate "repos/$REPO/pulls/$PR/files" --jq '.[].filename')
# force=true (set by claude-new.yml on #new-review dispatch)
# bypasses the auto-skip heuristics. The user explicitly asked
# for a regenerate; trivial / fmonly / oversized / draft /
# bot-author / already-reviewed are all overridable. Empty-diff is
# NOT overridable — there's nothing to review.
SKIP=""
if [[ "$FORCE" != "true" ]]; then
if [[ "$IS_DRAFT" == "true" ]]; then
SKIP="draft"
elif [[ ",$LABELS_CSV," == *",review:trivial,"* ]]; then
SKIP="trivial"
elif [[ ",$LABELS_CSV," == *",review:frontmatter-only,"* ]]; then
SKIP="frontmatter-only"
# Oversized PRs (>15K changed lines or >150 files — set by
# triage, or by hand on a PR that timed out under those
# thresholds) can't finish inside the review budget: every
# attempt burns the full Opus step timeout of runner + API
# spend, then error-cycles the labels. Triage posts a
# TRIAGE_OVERSIZED advisory comment explaining the skip and
# the alternatives.
elif [[ ",$LABELS_CSV," == *",review:oversized,"* ]]; then
SKIP="oversized"
# Bot-authored PRs are slop-skipped — EXCEPT content-review/*, which
# are first-class automated docs fixes we want reviewed. Those fall
# through to the already-reviewed guard below (so they still can't
# re-review loop) and otherwise get a normal review. Triage already
# ran on them and applied any trivial / frontmatter-only short-circuit
# above — the point of this change: a trivial content-review PR skips
# there instead of being force-reviewed.
elif [[ ( "$AUTHOR" == "pulumi-bot" || "$AUTHOR" == "dependabot[bot]" ) \
&& "$HEAD_BRANCH" != content-review/* ]]; then
SKIP="bot-author"
# Auto-fire path (workflow_run from triage) on a PR that the
# review pipeline has already touched: don't burn API on a
# reopen / re-triage. Any terminal state label means an
# explicit user action (`@claude #new-review` or
# `#update-review`) is the documented refresh path.
# outstanding-issues / no-blockers: review ran and is current
# (no commits since — mark-stale would have transitioned
# otherwise). stale: review ran, commits pushed; refresh via
# explicit mention. error: workflow failed; explicit retry
# avoids transient-error loops.
elif [[ "${{ github.event_name }}" == "workflow_run" ]] && (
[[ ",$LABELS_CSV," == *",review:outstanding-issues,"* ]] || \
[[ ",$LABELS_CSV," == *",review:no-blockers,"* ]] || \
[[ ",$LABELS_CSV," == *",review:stale,"* ]] || \
[[ ",$LABELS_CSV," == *",review:error,"* ]]
); then
SKIP="already-reviewed"
fi
fi
if [[ -z "$SKIP" && "$FILE_COUNT" == "0" ]]; then
# Empty diff after retry — GitHub still hasn't re-evaluated.
# Skip cleanly instead of letting the model run with no diff
# context (which previously errored with "directory mismatch").
# The author can flip draft → ready or push to retry.
SKIP="empty-diff"
fi
# Detect whether the diff touches any Hugo-templating-relevant path.
# When it doesn't (content-only PR — the 95% case), the heavy
# `hugo --renderToMemory` pre-step skips entirely. The companion
# build-and-deploy workflow runs a real Hugo build on every PR;
# any templating error there blocks the merge regardless.
TEMPLATING_CHANGED="false"
if printf '%s\n' "$FILES_PATHS" | grep -qE '^(assets|config|data|i18n|layouts|styles|theme)/|^(hugo|config)\.(toml|yaml|yml)$'; then
TEMPLATING_CHANGED="true"
fi
{
echo "pr_number=$PR"
echo "is_draft=$IS_DRAFT"
echo "author=$AUTHOR"
echo "labels_csv=$LABELS_CSV"
echo "labels_json=$LABELS_JSON"
echo "skip_reason=$SKIP"
echo "repo_full=$REPO"
echo "head_sha=$HEAD_SHA"
echo "head_sha_short=$HEAD_SHA_SHORT"
echo "head_branch=$HEAD_BRANCH"
echo "base_branch=$BASE_BRANCH"
echo "additions=$ADDITIONS"
echo "deletions=$DELETIONS"
echo "file_count=$FILE_COUNT"
echo "templating_changed=$TEMPLATING_CHANGED"
echo "title=$TITLE"
echo "files_list<<EOF_FILES"
echo "$FILES_LIST"
echo "EOF_FILES"
} >> "$GITHUB_OUTPUT"
if [[ -n "$SKIP" ]]; then
echo "review: pr=$PR skip=$SKIP (labels=$LABELS_CSV, draft=$IS_DRAFT, author=$AUTHOR)"
else
echo "review: pr=$PR proceed (labels=$LABELS_CSV, files=$FILE_COUNT, +$ADDITIONS/-$DELETIONS, head=$HEAD_SHA_SHORT)"
fi
# Publish a Checks API check-run pinned to the PR's head SHA so
# the review status appears in the PR's Status checks list.
# workflow_run-triggered jobs don't surface in PR Checks by default;
# this is the standard escape hatch. Runs unconditionally so even
# skipped reviews (trivial, draft, bot-author) get a check entry.
- name: Publish check-run (in_progress)
id: check-run
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# workflow_run carries the originating commit SHA on the event
# payload; workflow_dispatch doesn't, so fall back to the head
# SHA pr-context just resolved via gh pr view.
HEAD_SHA="${{ github.event.workflow_run.head_sha || steps.pr-context.outputs.head_sha }}"
DETAILS_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
CHECK_ID=$(gh api -X POST "repos/${{ github.repository }}/check-runs" \
-f name="Pre-merge Review" \
-f head_sha="$HEAD_SHA" \
-f status="in_progress" \
-f details_url="$DETAILS_URL" \
--jq '.id' || echo "")
echo "check_id=$CHECK_ID" >> "$GITHUB_OUTPUT"
# Resolve write-access and post the "Reviewing" signal up front,
# before the LLM-heavy pre-steps. The signal needs to be visible
# while pre-steps run, not after them — moving these two steps
# collapses perceived latency from ~4 min to <30s without changing
# wall-clock. check-access must precede progress because progress
# gates on has_write_access.
- name: Check repository write access
if: steps.pr-context.outputs.skip_reason == ''
id: check-access
run: |
# Use the actual repository the workflow is running in, not a hardcoded
# upstream name. The GITHUB_TOKEN is only scoped to this repo, so a
# hardcoded owner/repo would always return "none" in fork-based testing
# and in repo transfers.
REPO_FULL="${{ github.repository }}"
AUTHOR="${{ steps.pr-context.outputs.author }}"
# GitHub App bots are not collaborators, so the permission API
# below returns "none" for them. Trusted bots that open PRs on this
# repo are whitelisted by name instead. `workprentice` is Joe Duffy's
# docs-automation identity (the Docs Groundskeeper agent) and is
# trusted like an internal author. The author string differs by source:
# this workflow reads `gh pr view --json author` (`app/workprentice`)
# while claude-triage.yml and claude-update.yml read the REST webhook
# `user.login` (`workprentice[bot]`), so both forms are listed in
# every file. Keep this list in sync with the matching checks in
# claude-triage.yml and claude-update.yml.
if [[ "$AUTHOR" == "github-copilot[bot]" || "$AUTHOR" == "eon-pulumi-agent[bot]" \
|| "$AUTHOR" == "app/workprentice" || "$AUTHOR" == "workprentice[bot]" ]]; then
echo "has_write_access=true" >> $GITHUB_OUTPUT
echo "✓ Bot $AUTHOR is whitelisted for Claude reviews"
exit 0
fi
PERMISSION=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO_FULL/collaborators/$AUTHOR/permission" \
| jq -r '.permission // "none"')
if [[ "$PERMISSION" == "admin" || "$PERMISSION" == "write" ]]; then
echo "has_write_access=true" >> $GITHUB_OUTPUT
echo "✓ User $AUTHOR has $PERMISSION access to $REPO_FULL"
else
echo "has_write_access=false" >> $GITHUB_OUTPUT
echo "✗ User $AUTHOR has $PERMISSION access to $REPO_FULL (insufficient permissions)"
fi
# Post a transient <!-- CLAUDE_PROGRESS --> comment so the author sees
# "something is happening" while the pre-steps and Opus run. The post
# step below edits it to a done/errored state when the review completes.
# Separate marker from the pinned review so pinned-comment.sh never
# touches it.
- name: Post progress signal
if: steps.check-access.outputs.has_write_access == 'true'
id: progress
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR="${{ steps.pr-context.outputs.pr_number }}"
REPO="${{ github.repository }}"
BODY=$(cat <<'EOF'
<!-- CLAUDE_PROGRESS -->
<img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="16"> Reviewing — this can take several minutes.
EOF
)
COMMENT_ID=$(gh api "repos/$REPO/issues/$PR/comments" \
-f body="$BODY" --jq '.id' || echo "")
echo "comment_id=$COMMENT_ID" >> "$GITHUB_OUTPUT"
# Set the in-progress state label right after the progress signal so
# the PR's labels reflect "Claude is reviewing right now." The
# finalization step at job end transitions this to outstanding-issues
# / no-blockers (on success) or error (on failure). The mark-stale
# step on a later synchronize event moves it again to review:stale.
- name: Set review:in-progress label
if: steps.check-access.outputs.has_write_access == 'true' && steps.pr-context.outputs.skip_reason == ''
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
.claude/commands/docs-review/scripts/set-review-label.sh \
--pr "${{ steps.pr-context.outputs.pr_number }}" \
--repo "${{ github.repository }}" \
--label review:in-progress
# An oversized skip supersedes any leftover terminal state from earlier
# attempts (typically review:error from a pre-label timeout run) —
# without this, the stale error label sits next to review:oversized and
# reads as "the pipeline is still broken" when the skip is deliberate.
# Other skip paths (trivial / fmonly / draft / bot) never had a prior
# state label, so they don't need this.
- name: Clear stale review-state label (oversized skip)
# No has_write_access conjunct: the `Check repository write access`
# step above only runs when skip_reason is empty, so on the oversized
# path its output is always '' and adding it here made this step
# unreachable — the stale review:error label was never cleared.
if: steps.pr-context.outputs.skip_reason == 'oversized'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
.claude/commands/docs-review/scripts/set-review-label.sh \
--pr "${{ steps.pr-context.outputs.pr_number }}" \
--repo "${{ github.repository }}" \
--clear
# Run Vale on PR-changed files in content/docs and content/blog. Findings
# are filtered to PR-introduced lines only and capped (10/file, 50 total);
# blocker-tier findings are exempt from both caps so one is never silently
# dropped. Written to .vale-findings.json for the review skill to consume.
# continue-on-error keeps a Vale *crash* from blocking the review: no
# findings is a degraded review, not a failed one. That is separate from
# what the findings mean -- advisory ones are nags, but the blocker tier
# lands in 🚨 and drives review:outstanding-issues.
- name: Run Vale on PR-changed prose
if: steps.pr-context.outputs.skip_reason == ''
id: vale
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ steps.pr-context.outputs.pr_number }}
run: |
CHANGED=$(gh pr diff "$PR" --name-only \
| grep -E '^content/(docs|blog|what-is)/.*\.md$' || true)
if [ -z "$CHANGED" ]; then
echo '{}' > .vale-raw.json
echo '[]' > .vale-findings.json
echo "vale: no in-scope prose files changed; skipping"
exit 0
fi
# `||` fallbacks guarantee both files exist even when vale is
# missing or the filter crashes. The downstream prompt's "if
# file exists and is non-empty" check would otherwise fall over
# on a missing file. Pattern mirrors claude-triage.yml.
vale --no-exit --output=JSON $CHANGED > .vale-raw.json 2>/dev/null \
|| echo '{}' > .vale-raw.json
# Vale processes markdown to HTML before applying rules, so bracket
# constructions (`[here](url)`, ``) are gone before tokens
# match. The companion script scans raw markdown for the missing
# syntax patterns and emits Vale-shaped JSON we merge into the raw
# findings before filtering. Same `||` fallback discipline.
python3 .claude/commands/docs-review/scripts/markdown-syntax-findings.py \
$CHANGED > .syntax-findings.json \
|| echo '{}' > .syntax-findings.json
# Concatenate per-file alert arrays (jq's `*` shallow-merges and would
# *replace* Vale's array with the script's for any overlapping file).
jq -s 'reduce .[] as $o ({}; reduce ($o | keys_unsorted[]) as $k (.; .[$k] = ((.[$k] // []) + $o[$k])))' \
.vale-raw.json .syntax-findings.json > .vale-raw.merged.json \
&& mv .vale-raw.merged.json .vale-raw.json \
|| true
python3 .claude/commands/docs-review/scripts/vale-findings-filter.py \
--pr "$PR" --in .vale-raw.json --out .vale-findings.json \
|| echo '[]' > .vale-findings.json
# Pre-fetch external URLs added by the PR diff. Pass 2 of the External
# claim verification lane consults this file instead of dispatching
# WebFetch at review time. Pass 3 (search-then-fetch for external-public
# claims with no URL in the diff) still runs model-side. continue-on-
# error keeps fetch failures from blocking the review; the validator's
# `pass-2-fetch-faithfulness` rule catches the unfaithful pattern where
# the model claims Pass 2 dispatches that didn't actually happen.
- name: Pre-fetch external URLs
if: steps.pr-context.outputs.skip_reason == ''
id: extract-urls
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ steps.pr-context.outputs.pr_number }}
run: |
CHANGED=$(gh pr diff "$PR" --name-only \
| grep -E '^content/(docs|blog|what-is)/.*\.md$' || true)
if [ -z "$CHANGED" ]; then
echo '[]' > .fetched-urls.json
echo "extract-urls: no in-scope prose files changed; skipping"
exit 0
fi
python3 .claude/commands/docs-review/scripts/extract-urls-and-fetch.py \
--pr "$PR" --out .fetched-urls.json \
|| echo '[]' > .fetched-urls.json
# ---- Claim extraction ------------------------------------------------
# The claim *floor* the review must verify. Three layers, unioned:
# A. extract-claims.py — deterministic regex/heuristic floor
# (numbers, version pins, temporal words,
# attributions, URLs, named-entity/spec,
# positioning/comparison triggers); walks
# the WHOLE diff. → .candidate-claims-regex.json
# B. extract-claims-llm.py ×2 — two redundant Sonnet passes (atomic /
# holistic framing), one API call per
# changed content/**/*.md file.
# → .candidate-claims-llm-1.json / -2.json
# merge-claims.py — union + dedup + line-anchor.
# → .candidate-claims.json
# The review MUST verify every entry in .candidate-claims.json and MAY add
# more; the validator's `candidate-claims-coverage` rule fails the review
# if it drops a candidate claim. See references/fact-check.md §Pre-step
# artifact `.candidate-claims.json` and references/pre-computation.md.
# All steps continue-on-error with schema-matching `||` stubs; the scripts'
# safe_main() surfaces failures *inside* the artifact (`errors: [...]`),
# not via file-presence heuristics.
- name: Extract candidate claims (Layer A — regex floor)
if: steps.pr-context.outputs.skip_reason == ''
id: extract-claims-regex
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ steps.pr-context.outputs.pr_number }}
run: |
python3 .claude/commands/docs-review/scripts/extract-claims.py \
--pr "$PR" --out .candidate-claims-regex.json \
|| echo '{"schema_version": 1, "claims": [], "renames": [], "errors": ["extract-claims.py failed to start"], "stats": {"claims_count": 0, "files_scanned": 0, "by_type": {}}}' > .candidate-claims-regex.json
- name: Extract candidate claims (Layer B — atomic + holistic in parallel)
if: steps.pr-context.outputs.skip_reason == ''
id: extract-claims-llm
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
PR: ${{ steps.pr-context.outputs.pr_number }}
run: |
# Run the two Sonnet extraction passes concurrently — wall-clock
# collapses from sum(atomic + holistic) to max(atomic, holistic).
# Each writes its own artifact; merge-claims.py reads both
# independently. Per pre-step fallback discipline, failure is
# surfaced INSIDE the artifact (errors: [...]), not via a
# missing-file or silent-empty heuristic.
# Scrutiny is resolved per file inside the script (blog/new files
# bump to heightened; renames and small edits pin to standard).
set +e
python3 .claude/commands/docs-review/scripts/extract-claims-llm.py \
--pr "$PR" --pass atomic --scrutiny standard \
--out .candidate-claims-llm-1.json &
ATOMIC_PID=$!
python3 .claude/commands/docs-review/scripts/extract-claims-llm.py \
--pr "$PR" --pass holistic --scrutiny standard \
--out .candidate-claims-llm-2.json &
HOLISTIC_PID=$!
wait "$ATOMIC_PID"; ATOMIC_RC=$?
wait "$HOLISTIC_PID"; HOLISTIC_RC=$?
if [ "$ATOMIC_RC" -ne 0 ] && [ ! -s .candidate-claims-llm-1.json ]; then
echo '{"schema_version": 1, "pass": "atomic", "model": "claude-sonnet-5", "claims": [], "errors": ["extract-claims-llm.py failed to start"], "meta": {"files": 0, "scrutiny": "unknown", "input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}}' > .candidate-claims-llm-1.json
fi
if [ "$HOLISTIC_RC" -ne 0 ] && [ ! -s .candidate-claims-llm-2.json ]; then
echo '{"schema_version": 1, "pass": "holistic", "model": "claude-sonnet-5", "claims": [], "errors": ["extract-claims-llm.py failed to start"], "meta": {"files": 0, "scrutiny": "unknown", "input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}}' > .candidate-claims-llm-2.json
fi
exit 0
- name: Merge candidate claims → .candidate-claims.json
if: steps.pr-context.outputs.skip_reason == ''
continue-on-error: true
run: |
python3 .claude/commands/docs-review/scripts/merge-claims.py \
--regex .candidate-claims-regex.json \
--llm .candidate-claims-llm-1.json --llm .candidate-claims-llm-2.json \
--out .candidate-claims.json \
|| echo '{"schema_version": 1, "claims": [], "errors": ["merge-claims.py failed to start"], "meta": {"regex_claims": 0, "llm_claims": 0, "merged_claims": 0, "llm_input_tokens": 0, "llm_output_tokens": 0, "llm_cache_read_input_tokens": 0, "llm_cache_creation_input_tokens": 0}}' > .candidate-claims.json
# ---- end claim extraction --------------------------------------------
# ---- Readthrough coherence lane --------------------------------------
# A whole-page Sonnet pass asking "does this page cohere and serve the
# reader?" — anchored structural findings (prerequisite inversion, missing
# step, purpose mismatch, …) that the fact/code/style passes never surface.
# compose-review.py synthesizes one `🚩 flagged` detector verdict per
# finding; Opus triages into the normal buckets.
#
# Scope is PER PAGE, not per PR: the lane reads each page that is itself
# whole-page-authored — every NEW content page (the whole file is new) and
# every blog/case-study file (drafted whole-file). A small edit to an
# existing non-blog page is out of scope here (a 3-line fix to a 600-line
# reference shouldn't trigger a full re-read); those pages get a coherence
# pass on the existing-content sweep's cadence instead. The qualifying file
# list is passed to readthrough.py via --changed-files, so a PR with three
# new docs gets three independent end-to-end reads and nothing else.
# (A >70%-rewrite of an existing page is not yet detected here — deferred;
# the sweep covers it.) Per pre-step fallback discipline, failure surfaces
# INSIDE the artifact (errors: [...]); the `||` stub is for can't-even-start.
- name: Pre-compute readthrough scope
if: steps.pr-context.outputs.skip_reason == ''
id: readthrough-scope
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ steps.pr-context.outputs.pr_number }}
REPO: ${{ github.repository }}
run: |
# Added content pages, plus any blog/case-study file (any status).
FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
--jq '.[] | select(.filename | test("^content/.*\\.md$")) | select(.status == "added" or (.filename | test("^content/(blog|case-studies)/"))) | .filename' \
| paste -sd, - || true)
echo "files=$FILES" >> "$GITHUB_OUTPUT"
if [ -n "$FILES" ]; then echo "readthrough scope: $FILES"; else echo "readthrough scope: none"; fi
- name: Readthrough coherence pass → .readthrough-findings.json
if: steps.pr-context.outputs.skip_reason == '' && steps.readthrough-scope.outputs.files != ''
id: readthrough
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
PR: ${{ steps.pr-context.outputs.pr_number }}
FILES: ${{ steps.readthrough-scope.outputs.files }}
run: |
python3 .claude/commands/docs-review/scripts/readthrough.py \
--pr "$PR" --changed-files "$FILES" --out .readthrough-findings.json \
|| echo '{"schema_version": 1, "ran": false, "model": "claude-sonnet-5", "findings": [], "errors": ["readthrough.py failed to start"], "meta": {"files": 0, "scrutiny": "unknown", "input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}}' > .readthrough-findings.json
# ---- end readthrough coherence lane ----------------------------------
# ---- Claim verification ----------------------------------------------
# verify-claims.py routes every entry in .candidate-claims.json to one of
# three lanes — Pass 1 (pulumi-internal: `gh` + local reads), Pass 2
# (external w/ a fetched URL: consult .fetched-urls.json), Pass 3
# (external w/o a fetched URL: server-side web_search) — fires ≤16 parallel
# Sonnet 5 verifiers via direct /v1/messages with a forced `verify_claim`
# tool, and emits .verified-claims.json. The main review reads that file as
# the verdict *source* (it does NOT re-verify); validate-pinned.py's
# `verified-claims-trail-faithful` rule fails the review if the rendered
# 🔍 Verification trail drifts from the artifact. safe_main() surfaces
# failures *inside* the artifact (`errors: [...]`), so the `||` stub is
# reserved for can't-even-start failures.
- name: Verify candidate claims → .verified-claims.json
if: steps.pr-context.outputs.skip_reason == ''
id: verify-claims
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
PR: ${{ steps.pr-context.outputs.pr_number }}
run: |
python3 .claude/commands/docs-review/scripts/verify-claims.py \
--in .candidate-claims.json --fetched-urls .fetched-urls.json \
--pr "$PR" --repo "$GITHUB_REPOSITORY" \
--out .verified-claims.json \
|| echo '{"schema_version": 1, "model": "claude-sonnet-5", "verdicts": [], "errors": ["verify-claims.py failed to start"], "meta": {"n_claims": 0, "n_pass1": 0, "n_pass2": 0, "n_pass3": 0, "input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}}' > .verified-claims.json
# ---- end claim verification ------------------------------------------
# Pre-compute editorial-balance Tier 1 (listicle / FAQ trigger detection,
# section-depth stats, outlier flag) so the model renders the rich vs
# empty form deterministically. Tier 2 (entity counting, recommendation
# steering) remains model-side. Tier 3 (don't-flag exceptions) stays
# model-judged.
- name: Pre-compute editorial-balance Tier 1
if: steps.pr-context.outputs.skip_reason == ''
id: editorial-balance
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ steps.pr-context.outputs.pr_number }}
run: |
CHANGED=$(gh pr diff "$PR" --name-only \
| grep -E '^content/blog/.*\.md$' || true)
if [ -z "$CHANGED" ]; then
echo '{"trigger": null, "files": []}' > .editorial-balance.json
echo "editorial-balance: no blog files changed; skipping"
exit 0
fi
python3 .claude/commands/docs-review/scripts/editorial-balance-detect.py \
--pr "$PR" --out .editorial-balance.json \
|| echo '{"trigger": null, "files": []}' > .editorial-balance.json
# Pre-compute cross-sibling discovery so the model uses a structurally-
# guaranteed sibling list instead of computing the "is this in a templated
# section?" decision inline — see references/fact-check.md §Cross-sibling
# consistency for the artifact contract.
- name: Pre-compute cross-sibling discovery
if: steps.pr-context.outputs.skip_reason == ''
id: cross-sibling
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ steps.pr-context.outputs.pr_number }}
run: |
CHANGED=$(gh pr diff "$PR" --name-only \
| grep -E '^content/docs/.*\.md$' || true)
if [ -z "$CHANGED" ]; then
echo '{"files": []}' > .cross-sibling-discovery.json
echo "cross-sibling: no docs files changed; skipping"
exit 0
fi
python3 .claude/commands/docs-review/scripts/cross-sibling-discover.py \
--pr "$PR" --out .cross-sibling-discovery.json \
|| echo '{"files": []}' > .cross-sibling-discovery.json
# Pre-compute frontmatter validation: menu-parent identifier resolution
# against the global menu-identifier map, plus alias-collision detection
# (PR-internal and repo-wide). See references/fact-check.md §Cross-sibling
# consistency for the artifact contract, and references/pre-computation.md
# for the atomized-discovery pattern.
- name: Pre-compute frontmatter validation
if: steps.pr-context.outputs.skip_reason == ''
id: frontmatter-validate
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ steps.pr-context.outputs.pr_number }}
run: |
CHANGED=$(gh pr diff "$PR" --name-only \
| grep -E '^content/.*\.md$' || true)
if [ -z "$CHANGED" ]; then
echo '{"files": [], "global_identifier_map_size": 0, "global_alias_map_size": 0}' > .frontmatter-validation.json
echo "frontmatter-validate: no content files changed; skipping"
exit 0
fi
python3 .claude/commands/docs-review/scripts/frontmatter-validate.py \
--pr "$PR" --out .frontmatter-validation.json \
|| echo '{"files": [], "global_identifier_map_size": 0, "global_alias_map_size": 0}' > .frontmatter-validation.json
# Pre-compute Hugo build artifact: full `hugo --renderToMemory`
# at HEAD for warnings/errors/link-integrity, plus `hugo list all` at HEAD
# and BASE for sitemap diff. Hugo is the canonical authority for routing/
# build correctness — the agent reads this artifact instead of running
# `make build` itself (which the workflow intentionally skips per ci.md
# hard rule 4). See references/pre-computation.md and
# references/fact-check.md §Hugo build artifact for the contract.
#
# Conditional: runs only when the diff touches templating-relevant paths
# (assets/, config/, data/, i18n/, layouts/, styles/, theme/, root
# hugo.{toml,yaml,yml}). For content-only PRs (~95% of the corpus), the
# step writes a skip-stub artifact so downstream consumers see an empty
# findings shape — the companion build-and-deploy workflow runs a real
# Hugo build on every PR, so templating errors are still caught (just not
# surfaced inline in the pinned comment).
- name: Pre-compute Hugo build artifact
if: steps.pr-context.outputs.skip_reason == ''
id: hugo-build
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ steps.pr-context.outputs.pr_number }}
TEMPLATING_CHANGED: ${{ steps.pr-context.outputs.templating_changed }}
run: |
if [[ "$TEMPLATING_CHANGED" != "true" ]]; then
echo "review: hugo-build skipped (content-only PR; templating paths untouched)"
python3 -c "import json; print(json.dumps({'schema_version': 1, 'skipped': True, 'skipped_reason': 'content-only PR; templating paths untouched. Full Hugo build runs in build-and-deploy.yml.', 'head_exit_code': 0, 'errors': [], 'link_integrity': [], 'sitemap_diff': {'added': [], 'removed': [], 'changed': []}, 'stats': {'errors_count': 0, 'warnings_count': 0, 'link_integrity_count': 0, 'suppressed_ci_noise_count': 0, 'head_pages_count': 0, 'base_pages_count': 0, 'added_pages_count': 0, 'removed_pages_count': 0}}, indent=2))" > .hugo-build.json
exit 0
fi
# Resolve base SHA and ensure it's fetched (workflow checkout uses
# depth=1 so the base may not be in local history).
BASE_SHA=$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json baseRefOid --jq .baseRefOid 2>/dev/null || echo "")
if [ -n "$BASE_SHA" ]; then
git fetch --depth=1 origin "$BASE_SHA" 2>/dev/null || true
fi
# Don't redirect stderr — the script's safe_main wrapper guarantees
# a useful JSON artifact even on uncaught exceptions, and surfacing
# tracebacks in workflow logs is the whole observability story when
# things go wrong. The `||` fallback only fires if the script can't
# even start (ImportError, missing python3, etc.).
python3 .claude/commands/docs-review/scripts/hugo-build-validate.py \
--pr "$PR" --base-sha "$BASE_SHA" --repo "$GITHUB_REPOSITORY" \
--out .hugo-build.json \
|| echo '{"schema_version": 1, "head_exit_code": -1, "head_exit_nonzero_is_ci_noise": false, "errors": ["hugo-build-validate.py failed to start"], "link_integrity": [], "sitemap_diff": {"added": [], "removed": [], "changed": []}, "stats": {"errors_count": 1, "warnings_count": 0, "link_integrity_count": 0, "suppressed_ci_noise_count": 0, "head_pages_count": 0, "base_pages_count": 0, "added_pages_count": 0, "removed_pages_count": 0}}' > .hugo-build.json
# Wall-clock timestamp for the `## Pre-merge Review — Last updated <ts>`
# line. Computed in the workflow (not by the model) so the timestamp is
# always a real UTC instant — left to the model, it sometimes renders a
# T00:00:00Z / round-hour placeholder instead of the actual time.
- name: Compute review timestamp
if: steps.check-access.outputs.has_write_access == 'true'
id: now
run: echo "value=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
# Compose an ~80%-assembled review draft (.review-draft.md) from the
# pre-step artifacts so the Opus job EDITS rather than ASSEMBLES — the 🔍
# trail (verbatim from .verified-claims.json), bucket-count table,
# investigation-log scaffold, 📊 Editorial-balance Tier 1, #### Style
# suggestions block, 📜 Review-history line, and stub 🚨/⚠️ bullets are laid
# out; <TODO> tokens mark the parts that are the reviewer's (summary,
# confidence levels, fix prose, cross-sibling read count). The composer
# runs validate-pinned.py on its own draft (--skip-rule no-todo-tokens)
# and, on a self-check failure, writes a visible `> [!CAUTION]` banner
# INTO .review-draft.md (never a silent-empty file) so the model falls
# back to manual assembly per ci.md §Fallback. safe_main() guarantees a
# valid fallback draft on any uncaught exception; the `||` stub below is
# reserved for can't-even-start failures and writes the same banner shape.
- name: Compose review draft → .review-draft.md
if: steps.check-access.outputs.has_write_access == 'true'
id: compose-review
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 .claude/commands/docs-review/scripts/compose-review.py \
--pr "${{ steps.pr-context.outputs.pr_number }}" --repo "$GITHUB_REPOSITORY" \
--out .review-draft.md \
--timestamp "${{ steps.now.outputs.value }}" \
--head-sha "${{ steps.pr-context.outputs.head_sha }}" \
--head-sha-short "${{ steps.pr-context.outputs.head_sha_short }}" \
--verified-claims .verified-claims.json \
--candidate-claims .candidate-claims.json \
--vale-findings .vale-findings.json \
--editorial-balance .editorial-balance.json \
--cross-sibling .cross-sibling-discovery.json \
--frontmatter .frontmatter-validation.json \
--hugo-build .hugo-build.json \
--fetched-urls .fetched-urls.json \
--readthrough .readthrough-findings.json \
|| printf '%s\n' \
'## Pre-merge Review — Last updated ${{ steps.now.outputs.value }}' \
'' \
'> [!CAUTION]' \
'> The review composer (compose-review.py) failed to start. Do **not** post the lines below — assemble the review manually per `.claude/commands/docs-review/ci.md` §Fallback (manual assembly): read the pre-step artifacts (`.verified-claims.json`, `.vale-findings.json`, `.editorial-balance.json`, `.hugo-build.json`, `.frontmatter-validation.json`, `.cross-sibling-discovery.json`) and render per `docs-review:references:output-format`. This stub exists so the consumer sees the failure rather than a missing file.' \
'' \
'_(composer stub — see ci.md §Fallback)_' \
> .review-draft.md
# Wall-clock anchor for distinguishing "the Opus step burned its whole
# budget" (deterministic timeout — retrying the same PR fails the same
# way) from "the Opus step died early" (transient error — retry advice
# is legitimate) in the classify-outcome step below.
- name: Record review start
if: steps.check-access.outputs.has_write_access == 'true'
id: review-start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Run Claude Code Review
if: steps.check-access.outputs.has_write_access == 'true'
id: claude-review