-
Notifications
You must be signed in to change notification settings - Fork 1.4k
566 lines (558 loc) · 26.1 KB
/
Copy pathdeploy-docs.yaml
File metadata and controls
566 lines (558 loc) · 26.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
name: Update coder.com/docs
# Triggers updates to the public docs at coder.com/docs from three
# sources:
#
# * push to main or release/* (docs/** only): markdown edits land in
# search and ISR within seconds.
# * release.published: when a stable vX.Y.Z release ships on this
# repo, the workflow translates the tag to its release/X.Y branch
# and reindexes. Eliminates the manual workflow_dispatch step from
# the mainline rotation. Prereleases and non-semver tags are
# skipped. See DOCS-327.
# * workflow_dispatch: operator-driven, with explicit action and ref.
#
# One preflight job (`changes`) feeds two parallel sibling jobs so that
# search records, the static cache, and any new routes register at the
# same time:
#
# 1. algolia-and-isr: HMAC-signed POST to coder.com/api/algolia-docs-sync.
# The handler re-extracts records for the (corpus, ref) pair and
# atomically replaces the slice of the Algolia `docs` index, then
# calls `res.revalidate(p)` for every navigable manifest entry to
# refresh Vercel's static-page cache without a full rebuild. Runs
# on every docs/** push.
#
# 2. vercel-rebuild: fires the Vercel deploy hook for a full
# build+deploy. Only runs when docs/manifest.json changed, since a
# manifest change can introduce or remove routes that Next.js's
# `getStaticPaths` only re-evaluates on a full rebuild.
#
# Markdown-only edits hit only path 1 and surface in seconds. Manifest
# edits hit both paths in parallel; the ISR revalidate is harmless
# against the previous deployment while the new build is in flight,
# and Vercel only swaps to the new build atomically when ready.
#
# https://vercel.com/docs/deploy-hooks#triggering-a-deploy-hook
# See coder/coder.com/src/pages/api/algolia-docs-sync.ts.
on:
push:
branches:
- main
- "release/*"
paths:
# Intentionally only docs/**. Edits to this workflow file must not
# auto-trigger a production reindex; use workflow_dispatch instead.
# See DOCS-121 (incident) and DOCS-124 (fix).
#
# docs/.style/** is contributor tooling and never deploys to
# coder.com/docs. Negating it here skips the workflow on .style-only
# commits. GitHub Actions only suppresses when every changed file
# matches a negation, so mixed commits still trigger; the surgical
# diff step below drops .style paths from the payload.
- "docs/**"
- "!docs/.style/**"
release:
# Fires when a draft release is published, when a release goes from
# prerelease to non-prerelease, or when a release is created already
# published. The Compute step below translates the published tag
# (vX.Y.Z) into its release/X.Y branch and skips prereleases. See
# DOCS-327 for the rotation context that motivated this trigger.
types: [published]
workflow_dispatch:
inputs:
action:
description: "Algolia action to perform"
required: true
type: choice
default: index
options:
- index
- delete
ref:
description: "Branch to (re)index or delete (e.g. main, release/2.32). Defaults to the workflow's checkout ref."
required: false
type: string
permissions:
contents: read
# Do not cancel in-progress runs. Each run's `changes` job diffs the
# event's own (before, after) SHA pair, so two rapid pushes produce two
# non-overlapping surgical-mode requests. Cancelling the first run
# would silently drop its diff: the second run only sees its own pair,
# never sees the cancelled run's paths, and the dropped pages would
# stay stale until the next whole-branch reindex (manifest change,
# >50-file push, or manual workflow_dispatch). Runs are lightweight
# (shell + curl, ~2 minutes), so overlapping runs are cheap.
concurrency:
group: deploy-docs-${{ github.ref }}
cancel-in-progress: false
jobs:
# Detect what changed so the dependent jobs know:
# - whether a Vercel full rebuild is needed (manifest changed), and
# - which markdown pages to surgically reindex (the changed set).
#
# Outputs:
# manifest_changed: "true" | "false"
# paths_json: a JSON array of {path, status} objects, or "[]"
# when no markdown changes are eligible for
# surgical mode (manifest-only push, an
# uncomputable diff, a non-push event
# (workflow_dispatch or release.published),
# or a diff that exceeds the surgical-mode cap).
# An empty array tells the handler to fall back
# to whole-branch reindex.
changes:
runs-on: ubuntu-latest
outputs:
manifest_changed: ${{ steps.diff.outputs.manifest_changed }}
paths_json: ${{ steps.diff.outputs.paths_json }}
steps:
- name: Compute changed-files signal
id: diff
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.sha }}
run: |
set -euo pipefail
emit_whole_branch_fallback() {
# Tells the algolia-and-isr job to operate in whole-branch
# mode by sending an empty paths array. The handler treats
# the absence of paths (or an empty list) as "reindex
# everything for this (corpus, ref)".
echo "paths_json=[]" >> "$GITHUB_OUTPUT"
}
# Non-push events (workflow_dispatch, release.published)
# have no diff range; treat as "manifest unchanged" so the
# manual or release-triggered reindex doesn't fire a Vercel
# rebuild it didn't ask for, and as whole-branch so the
# resulting reindex is exhaustive.
if [ "$EVENT_NAME" != "push" ]; then
echo "manifest_changed=false" >> "$GITHUB_OUTPUT"
emit_whole_branch_fallback
exit 0
fi
# First push to a brand-new branch has BEFORE_SHA = all zeros.
# In that edge case we conservatively assume the manifest is
# part of the initial state and trigger a full rebuild + a
# whole-branch reindex.
if [ -z "${BEFORE_SHA:-}" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "manifest_changed=true" >> "$GITHUB_OUTPUT"
emit_whole_branch_fallback
exit 0
fi
# We don't need a full checkout for `git diff` against two
# known SHAs. A shallow fetch of just those two commits is
# enough.
git init -q
git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git"
GIT_ERR=$(mktemp)
if ! git -c protocol.version=2 fetch --depth=1 origin "$BEFORE_SHA" "$AFTER_SHA" 2>"$GIT_ERR"; then
# Fall back to whole-branch if the shallow fetch failed
# (e.g. force-push rewrote history). Surfacing the git
# stderr line in the warning lets operators diagnose
# network or auth failures without reproducing the fetch
# manually.
FIRST_ERR=$(head -1 "$GIT_ERR" 2>/dev/null || true)
echo "::warning::Could not fetch BEFORE_SHA=$BEFORE_SHA: ${FIRST_ERR:-unknown}; assuming manifest changed"
echo "manifest_changed=true" >> "$GITHUB_OUTPUT"
emit_whole_branch_fallback
exit 0
fi
# Manifest signal.
if git diff --name-only "$BEFORE_SHA" "$AFTER_SHA" -- docs/manifest.json | grep -q .; then
echo "manifest_changed=true" >> "$GITHUB_OUTPUT"
# Manifest changes can rename or restructure routes, so
# surgical mode is not safe; a per-path delete keyed off
# the new canonical URL would miss records under old URLs.
# Whole-branch reindex is the right behavior here.
emit_whole_branch_fallback
exit 0
else
echo "manifest_changed=false" >> "$GITHUB_OUTPUT"
fi
# Surgical mode: emit the changed markdown set as a JSON
# array of {path, status} objects. We use --name-status -z
# so the handler can distinguish modified/added (re-extract
# + save) from deleted/renamed-old-side (delete only), and
# so paths containing whitespace or quotes survive intact.
DIFF_FILE=$(mktemp)
# 'docs/**/*.md' to keep markdown-only paths, ':(exclude)docs/.style/**'
# so contributor-tooling pages never reach the surgical-reindex payload
# on mixed commits. The trigger filter already short-circuits .style-only
# pushes; this is defense in depth.
git diff --name-status -z "$BEFORE_SHA" "$AFTER_SHA" -- 'docs/**/*.md' ':(exclude)docs/.style/**' > "$DIFF_FILE"
# Parse the NUL-delimited diff into <path>\t<status> lines.
# `--name-status -z` uses NUL between fields and between
# records, with a special twist for renames: the record is
# `R<n>\0<old>\0<new>\0`, three NUL-delimited fields instead
# of two. Status codes: A=added, M=modified, T=type-changed
# (treated as modified), D=deleted, R<n>=renamed (we index
# the new path since that is the live route). Unknown codes
# log a warning and are skipped; a single awk handles both
# the parsing and the count so the two cannot disagree.
#
# Tested in test-deploy-docs-diff.sh. Keep that script in
# sync with any changes to this block.
PARSED=$(mktemp)
awk -v RS='\0' '
function emit(path, status) {
printf "%s\t%s\n", path, status
}
{
code = substr($0, 1, 1)
if (code == "A") { getline; emit($0, "added"); next }
if (code == "M") { getline; emit($0, "modified"); next }
if (code == "T") { getline; emit($0, "modified"); next }
if (code == "D") { getline; emit($0, "deleted"); next }
if (code == "R") {
# R<similarity>\0<old>\0<new>\0
getline old_path
getline new_path
emit(new_path, "renamed")
next
}
if ($0 != "") {
# Unknown status code. Consume the path field so the
# record alignment stays correct, then warn.
unknown_code = $0
getline unknown_path
printf "::warning::Unknown git diff status %s for %s; skipping.\n", unknown_code, unknown_path > "/dev/stderr"
}
}
' "$DIFF_FILE" > "$PARSED"
# Count is derived from the emitter output, so the count and
# the JSON payload cannot diverge by construction (DEREM-21).
CHANGED=$(wc -l < "$PARSED" | tr -d ' ')
if [ "$CHANGED" -eq 0 ]; then
# Markdown-only path filter on the trigger means we should
# only get here on edits to non-markdown files under docs/
# (e.g., images). Whole-branch reindex is overkill for
# those, but it is also harmless and avoids a special case;
# an empty paths array makes the handler skip both the
# save and the revalidate when no manifest entry maps to
# the changed file.
emit_whole_branch_fallback
exit 0
fi
# Cap at 50 changed files. Above that a whole-branch reindex
# is faster (one deleteBy + one saveObjects vs N deleteBy
# calls), and the surgical-mode payload also stays well under
# GitHub Actions' output size limit.
if [ "$CHANGED" -gt 50 ]; then
echo "::notice::$CHANGED markdown files changed; falling back to whole-branch reindex (cap is 50 for surgical mode)"
emit_whole_branch_fallback
exit 0
fi
# jq -Rcn slurps the <path>\t<status> lines and handles JSON
# escaping for quotes, backslashes, and any other special
# characters in the path.
PATHS_JSON=$(jq -Rcn '
[ inputs
| split("\t")
| { path: .[0], status: .[1] }
]
' < "$PARSED")
# Defense in depth: fail loudly if jq could not parse what
# we built. jq -c already validates structure; this catches
# the empty-stdin edge case.
if [ -z "$PATHS_JSON" ] || [ "$PATHS_JSON" = "null" ]; then
PATHS_JSON='[]'
fi
echo "paths_json=$PATHS_JSON" >> "$GITHUB_OUTPUT"
echo "Surgical mode: $CHANGED path(s) changed."
# Path 1: always run. Notifies coder.com to refresh Algolia records
# and ISR-revalidate the affected pages.
algolia-and-isr:
runs-on: ubuntu-latest
needs: changes
steps:
- name: Compute action and ref
id: input
env:
INPUT_ACTION: ${{ inputs.action }}
INPUT_REF: ${{ inputs.ref }}
GITHUB_REF_NAME: ${{ github.ref_name }}
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
run: |
set -euo pipefail
ACTION=""
REF=""
# release.published path: translate a stable vX.Y.Z tag into
# its release/X.Y branch and let the rest of the step
# validate. Skip prereleases and any tag that does not match
# the plain semver shape; backports (vX.Y.<patch>) are
# in-scope because they may carry doc updates worth
# reindexing. See DOCS-327. The handler's allowlist gates the
# downstream POST, so an unsupported minor still no-ops
# rather than reindexing something we did not intend.
#
# Tested in test-deploy-docs-release.sh. Keep that script in
# sync with any changes to this block.
if [ "${EVENT_NAME:-}" = "release" ]; then
if [ "${RELEASE_PRERELEASE:-false}" = "true" ]; then
echo "::notice::Skipping prerelease ${RELEASE_TAG:-<unknown>}; no docs reindex."
exit 0
fi
if [[ "${RELEASE_TAG:-}" =~ ^v([0-9]+)\.([0-9]+)\.[0-9]+$ ]]; then
ACTION="index"
REF="release/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
echo "::notice::Release ${RELEASE_TAG} resolved to ref ${REF}."
else
echo "::notice::Skipping ${RELEASE_TAG:-<unknown>}: not a plain vX.Y.Z release tag."
exit 0
fi
fi
ACTION="${ACTION:-${INPUT_ACTION:-index}}"
REF="${REF:-${INPUT_REF:-$GITHUB_REF_NAME}}"
# Reject newlines/carriage returns in either input. GitHub
# Actions parses GITHUB_OUTPUT line-by-line with last-writer-
# wins, so a newline in $REF would let an operator dispatch
# `release/x\naction=delete\nref=main` past the validation
# below (the case `*` glob matches the multi-line string),
# then have `echo "ref=$REF" >> $GITHUB_OUTPUT` write three
# lines whose effective outputs are `action=delete ref=main`.
# `inputs.ref` is a single-line UI field; the REST API will
# accept anything. Reject embedded newlines explicitly.
case "$ACTION" in
*[$'\n\r']*)
echo "::error::action must not contain newlines."
exit 1
;;
esac
case "$REF" in
*[$'\n\r']*)
echo "::error::ref must not contain newlines."
exit 1
;;
esac
# The workflow_dispatch `type: choice` is enforced only by
# the GitHub UI. The REST API will accept any string. We
# validate explicitly so a malformed action never reaches
# the handler (which trusts this value after HMAC check).
case "$ACTION" in
index|delete) ;;
*)
echo "::error::Unsupported action '$ACTION'. Must be 'index' or 'delete'."
exit 1
;;
esac
case "$REF" in
main|release/*) ;;
*)
echo "::error::Unsupported ref '$REF'. Only main and release/* are eligible."
exit 1
;;
esac
# Refuse to run `action=delete` against main. The dispatch
# UI defaults `ref` to the dispatching branch (typically
# `main`), so a single forgotten field when cleaning up a
# release branch would wipe production search records.
# Force the operator to type the ref explicitly for delete.
if [ "$ACTION" = "delete" ] && [ "$REF" = "main" ]; then
echo "::error::Refusing to delete records for ref=main. Specify a release/* ref explicitly when dispatching delete."
exit 1
fi
echo "action=$ACTION" >> "$GITHUB_OUTPUT"
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: POST to coder.com docs indexer
# Sentinel guard. The Compute step has two release-event
# early-exit paths (prerelease skip, non-semver tag skip) that
# succeed without writing action/ref to GITHUB_OUTPUT. Without
# this guard, the POST would still fire with empty ACTION and
# REF env vars, sending stray no-op traffic to the production
# handler. The step only writes `action` on the success path,
# so its presence is a reliable proceed signal. See DOCS-327.
if: steps.input.outputs.action != ''
env:
ACTION: ${{ steps.input.outputs.action }}
REF: ${{ steps.input.outputs.ref }}
PATHS_JSON: ${{ needs.changes.outputs.paths_json }}
SECRET: ${{ secrets.ALGOLIA_DOCS_SYNC_SECRET }}
run: |
set -euo pipefail
if [ -z "${SECRET:-}" ]; then
echo "::error::ALGOLIA_DOCS_SYNC_SECRET is not configured."
exit 1
fi
# Build the webhook body. paths_json is always a valid JSON
# array (possibly empty) thanks to the changes job. An empty
# array tells the handler to do a whole-branch reindex; a
# non-empty array triggers surgical per-page mode.
if [ -z "${PATHS_JSON:-}" ]; then
PATHS_JSON='[]'
fi
BODY=$(jq -nc \
--arg action "$ACTION" \
--arg corpus "v2" \
--arg ref "$REF" \
--argjson paths "$PATHS_JSON" \
'{action: $action, corpus: $corpus, ref: $ref, paths: $paths}')
# SHA-256 HMAC over the exact bytes we POST. The handler verifies
# with crypto.timingSafeEqual on the same raw body, so the
# prefix and hex casing must match.
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')"
PATHS_COUNT=$(printf '%s' "$PATHS_JSON" | jq 'length')
MODE="whole-branch"
if [ "$PATHS_COUNT" -gt 0 ]; then
MODE="surgical ($PATHS_COUNT path(s))"
fi
echo "Action: $ACTION Ref: $REF Mode: $MODE"
RESPONSE=$(mktemp)
RC=0
# A whole-branch reindex fetches and extracts a few hundred
# pages server-side and can run past two minutes. Keep
# --max-time in step with the docs indexer's server-side
# function budget so curl waits for the response instead of
# aborting mid-reindex and reporting a false timeout.
HTTP_STATUS=$(curl --fail-with-body -sS \
--connect-timeout 10 \
--max-time 300 \
-o "$RESPONSE" \
-w '%{http_code}' \
-X POST \
-H 'Content-Type: application/json' \
-H "X-Coder-Signature: $SIG" \
--data "$BODY" \
https://coder.com/api/algolia-docs-sync) || RC=$?
# Render only an allowlisted subset of the handler response in
# the step summary. The handler can include free-form fields
# (error, reason, revalidateSampleErrors, skippedReasons,
# recordsByType) that may reflect upstream error strings. This
# repository is public, so the step summary is visible to
# anyone with read access; filter those fields out before the
# summary is written. The full response stays in a temp file and
# is never printed: run logs are public for this repository, so
# the raw body must not reach them either.
#
# Keep this allowlist in sync with SyncResponseBody in
# coder/coder.com/src/pages/api/algolia-docs-sync.ts; add a
# field here only after confirming it is bounded enough to be
# safe for a public UI.
SAFE_RESPONSE=$(jq '
if type == "object" then
{
action,
corpus,
ref,
records,
pagesIndexed,
pagesSkipped,
revalidated,
revalidateFailed,
mode,
pathsRequested,
pathsSkipped,
index,
tookMs
} | with_entries(select(.value != null))
else
{}
end
' "$RESPONSE" 2>/dev/null) || SAFE_RESPONSE='{}'
{
echo "## Algolia + ISR sync"
echo
echo "- Action: \`$ACTION\`"
echo "- Ref: \`$REF\`"
echo "- Mode: \`$MODE\`"
echo "- HTTP status: \`${HTTP_STATUS:-n/a}\`"
echo
echo "### Response (allowlisted fields)"
echo
echo '```json'
printf '%s\n' "$SAFE_RESPONSE"
echo '```'
if [ "$RC" -ne 0 ]; then
echo
echo "### Error"
echo
echo "The request failed. The raw response body is not shown because this repository is public; only the allowlisted fields above and the bounded error code (in the run log) are surfaced."
fi
} >> "$GITHUB_STEP_SUMMARY"
if [ "$RC" -ne 0 ]; then
# This repository is public: run logs and the step summary are
# both world-readable, so surface only the bounded error code,
# never the raw response body. Sanitize the extracted code so the
# "bounded" claim holds literally: `tr -cd` drops anything outside
# [A-Za-z0-9_.-] (removing newlines and `::` so a hostile response
# body can't inject a runner workflow command) and `head -c 64`
# caps the length.
ERR_CODE=$(jq -r '(.error | objects | .code) // empty' "$RESPONSE" 2>/dev/null | tr -cd 'A-Za-z0-9_.-' | head -c 64 || true)
echo "Algolia docs sync request failed: HTTP ${HTTP_STATUS:-n/a}, error code: ${ERR_CODE:-unknown}."
exit "$RC"
fi
# Path 2: full Vercel rebuild. Only fires when docs/manifest.json
# changed, because manifest changes can introduce or remove routes
# that Next.js's `getStaticPaths` only re-evaluates on a full build.
# Markdown-only edits don't need this; ISR revalidate covers them.
vercel-rebuild:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.manifest_changed == 'true'
steps:
- name: Trigger Vercel deploy hook
env:
HOOK: ${{ secrets.DEPLOY_DOCS_VERCEL_WEBHOOK }}
run: |
set -euo pipefail
if [ -z "${HOOK:-}" ]; then
echo "::error::DEPLOY_DOCS_VERCEL_WEBHOOK is not configured."
exit 1
fi
# Mirror the sibling job's pattern: capture response body and
# HTTP status, write the step summary unconditionally, then
# propagate failure. Without this, set -e would kill the
# script before the summary block on curl failure.
RESPONSE=$(mktemp)
RC=0
HTTP_STATUS=$(curl --fail-with-body -sS \
--connect-timeout 10 \
--max-time 120 \
-o "$RESPONSE" \
-w '%{http_code}' \
-X POST "$HOOK") || RC=$?
# Render only an allowlisted subset of the Vercel deploy hook
# response (job.id, job.state, job.createdAt). The deploy hook
# URL itself is the only secret in this flow; the response
# shape is bounded today, but we filter explicitly to insulate
# the public step summary from any future shape change
# upstream and to keep the two summary blocks consistent.
SAFE_RESPONSE=$(jq '
if type == "object" and (.job | type) == "object" then
{ job: (.job | { id, state, createdAt } | with_entries(select(.value != null))) }
else
{}
end
' "$RESPONSE" 2>/dev/null) || SAFE_RESPONSE='{}'
{
echo "## Vercel rebuild"
echo
echo "- Reason: \`docs/manifest.json\` changed"
echo "- HTTP status: \`${HTTP_STATUS:-n/a}\`"
echo
echo "### Response (allowlisted fields)"
echo
echo '```json'
printf '%s\n' "$SAFE_RESPONSE"
echo '```'
if [ "$RC" -ne 0 ]; then
echo
echo "### Error"
echo
echo "The request failed. The raw response body is not shown because this repository is public; only the allowlisted fields above and the bounded error code (in the run log) are surfaced."
fi
} >> "$GITHUB_STEP_SUMMARY"
if [ "$RC" -ne 0 ]; then
# This repository is public: run logs and the step summary are
# both world-readable, so surface only the bounded error code,
# never the raw response body. Sanitize the extracted code so the
# "bounded" claim holds literally: `tr -cd` drops anything outside
# [A-Za-z0-9_.-] (removing newlines and `::` so a hostile response
# body can't inject a runner workflow command) and `head -c 64`
# caps the length.
ERR_CODE=$(jq -r '(.error | objects | .code) // empty' "$RESPONSE" 2>/dev/null | tr -cd 'A-Za-z0-9_.-' | head -c 64 || true)
echo "Vercel deploy hook request failed: HTTP ${HTTP_STATUS:-n/a}, error code: ${ERR_CODE:-unknown}."
exit "$RC"
fi