Skip to content

Commit f36e7a6

Browse files
committed
fix(review): grant checks and statuses read for the CI rollup
1 parent 0947fcb commit f36e7a6

4 files changed

Lines changed: 191 additions & 13 deletions

File tree

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,26 @@ jobs:
1717
contents: read
1818
pull-requests: write
1919
id-token: write
20-
# actions: read for the failing-job log excerpts, issues: read for the PR
21-
# conversation (/issues/{n}/comments is the PR's own comment thread). The job
22-
# declares permissions explicitly, so anything not listed here is `none` and both
23-
# reads 403 -- silently, into "(log unavailable)" and "Could not read PR
24-
# conversation comments.", since every block in the context step is guarded.
20+
# One line per extra read the context step makes. The job declares permissions
21+
# explicitly, so anything not listed here is `none` and the read fails -- quietly, into
22+
# that block's "could not read" sentence, because every block is guarded.
23+
#
24+
# actions: read -- the failing-job log excerpts (/actions/jobs/{id}/logs)
25+
# issues: read -- the PR conversation (/issues/{n}/comments is the PR's own thread)
26+
# checks: read -- the CheckRun half of statusCheckRollup
27+
# statuses: read -- the StatusContext half of it (Aikido, Terraform Cloud, anything
28+
# posting a commit status rather than a check run)
29+
#
30+
# checks and statuses were both missing on the first production run, and the shape of
31+
# that failure is worth remembering: `gh pr view --json statusCheckRollup` resolves one
32+
# GraphQL field needing both, so it fails the whole rollup with "Resource not accessible
33+
# by integration" rather than omitting the half it may not read. The CI block -- the
34+
# reason this step exists -- rendered "Could not read check status." on a PR with 12
35+
# checks. A personal access token cannot reproduce it, because it has every scope.
2536
actions: read
2637
issues: read
38+
checks: read
39+
statuses: read
2740
steps:
2841
- uses: actions/checkout@v6.0.2
2942
with:

‎.github/workflows/tests.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ jobs:
1616
with:
1717
fetch-depth: 1
1818

19+
# First, because it is the check whose absence took the org down: a workflow that
20+
# Actions cannot parse never starts, so the required check never reports and every pull
21+
# request in the org blocks. Nothing else in this suite can see that -- the file is
22+
# valid YAML and the shell runs it happily.
23+
- name: Workflow validity
24+
run: tests/workflow-lint-test.sh
25+
1926
- name: Review cycle counter
2027
run: tests/review-cycle-test.sh
2128

‎tests/context-step-test.sh‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,31 @@ extract_step() {
3636
' "$WORKFLOW"
3737
}
3838

39+
# Assembled rather than written literally: this file must not contain the delimiter either,
40+
# or the grep below finds itself when someone greps the test suite for it.
41+
EXPR_OPEN="\${$(printf '%s' '{')"
3942
extract_step \
40-
| sed -e 's/\${{ github.event.pull_request.number }}/"$STUB_PR"/g' \
41-
-e 's/\${{ github.repository }}/"$STUB_REPO"/g' \
43+
| sed -e "s/${EXPR_OPEN} github.event.pull_request.number }}/\"\$STUB_PR\"/g" \
44+
-e "s/${EXPR_OPEN} github.repository }}/\"\$STUB_REPO\"/g" \
4245
> "$WORK/step.sh"
4346

4447
if [ "$(wc -l < "$WORK/step.sh")" -lt 100 ]; then
4548
echo "FAIL: extracted step script is only $(wc -l < "$WORK/step.sh") lines; the awk" \
4649
"extraction no longer matches the workflow" >&2
4750
exit 1
4851
fi
49-
# Comments are allowed to discuss ${{ }}; code is not allowed to contain one this test
50-
# does not substitute, because an unsubstituted expression would run here as literal text
51-
# and hide whatever the real workflow splices in.
52-
if grep -vE '^[[:space:]]*#' "$WORK/step.sh" | grep -q '\${{'; then
53-
echo "FAIL: the step gained a \${{ }} interpolation this test does not substitute:" >&2
54-
grep -nE '\${{' "$WORK/step.sh" | grep -vE ':[[:space:]]*#' >&2
52+
# No Actions expression delimiter may survive anywhere in the extracted script -- not in
53+
# code, and not in a comment either. The comment exemption this check used to carry is what
54+
# shipped a broken workflow to every repo in the org: a shell comment reading "never a
55+
# ${OPEN} interpolation" parses as an *empty expression*, which Actions rejects outright, so
56+
# the workflow never started, no required check ever reported, and every PR in the org sat
57+
# behind "Please close and reopen the PR to trigger this workflow". bash does not care what
58+
# is in a comment; the Actions expression parser does.
59+
if grep -q "$EXPR_OPEN" "$WORK/step.sh"; then
60+
echo "FAIL: an Actions expression delimiter survives in the extracted step script." >&2
61+
echo " Either this test needs to substitute it, or -- if it is inside a comment --" >&2
62+
echo " the comment has to stop spelling the delimiter out." >&2
63+
grep -n "$EXPR_OPEN" "$WORK/step.sh" >&2
5564
exit 1
5665
fi
5766

