docs: retire the generated feature stage lists (#29113) #596
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |