Skip to content

Commit a21b338

Browse files
authored
Merge pull request #27 from hotdata-dev/feat/workflow-smoke-test
test(ci): smoke-run the review workflow in CI
2 parents 8b04393 + 6ba8072 commit a21b338

3 files changed

Lines changed: 136 additions & 3 deletions

File tree

.github/workflows/claude-pr-review.yml

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,38 @@ name: Claude PR Review
33
on:
44
pull_request:
55
types: [opened, synchronize, ready_for_review, reopened]
6-
6+
# Callable so this repository's own CI can run this file and prove it starts. That is not a
7+
# theoretical worry: an org ruleset injects this workflow into every repo resolved from
8+
# `main`, so a pull request here is reviewed by main's copy and never by the copy it is
9+
# changing. Twice in one day a file that Actions refuses to parse merged green -- an empty
10+
# expression delimiter in a shell comment, then a `run:` block past the 21,000-character
11+
# expression limit -- because nothing in CI had ever executed the version under review.
12+
#
13+
# tests.yml calls this with `uses: ./...`, which resolves from the calling commit, so the
14+
# smoke run is the pull request's copy. A copy Actions cannot load fails the caller too, which
15+
# is the whole point: "does it start" is exactly what both outages got wrong.
16+
workflow_call:
17+
inputs:
18+
dry_run:
19+
description: >-
20+
Everything except the review itself. Set by tests.yml, never on the production path,
21+
where `inputs` is empty and this reads as false.
22+
type: boolean
23+
default: false
24+
25+
# Distinct groups for the smoke run and the live review, because a called workflow inherits the
26+
# caller's `github` context: both would otherwise compute pr-review-<same number> on the same
27+
# pull request, and cancel-in-progress would have each cancelling the other. A smoke test that
28+
# kills real reviews is worse than no smoke test.
29+
#
30+
# The run_id fallback is for the other event. tests.yml also calls this on `push: branches:
31+
# [main]`, where there is no pull request and the number expands to nothing, so without it every
32+
# main-push run shares one constant group -- and cancel-in-progress on a *called* workflow does
33+
# not just drop the smoke job, it takes the caller's whole Tests run with it. Two merges close
34+
# together would leave the earlier commit with no test signal on main. run_id is the caller's,
35+
# and unique per run.
736
concurrency:
8-
group: pr-review-${{ github.event.pull_request.number }}
37+
group: pr-review-${{ inputs.dry_run && 'smoke' || 'live' }}-${{ github.event.pull_request.number || github.run_id }}
938
cancel-in-progress: true
1039

1140
jobs:
@@ -509,8 +538,19 @@ jobs:
509538
PR_TITLE: ${{ github.event.pull_request.title }}
510539
PR_BODY: ${{ github.event.pull_request.body }}
511540

541+
# The only step a dry run skips, and the only one it needs to: every step after this one
542+
# is already gated on this step's outcome or its outputs, so skipping it silences the whole
543+
# write side of the workflow without a second condition anywhere.
544+
#
545+
# Reduce execution log -- needs steps.review.outputs.execution_file, empty when skipped
546+
# Upload tool usage -- needs steps.tool-usage.outcome == 'success', which is 'skipped'
547+
# Notify on failure -- needs outcome 'failure' or 'cancelled', and this is 'skipped'
548+
#
549+
# So a smoke run posts no review, no comment and no artifact. Everything before this step
550+
# still runs against the live API: the app token, the cross-repo prompt checkout, and the
551+
# nine context reads with the job's real permissions.
512552
- uses: anthropics/claude-code-action@v1
513-
if: github.event.pull_request.user.login != 'dependabot[bot]'
553+
if: github.event.pull_request.user.login != 'dependabot[bot]' && !inputs.dry_run
514554
id: review
515555
continue-on-error: true
516556
with:

.github/workflows/tests.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,41 @@ jobs:
5454

5555
- name: Context step end to end
5656
run: tests/context-step-test.sh
57+
58+
# Runs claude-pr-review.yml itself, from this commit, with the review step skipped. The checks
59+
# above all read the workflow as text; this one hands it to Actions and asks whether it starts.
60+
#
61+
# That is the gap the last two outages went through. An org ruleset injects the review workflow
62+
# into every repo resolved from `main`, so the review on a pull request here comes from main's
63+
# copy -- never from the copy the pull request is changing. Both times, a file Actions refuses
64+
# to parse merged with this suite green, and the org lost reviews until someone noticed: an
65+
# empty expression delimiter inside a shell comment, then a `run:` block over the
66+
# 21,000-character expression limit. Neither is visible to yaml.safe_load, to bash, or to
67+
# actionlint, and enumerating the next limit ahead of time is a game with no end. Executing the
68+
# file has no such gap -- if Actions will not load it, this job cannot start, and CI is red.
69+
#
70+
# `uses: ./` resolves from the calling commit rather than from the default branch, which is what
71+
# makes this the pull request's copy and not main's.
72+
smoke:
73+
name: Review workflow starts
74+
# The same set the called job declares. A caller cannot grant a reusable workflow more than
75+
# it holds, and the point of this job is to exercise the real thing: the nine context reads
76+
# each need their permission, and a missing one degrades silently into a "could not read"
77+
# sentence rather than failing. pull-requests: write is unused on this path -- the only two
78+
# steps that write are skipped with the review step -- but it is what production runs with,
79+
# and a smoke test that runs with a different token is testing a different workflow.
80+
permissions:
81+
contents: read
82+
pull-requests: write
83+
id-token: write
84+
actions: read
85+
issues: read
86+
checks: read
87+
statuses: read
88+
uses: ./.github/workflows/claude-pr-review.yml
89+
with:
90+
dry_run: true
91+
# The GitHub App private key, for the token step and the cross-repo prompt checkout. Both run
92+
# in a dry run, so a broken sparse-checkout or an expired key surfaces here rather than in the
93+
# org.
94+
secrets: inherit