‎tests/workflow-lint-test.sh‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Asserts the workflows are valid to GitHub, not merely valid YAML.
4+
#
5+
# This file exists because of an outage. A shell comment inside a `run:` block explained that
6+
# PR title and body deliberately avoid an Actions expression -- and spelled the delimiter out
7+
# to say so. Actions parses those delimiters everywhere in a workflow file, including inside
8+
# a run block's shell comments, and an empty pair is a syntax error. The workflow became
9+
# unparseable, so every run started with zero jobs, the required check never reported, and
10+
# every open pull request across the org sat behind "Please close and reopen the PR to
11+
# trigger this workflow" until the comment was reworded.
12+
#
13+
# Nothing in the previous suite could see it. `yaml.safe_load` accepts the file, the shell
14+
# runs the comment happily, and tests/context-step-test.sh explicitly *exempted* comments
15+
# from its delimiter check. The lesson is narrow and worth encoding: YAML-valid is not
16+
# Actions-valid, and the gap is expression syntax.
17+
#
18+
# actionlint is the real check and runs when available. The scan below is the part that
19+
# always runs, because CI must not depend on a tool being installed to catch the specific
20+
# defect that caused an outage.
21+
22+
set -euo pipefail
23+
24+
cd "$(dirname "$0")/.."
25+
26+
failures=0
27+
WORKFLOW_FILE=.github/workflows/claude-pr-review.yml
28+
29+
# The delimiter, assembled rather than written, so this file does not trip its own scan.
30+
OPEN="\${$(printf '%s' '{')"
31+
CLOSE="$(printf '%s' '}')}"
32+
33+
for wf in .github/workflows/*.yml; do
34+
# The distinction that caused the outage, and the one the scan has to make: a delimiter in
35+
# a *YAML* comment is stripped by the YAML parser and never reaches Actions, while the same
36+
# characters inside a block scalar are part of the string value and are parsed. So block
37+
# scalars are scanned line for line, comments included, and outside them YAML comment lines
38+
# are skipped. Scanning raw text without that distinction reports the harmless env: comment
39+
# that has sat in this workflow since before any of this and would train the reader to
40+
# ignore the check.
41+
bad=$(awk -v open="$OPEN" -v shut="$CLOSE" '
42+
function scan(line, where, i, rest, j, expr) {
43+
while ((i = index(line, open)) > 0) {
44+
rest = substr(line, i + length(open))
45+
j = index(rest, shut)
46+
if (j == 0) { print FILENAME ":" FNR ": unterminated expression (" where ")"; return }
47+
expr = substr(rest, 1, j - 1)
48+
gsub(/^[ \t]+|[ \t]+$/, "", expr)
49+
if (expr == "")
50+
print FILENAME ":" FNR ": empty expression in " where \
51+
" -- Actions rejects the whole workflow"
52+
else if (expr !~ /^[A-Za-z_0-9.,()!<>=&|*'\''"\[\] \t-]+$/)
53+
print FILENAME ":" FNR ": odd expression in " where ": " expr
54+
line = substr(rest, j + length(shut))
55+
}
56+
}
57+
{
58+
indent = match($0, /[^ ]/) - 1
59+
if (indent < 0) indent = length($0)
60+
if (in_block) {
61+
if ($0 ~ /^[ \t]*$/) next
62+
if (indent < block_indent) in_block = 0
63+
else { scan($0, "a block scalar (a shell comment counts)"); next }
64+
}
65+
if ($0 ~ /:[ \t]*[|>][-+0-9]*[ \t]*$/) {
66+
in_block = 1
67+
block_indent = indent + 1
68+
next
69+
}
70+
# Outside a block scalar a whole-line YAML comment is invisible to Actions.
71+
if ($0 ~ /^[ \t]*#/) next
72+
scan($0, "a value")
73+
}
74+
' "$wf") || {
75+
echo "FAIL the expression scan itself failed on $wf; the scan proves nothing"
76+
failures=$((failures + 1))
77+
continue
78+
}
79+
if [ -n "$bad" ]; then
80+
echo "FAIL $wf has invalid Actions expressions:"
81+
printf '%s\n' "$bad" | sed 's/^/ /'
82+
failures=$((failures + 1))
83+
else
84+
echo "ok $wf expressions are well formed"
85+
fi
86+
done
87+
88+
# Every read the context step makes needs a permission declared on the job, because the job
89+
# declares `permissions:` explicitly and anything unlisted is `none`. That failure is silent
90+
# by design -- each block degrades to its "could not read" sentence -- so a missing line here
91+
# does not turn a run red, it just quietly empties the block. Two were missing on the first
92+
# production run (checks and statuses), and no local test could see it: a personal access
93+
# token has every scope, so the step works on a laptop and fails on the runner.
94+
#
95+
# The table is endpoint-shape to permission. It is deliberately coarse; the point is that
96+
# adding a new API call to the step forces a decision about its permission.
97+
step_script=$(awk '
98+
/^ - name: Gather review context$/ { in_step = 1 }
99+
in_step && /^ run: \|$/ { in_run = 1; next }
100+
in_run && /^ [a-z]/ { exit }
101+
in_run { print }
102+
' "$WORKFLOW_FILE")
103+
declared=$(awk '/^ permissions:$/ { p = 1; next } p && /^ [a-z-]+:/ { print $1 } p && /^ [a-z]/ { exit }' \
104+
"$WORKFLOW_FILE" | tr -d ':')
105+
106+
# check <pattern> <permission> <what it is for>
107+
check_permission() {
108+
printf '%s\n' "$step_script" | grep -qF -- "$1" || return 0
109+
if printf '%s\n' "$declared" | grep -qx "$2"; then
110+
echo "ok $2 is declared for $3"
111+
else
112+
echo "FAIL the step calls $3 but the job never grants $2: read"
113+
failures=$((failures + 1))
114+
fi
115+
}
116+
117+
check_permission "/actions/jobs/" actions "the failing-job log excerpts"
118+
check_permission "/issues/" issues "the PR conversation comments"
119+
check_permission "statusCheckRollup" checks "the CheckRun half of the CI rollup"
120+
check_permission "statusCheckRollup" statuses "the StatusContext half of the CI rollup"
121+
check_permission "/compare/" contents "the since-last-review comparison"
122+
check_permission "/pulls/" pull-requests "the PR reads"
123+
124+
# The scan above is a backstop for one class. actionlint checks the schema, the expression
125+
# grammar, and the shell; run it when it is on PATH. shellcheck findings are excluded because
126+
# the run blocks here intentionally use unquoted word splitting for job ids.
127+
if command -v actionlint >/dev/null 2>&1; then
128+
if out=$(actionlint .github/workflows/*.yml 2>&1); then
129+
echo "ok actionlint reports no findings"
130+
else
131+
remaining=$(printf '%s\n' "$out" | grep -v 'shellcheck reported' || true)
132+
if printf '%s\n' "$remaining" | grep -qE '\[(expression|syntax-check|events|workflow-call)\]'; then
133+
echo "FAIL actionlint reports errors that would stop the workflow from starting:"
134+
printf '%s\n' "$remaining" | grep -E '\[(expression|syntax-check|events|workflow-call)\]' \
135+
| sed 's/^/ /'
136+
failures=$((failures + 1))
137+
else
138+
echo "ok actionlint reports no startup-fatal findings"
139+
fi
140+
fi
141+
else
142+
echo "skip actionlint not installed; only the expression scan ran"
143+
fi
144+
145+
if [ "$failures" -ne 0 ]; then
146+
echo "$failures test(s) failed"
147+
exit 1
148+
fi
149+
echo "all tests passed"

0 commit comments

Comments
 (0)