-
Notifications
You must be signed in to change notification settings - Fork 1.4k
418 lines (383 loc) · 20.2 KB
/
Copy pathdocs-preview.yaml
File metadata and controls
418 lines (383 loc) · 20.2 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
# This workflow posts a docs preview comment listing every navigable
# page a pull request touches. The preview is served by coder.com's
# branch-preview feature at /docs/@<branch>.
#
# Each page in the list gets its own preview link plus a Markdown
# task-list checkbox, so a reviewer can tick off pages as they review
# them. State is round-tripped across pushes: a checkbox a reviewer
# already ticked stays ticked as long as that page hasn't changed
# since, but flips back to unchecked the moment new content lands on
# that page, since a checked box should mean "I've reviewed the
# current revision," not "I reviewed some earlier revision of this
# page."
#
# The checkbox contract (reset-on-change) matches GitHub's native
# per-file "Viewed" control, but Viewed tracks the raw diff and can't
# deep-link to the rendered coder.com preview. This checklist tracks
# review of the preview page itself, which the platform doesn't
# provide, so the state is reimplemented here rather than reused.
#
# Only pages that resolve to a route in docs/manifest.json get a
# link. Anything else (docs/.style/** contributor tooling, or a page
# that hasn't been wired into navigation yet) is dropped from the list
# entirely, since those pages 404 on the docs site and would confuse
# reviewers.
#
# Branch names are URL-encoded so that names containing slashes or
# other special characters produce working links.
#
# On subsequent pushes (synchronize) the existing comment is updated
# rather than creating a duplicate. If a previous push had eligible
# Markdown files but the current push has none, the stale comment is
# deleted so readers don't follow a dead deep-link. If the PR only
# deletes Markdown files (or only changes non-Markdown files such as
# images or manifest.json), no comment is posted.
name: docs-preview
on:
pull_request:
types:
- opened
- synchronize
- reopened
paths:
- "docs/**"
# docs/.style/** is contributor tooling and never deploys to coder.com.
# Skipping the workflow on .style-only PRs avoids posting a preview
# comment with an empty page list. Mixed PRs still trigger; the
# selection logic below filters .style files out of the preview list.
- "!docs/.style/**"
concurrency:
group: docs-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
docs-preview:
runs-on: ubuntu-latest
permissions:
# Job-level permissions replace (not merge with) the workflow-level
# defaults above, so contents: read has to be repeated here for the
# docs/manifest.json contents-API read below.
contents: read
pull-requests: write # needed for commenting on PRs
steps:
- name: Post docs preview comment
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ github.event.pull_request.head.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# DOCS_PREVIEW_MARKER locates this workflow's own comments.
# STATE_PREFIX carries the last-seen `path -> blob sha` map for
# change detection. Keep this script's map_doc_path, manifest
# filter, and carryover logic in sync with
# test-docs-preview-mapper.sh.
DOCS_PREVIEW_MARKER='<!-- docs-preview -->'
STATE_PREFIX='docs-preview-state:'
# Returns IDs of github-actions[bot] comments on the PR whose
# body contains DOCS_PREVIEW_MARKER.
list_docs_preview_comments() {
gh api --paginate \
"repos/${REPO}/issues/${PR_NUMBER}/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"${DOCS_PREVIEW_MARKER}\")) | .id"
}
# Deletes the existing docs-preview comment (found earlier as
# existing_id) and exits 0, so a stale comment doesn't point
# readers at a dead deep-link. A failed delete is only cosmetic,
# so it logs and exits clean; the next push retries. The upsert
# path uses strict propagation instead, since silent failure
# there would duplicate comments.
cleanup_stale_and_exit() {
if [ -n "$existing_id" ]; then
if gh api --method DELETE \
"repos/${REPO}/issues/comments/${existing_id}"; then
echo "Deleted stale docs preview comment (id=${existing_id})."
else
echo "Failed to delete stale docs preview comment (id=${existing_id}); leaving in place. This is usually a transient API error, and the next push retries the cleanup." >&2
fi
fi
exit 0
}
# Maps a repo path to the docs site URL path.
# docs/README.md -> "" (docs root)
# docs/<dir>/index.md -> "<dir>" (directory index)
# docs/<dir>/README.md -> "<dir>" (directory index)
# docs/<dir>/<file>.md -> "<dir>/<file>"
map_doc_path() {
local doc_path="$1"
local rel="${doc_path#docs/}"
local page_path
case "$rel" in
README.md)
page_path=""
;;
*)
local base dir stripped
base="$(basename "$rel")"
dir="$(dirname "$rel")"
if [ "$dir" = "." ]; then
dir=""
fi
case "$base" in
index.md | README.md)
page_path="$dir"
;;
*)
stripped="${base%.md}"
if [ -z "$dir" ]; then
page_path="$stripped"
else
page_path="${dir}/${stripped}"
fi
;;
esac
;;
esac
printf '%s' "$page_path"
}
# Look up the existing docs-preview comment id up front so both
# the cleanup path and the upsert path can reuse it without
# listing twice. The body is fetched later, just before state
# recovery, to keep the read-modify-write window small.
#
# Keep the strict list separate from the tolerant head. A real
# list failure (network, auth, rate-limit) must propagate under
# set -e; otherwise the upsert treats it as "no comment" and
# posts a duplicate. The `|| true` only absorbs head's SIGPIPE
# on the printf feeding head.
all_comment_ids=$(list_docs_preview_comments)
existing_id=$(printf '%s\n' "$all_comment_ids" | head -n 1) || true
# Fetch the non-removed Markdown files under docs/ (excluding
# docs/.style/**) this PR currently touches, one <filename>\t<sha>
# pair per line. `.sha` is the blob sha of the file's content at
# this push, which is what lets later runs detect "this page
# changed since it was last listed" without a full checkout.
#
# This is intentionally not piped into grep so that a gh-api
# failure (network, auth, rate-limit) propagates immediately
# instead of being swallowed by `|| true`.
#
# `pulls/files` truncates at GitHub's 3000-file ceiling, which
# --paginate does not lift. A PR that changes 3000+ files would
# list only the first 3000; docs PRs never approach that.
changed_tsv=$(gh api --paginate \
"repos/${REPO}/pulls/${PR_NUMBER}/files" \
--jq '.[] | select(.status != "removed") | select(.filename | test("^docs/.*\\.md$")) | select((.filename | test("^docs/\\.style/")) | not) | [.filename, .sha] | @tsv')
if [ -z "$changed_tsv" ]; then
echo "No added/modified Markdown files under docs/ (outside docs/.style/) on this push."
cleanup_stale_and_exit
fi
# Fetch docs/manifest.json at the PR head sha (this job never
# checks the repo out) and collect every object with a "path"
# key, which in the manifest is always a navigable page entry.
# Manifest paths are written "./foo/bar.md" or "foo/bar.md"
# relative to docs/; normalize both to "docs/foo/bar.md" so
# they compare directly against the PR-files filenames above.
#
# Request the raw blob rather than the JSON envelope so the
# read has no 1MB inline-body ceiling, which would otherwise
# return an empty body and silently drop every page.
#
# This makes the manifest an implicit hard dependency of comment
# persistence: if the manifest schema ever drops or renames the
# "path" key, the fetch still succeeds but allowed_paths comes
# back empty, no page is eligible, and the run takes
# cleanup_stale_and_exit, deleting the comment and its checkbox
# state. "Parsed fine, zero matches" is indistinguishable from
# "format changed," so a future manifest refactor must keep this
# extraction in step.
manifest_content=$(gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/docs/manifest.json?ref=${HEAD_SHA}")
allowed_paths=$(printf '%s' "$manifest_content" \
| jq -r '[.. | objects | select(has("path")) | .path] | .[]' \
| sed -E 's#^\./##; s#^#docs/#')
# Intersect the changed-files set with the manifest allowlist.
# A file with no manifest route 404s on the docs site, so drop
# it from the list rather than link to a broken preview.
eligible_tsv=$(printf '%s\n' "$changed_tsv" | while IFS=$'\t' read -r filename sha; do
[ -z "$filename" ] && continue
if printf '%s\n' "$allowed_paths" | grep -qxF "$filename"; then
printf '%s\t%s\n' "$filename" "$sha"
fi
done)
if [ -z "$eligible_tsv" ]; then
echo "No changed Markdown files resolve to a docs/manifest.json route."
echo "(If pages you expect are missing, check docs/manifest.json's schema: an empty allowlist looks identical to no eligible pages.)"
cleanup_stale_and_exit
fi
eligible_json=$(printf '%s\n' "$eligible_tsv" \
| jq -R -s '[splits("\n") | select(length > 0) | split("\t") | {filename: .[0], sha: .[1]}]')
# Fetch the existing comment body now, right before reading its
# checkbox state, so a reviewer's toggle isn't overwritten by a
# stale read taken several API calls earlier. `|| true` keeps a
# transient API error from failing the run; state recovery then
# just treats every page as new.
#
# A reviewer toggle that lands in the small window between this
# read and the PATCH below is lost, but reappears correctly on
# the next push. Accepted limitation, not a bug.
existing_body=""
if [ -n "$existing_id" ]; then
existing_body=$(gh api "repos/${REPO}/issues/comments/${existing_id}" --jq '.body' || true)
if [ -z "$existing_body" ]; then
# A docs-preview comment always contains its body, so an
# empty read against a known id is a transient fetch
# failure, not a legitimately empty comment. State recovery
# will reset every checkbox this push; log a breadcrumb so
# the reset isn't silent (it self-heals on the next push).
echo "Could not read existing comment ${existing_id}; checkbox state resets this push (transient, self-heals)." >&2
fi
fi
# Recover state from the existing comment, if any:
# - old_state: the path -> sha map this workflow wrote the
# last time it updated the comment (hidden marker).
# - old_checked: the path -> checked map read from the
# *live* checkbox glyphs in the comment body, which is
# where a reviewer's manual clicks land (GitHub persists a
# checkbox toggle as an edit to the comment body).
old_state_json="{}"
old_checked_json="{}"
if [ -n "$existing_body" ]; then
old_state_b64=$(printf '%s\n' "$existing_body" | grep -oE "${STATE_PREFIX}[A-Za-z0-9+/=]+" | sed "s/^${STATE_PREFIX}//") || true
if [ -n "$old_state_b64" ]; then
# Guard the decode: a truncated or corrupted marker must
# degrade to "treat every page as new", not kill the run
# (base64 -d and jq both run under set -e). Reject an empty
# decode before the type check: on jq < 1.7 `jq -e` exits 0
# on empty input, so the type check alone would accept an
# empty string and `--argjson old_state ""` would abort the
# run. Require a non-empty result that parses as a JSON
# object, else keep {}.
decoded=$(printf '%s' "$old_state_b64" | base64 -d 2>/dev/null || true)
if [ -n "$decoded" ] && printf '%s' "$decoded" | jq -e 'type == "object"' >/dev/null 2>&1; then
old_state_json="$decoded"
fi
fi
# shellcheck disable=SC2016 # backticks below are literal Markdown code-span delimiters, not command substitution.
old_checked_json=$(printf '%s\n' "$existing_body" \
| grep -oE '^[[:space:]]*- \[[ xX]\] \[`[^`]+`\]' \
| sed -E 's/^[[:space:]]*- \[([ xX])\] \[`([^`]+)`\]/\1\t\2/' \
| jq -R -s '[splits("\n") | select(length > 0) | split("\t") | {(.[1]): (.[0] | test("x"; "i"))}] | add // {}') || true
fi
# Decide each page's checked state: carry the live checkbox
# value forward only if the page's blob sha hasn't changed
# since the last time this workflow wrote the state marker.
# New pages, and pages whose sha moved, start unchecked.
final_rows=$(jq -n \
--argjson eligible "$eligible_json" \
--argjson old_state "$old_state_json" \
--argjson old_checked "$old_checked_json" \
'[
$eligible[] | . as $f |
($old_state[$f.filename] // null) as $prev_sha |
(if $prev_sha != null and $prev_sha == $f.sha
then ($old_checked[$f.filename] // false)
else false
end) as $checked |
{filename: $f.filename, sha: $f.sha, checked: $checked}
] | sort_by(.filename)')
# URL-encode the branch name so slashes and special
# characters don't break the preview URL. The page path is
# left as-is because its components are simple ASCII path
# segments and the slashes between them must be preserved.
encoded_branch=$(jq -rn --arg b "$BRANCH" '$b | @uri')
url_prefix="https://coder.com/docs/@${encoded_branch}"
total_pages=$(printf '%s' "$final_rows" | jq 'length')
# Assemble the comment body for the first N pages: the checklist,
# the hidden base64 state marker, and (when N < total_pages) the
# omitted-pages summary line. Both the checklist and the marker
# derive from the same N rows, so this prints exactly the bytes
# that get posted, which is what lets the caller size the comment
# by measuring rather than estimating.
build_comment_body() {
local n="$1" rows state_json state_b64 checklist="" intro
local filename checked page_path url box omitted
rows=$(printf '%s' "$final_rows" | jq -c --argjson n "$n" '.[:$n]')
state_json=$(printf '%s' "$rows" | jq -c 'map({(.filename): .sha}) | add // {}')
state_b64=$(printf '%s' "$state_json" | base64 -w0)
while IFS=$'\t' read -r filename checked; do
[ -z "$filename" ] && continue
page_path=$(map_doc_path "$filename")
url="$url_prefix"
if [ -n "$page_path" ]; then
url="${url}/${page_path}"
fi
box=" "
if [ "$checked" = "true" ]; then
box="x"
fi
# The backticks are literal Markdown code-span delimiters.
checklist="${checklist}- [${box}] [\`${filename}\`](${url})"$'\n'
done < <(printf '%s' "$rows" | jq -r '.[] | [.filename, (.checked | tostring)] | @tsv')
omitted=$((total_pages - n))
if [ "$omitted" -gt 0 ]; then
checklist="${checklist}"$'\n'"_and ${omitted} more changed page(s) not listed to stay under GitHub's comment size limit. See the [Files tab](https://github.com/${REPO}/pull/${PR_NUMBER}/files) for the full list._"$'\n'
fi
intro="Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here."
printf '## Docs preview\n\n%s\n\n%s\n%s\n<!-- %s%s -->' \
"$intro" "$checklist" "$DOCS_PREVIEW_MARKER" "$STATE_PREFIX" "$state_b64"
}
# GitHub caps a comment body at 65536 characters. Estimating the
# per-page cost drifts from the real size (each page adds a
# checklist line and a base64 state entry whose combined length
# depends on the path), so assemble the real body and measure it
# instead. Keep every page if they all fit; otherwise binary
# search for the largest leading prefix that stays under budget.
# Body size grows monotonically with the page count, so the
# search is well defined. 65000 leaves headroom under the limit.
comment_budget=65000
body_bytes() { LC_ALL=C wc -c; }
if [ "$(build_comment_body "$total_pages" | body_bytes)" -le "$comment_budget" ]; then
keep_pages=$total_pages
else
lo=0
hi=$((total_pages - 1))
keep_pages=0
while [ "$lo" -le "$hi" ]; do
mid=$(((lo + hi) / 2))
if [ "$(build_comment_body "$mid" | body_bytes)" -le "$comment_budget" ]; then
keep_pages=$mid
lo=$((mid + 1))
else
hi=$((mid - 1))
fi
done
fi
# Always list at least one page. The binary search can floor at
# 0 only if a single line exceeds the budget (impossible at real
# path lengths), and an empty list under an "and N more" summary
# would be self-contradicting; a floor of 1 makes that
# unreachable state impossible.
if [ "$keep_pages" -lt 1 ]; then
keep_pages=1
fi
omitted_pages=$((total_pages - keep_pages))
echo "Listing ${keep_pages} of ${total_pages} changed page(s); ${omitted_pages} omitted for comment size."
comment_body=$(build_comment_body "$keep_pages")
# Upsert: PATCH the existing comment if we found one, else
# create it. existing_id is re-derived from a live list on
# every run (never persisted), so a genuinely deleted comment
# isn't found and lands in the create branch below.
#
# Therefore a PATCH failure against a known existing_id almost
# always means the comment still exists and the error is
# transient: never create in that case, or we post a permanent
# duplicate (the write-path sibling of the guarded list
# failure). Fail instead; the next push retries.
if [ -n "$existing_id" ]; then
if gh api --method PATCH \
"repos/${REPO}/issues/comments/${existing_id}" \
--raw-field body="$comment_body"; then
echo "Updated existing docs preview comment (id=${existing_id})."
else
echo "Failed to update docs preview comment ${existing_id}; leaving it in place to avoid a duplicate. This is usually a transient API error, and the next push will retry." >&2
exit 1
fi
else
gh pr comment "${PR_NUMBER}" \
--repo "${REPO}" \
--body "$comment_body"
echo "Created new docs preview comment."
fi