tests/workflow-lint-test.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ cd "$(dirname "$0")/.."
2525

2626
failures=0
2727
WORKFLOW_FILE=.github/workflows/claude-pr-review.yml
28+
TESTS_FILE=.github/workflows/tests.yml
2829

2930
# The delimiter, assembled rather than written, so this file does not trip its own scan.
3031
OPEN="\${$(printf '%s' '{')"
@@ -97,6 +98,30 @@ for wf in "${WORKFLOWS[@]}"; do
9798
fi
9899
done
99100

101+
# cancel-in-progress cancels whatever else is in the group, so the group needs a key that is
102+
# never empty on any event the workflow accepts. It is keyed on the pull request number, and
103+
# tests.yml calls this workflow on `push: branches: [main]` as well, where there is no pull
104+
# request and that key expands to nothing -- collapsing every main-push run into one constant
105+
# group. Two merges landing close together would then cancel each other, and because the
106+
# cancellation lands on a *called* workflow it takes the caller's whole Tests run with it, so a
107+
# commit on main silently loses its test signal.
108+
#
109+
# Checked as a property of the expression rather than by evaluating it: the PR number must be
110+
# followed by a `||` fallback, so the group stays unique when there is no pull request.
111+
group_line=$(grep -n '^ group:' "$WORKFLOW_FILE" | head -1)
112+
if [ -z "$group_line" ]; then
113+
echo "FAIL $WORKFLOW_FILE has no workflow-level concurrency group; this check proves nothing"
114+
failures=$((failures + 1))
115+
elif ! printf '%s\n' "$group_line" | grep -q 'pull_request\.number[[:space:]]*||'; then
116+
echo "FAIL the concurrency group keys on the pull request number with no fallback, so on a"
117+
echo " push to main it collapses to a constant and concurrent merges cancel each other's"
118+
echo " Tests run:"
119+
printf '%s\n' "$group_line" | sed 's/^/ /'
120+
failures=$((failures + 1))
121+
else
122+
echo "ok the concurrency group stays unique when there is no pull request"
123+
fi
124+
100125
# Every read the context step makes needs a permission declared on the job, because the job
101126
# declares `permissions:` explicitly and anything unlisted is `none`. That failure is silent
102127
# by design -- each block degrades to its "could not read" sentence -- so a missing line here
@@ -142,6 +167,36 @@ check_permission "statusCheckRollup" statuses "the StatusContext half of the CI
142167
check_permission "/compare/" contents "the since-last-review comparison"
143168
check_permission "/pulls/" pull-requests "the PR reads"
144169

170+
# The table above forces a new API call in the context step to declare its permission on the
171+
# review job. That does nothing for the smoke job in tests.yml, which calls the review workflow
172+
# and has to grant the same set by hand: a caller cannot give a reusable workflow more than it
173+
# holds, so a permission added on one side and not the other fails the smoke job with Actions'
174+
# "is requesting 'x: read', but is only allowed 'x: none'" -- loud, but on a file that looks
175+
# unrelated to the change that caused it. Asserting the two match keeps the claim in tests.yml's
176+
# comment true by construction instead of by review.
177+
#
178+
# Name and value both, so pull-requests: write degrading to read is caught too.
179+
perm_pairs() {
180+
awk '/^ permissions:$/ { p = 1; next }
181+
p && /^ [a-z-]+:[[:space:]]/ { print $1, $2 }
182+
p && /^ [a-z]/ { exit }' "$1" | sort
183+
}
184+
review_perms=$(perm_pairs "$WORKFLOW_FILE")
185+
smoke_perms=$(perm_pairs "$TESTS_FILE")
186+
if [ -z "$review_perms" ] || [ -z "$smoke_perms" ]; then
187+
echo "FAIL a permissions block came back empty (review: $(printf '%s' "$review_perms" | wc -l)," \
188+
"smoke: $(printf '%s' "$smoke_perms" | wc -l)); the parity check proves nothing"
189+
failures=$((failures + 1))
190+
elif [ "$review_perms" != "$smoke_perms" ]; then
191+
echo "FAIL the smoke job in $TESTS_FILE does not grant what the review job declares."
192+
echo " A caller cannot grant a reusable workflow more than it holds, so the smoke job"
193+
echo " fails until both sides agree. Difference (< review job, > smoke job):"
194+
diff <(printf '%s\n' "$review_perms") <(printf '%s\n' "$smoke_perms") | sed 's/^/ /'
195+
failures=$((failures + 1))
196+
else
197+
echo "ok the smoke job grants exactly what the review job declares"
198+
fi
199+
145200
# The scan above is a backstop for one class. actionlint checks the schema, the expression
146201
# grammar, and the shell; run it when it is on PATH. shellcheck findings are excluded because
147202
# the run blocks here intentionally use unquoted word splitting for job ids.

0 commit comments

Comments
 (0)