Skip to content

Deploy docs preview for PR #52

Deploy docs preview for PR

Deploy docs preview for PR #52

Workflow file for this run

name: Deploy docs preview

Check warning on line 1 in .github/workflows/site-preview.yml

View workflow run for this annotation

GitHub Actions / Deploy docs preview

Workflow execution policy warning (evaluate mode)

On November 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
run-name: Deploy docs preview for PR #${{ github.event.pull_request.number || inputs.pull_request_number }}
on:
pull_request_target:
types: [opened, reopened, synchronize, labeled, unlabeled]
workflow_dispatch:
inputs:
pull_request_number:
description: Open pull request to preview
required: true
type: string
permissions:
contents: read
pull-requests: write
concurrency:
group: nimbus-site-preview-${{ github.event.pull_request.number || inputs.pull_request_number }}
cancel-in-progress: true
jobs:
preview:
name: Build on Vercel
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
startsWith(github.event.label.name, 'docs-translations-')
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
# A superseding run may start after GitHub terminated the previous runner
# before its EXIT trap completed. Clear that stale state before any
# validation in this run can fail.
- name: Clear an interrupted preview status
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }}
run: |
marker="<!-- nimbus-site-preview -->"
comment_id="$({
gh api --paginate "repos/$GH_REPO/issues/$PULL_REQUEST_NUMBER/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"$marker\")) | .id"
} | tail -n 1)"
if [[ -z "$comment_id" ]]; then
exit 0
fi
body="$(gh api "repos/$GH_REPO/issues/comments/$comment_id" --jq .body)"
if [[ "$body" != *"🟡 Building"* ]]; then
exit 0
fi
updated_at="$(date -u '+%Y-%m-%d %H:%M UTC')"
body="$(jq -nr --arg body "$body" --arg updated_at "$updated_at" \
'$body | gsub("🟡 Building"; "🔴 Failed") | sub("\\| [^|\\n]+ \\|$"; "| \($updated_at) |")')"
gh api --method PATCH "repos/$GH_REPO/issues/comments/$comment_id" -f body="$body" >/dev/null
- name: Require a trusted manual invocation
if: github.event_name == 'workflow_dispatch'
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
if [[ "$GITHUB_REF_NAME" != "$DEFAULT_BRANCH" ]]; then
echo "Run this workflow from the repository's default branch." >&2
exit 1
fi
- name: Resolve the pull request revision
id: pull-request
env:
GH_TOKEN: ${{ github.token }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }}
run: |
state="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST_NUMBER" --jq .state)"
if [[ "$state" != "open" ]]; then
echo "Pull request $PULL_REQUEST_NUMBER is not open." >&2
exit 1
fi
head_sha="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST_NUMBER" --jq .head.sha)"
head_repository="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST_NUMBER" --jq .head.repo.full_name)"
if [[ ! "$head_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "GitHub returned an invalid pull request head SHA." >&2
exit 1
fi
if [[ ! "$head_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
echo "GitHub returned an invalid pull request head repository." >&2
exit 1
fi
merge_ref="refs/pull/$PULL_REQUEST_NUMBER/merge"
merge_sha="$({
git ls-remote "https://github.com/$GITHUB_REPOSITORY.git" "$merge_ref"
} | awk 'NR == 1 { print $1 }')"
if [[ ! "$merge_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "GitHub did not expose a merge revision for pull request $PULL_REQUEST_NUMBER." >&2
echo "Resolve any merge conflicts before requesting a preview." >&2
exit 1
fi
if [[ "$head_repository" == "$GITHUB_REPOSITORY" ]]; then
trust="trusted-branch"
else
trust="untrusted-fork"
fi
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
echo "head_repository=$head_repository" >> "$GITHUB_OUTPUT"
echo "merge_ref=$merge_ref" >> "$GITHUB_OUTPUT"
echo "merge_sha=$merge_sha" >> "$GITHUB_OUTPUT"
echo "trust=$trust" >> "$GITHUB_OUTPUT"
- name: Select translated collections from pull request labels
id: translations
env:
GH_TOKEN: ${{ github.token }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }}
run: |
mapfile -t labels < <(
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PULL_REQUEST_NUMBER/labels" --jq '.[].name'
)
known_locales=(ar es fr ja ko pt-BR ru zh)
include_translations=false
for label in "${labels[@]}"; do
normalized="${label,,}"
if [[ "$normalized" == "docs-translations-all" ]]; then
include_translations=true
continue
fi
if [[ "$normalized" != docs-translations-* ]]; then
continue
fi
matched=false
for locale in "${known_locales[@]}"; do
if [[ "$normalized" == "docs-translations-${locale,,}" ]]; then
include_translations=true
matched=true
break
fi
done
if [[ "$matched" != true ]]; then
echo "Unknown translation preview label: $label" >&2
echo "Use docs-translations-all or docs-translations-{ar,es,fr,ja,ko,pt-BR,ru,zh}." >&2
exit 1
fi
done
if [[ "$include_translations" == true ]]; then
locale_scope="all"
locale_summary="English and all translations"
else
locale_scope="none"
locale_summary="English only"
fi
echo "scope=$locale_scope" >> "$GITHUB_OUTPUT"
echo "summary=$locale_summary" >> "$GITHUB_OUTPUT"
# GitHub owns refs/pull/<number>/merge in the primary repository, even
# when the pull request head is in a fork. Vercel fetches that immutable
# revision through the project's Git connection; Actions never checks
# out, executes, archives, or uploads pull-request-controlled files.
- name: Trigger and wait for the Vercel preview
id: deploy
env:
APPROVED_SHA: ${{ steps.pull-request.outputs.merge_sha }}
GIT_REF: ${{ steps.pull-request.outputs.merge_ref }}
HEAD_REPOSITORY: ${{ steps.pull-request.outputs.head_repository }}
HEAD_SHA: ${{ steps.pull-request.outputs.head_sha }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }}
TRANSLATION_SCOPE: ${{ steps.translations.outputs.scope }}
TRANSLATION_SUMMARY: ${{ steps.translations.outputs.summary }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TRUST: ${{ steps.pull-request.outputs.trust }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TRANSLATIONS_PROJECT_ID: ${{ secrets.VERCEL_TRANSLATIONS_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
repository_owner="${GITHUB_REPOSITORY%%/*}"
repository_name="${GITHUB_REPOSITORY#*/}"
update_preview_comment() {
local deployment_status="$1"
local updated_at="$(date -u '+%Y-%m-%d %H:%M UTC')"
local marker="<!-- nimbus-site-preview -->"
local docs_url="${preview_url%/}/docs"
local components="English: $english_state; translations: Production fallback"
if [[ -n "${translations_url:-}" ]]; then
components="English: $english_state; [translations]($translations_url): $translations_state"
fi
local body
local comment_id
body="$(printf '🕵 %s\n\n| Preview | Deployment | Components | Updated (UTC) |\n| --- | --- | --- | --- |\n| [Docs preview](%s) | [%s](%s) | %s | %s |' \
"$marker" \
"$docs_url" \
"$deployment_status" \
"$preview_url" \
"$components" \
"$updated_at")"
if ! comment_id="$({
gh api --paginate "repos/$GH_REPO/issues/$PULL_REQUEST_NUMBER/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"$marker\")) | .id"
} | tail -n 1)"; then
return 1
fi
if [[ -n "$comment_id" ]]; then
gh api --method PATCH "repos/$GH_REPO/issues/comments/$comment_id" -f body="$body" >/dev/null
else
gh pr comment "$PULL_REQUEST_NUMBER" --body "$body"
fi
}
update_preview_comment_with_retry() {
local deployment_status="$1"
local attempt
for attempt in 1 2 3; do
if update_preview_comment "$deployment_status"; then
return 0
fi
echo "::warning::Unable to update the preview comment (attempt $attempt of 3)."
if (( attempt < 3 )); then
sleep 2
fi
done
return 1
}
english_id=""
translations_id=""
preview_url=""
translations_url=""
english_state="Not started"
translations_state="Production fallback"
preview_finished=false
cancel_vercel_deployment() {
local deployment_id="$1"
local deployment_label="$2"
local cancel_response
local cancel_exit_code
local deployment_response
local ready_state
if cancel_response="$(
curl --silent --show-error --fail-with-body \
--request PATCH \
--header "Authorization: Bearer $VERCEL_TOKEN" \
"https://api.vercel.com/v12/deployments/$deployment_id/cancel?teamId=$VERCEL_ORG_ID"
)"; then
echo "Canceled $deployment_label Vercel deployment $deployment_id."
return 0
else
cancel_exit_code=$?
fi
# A deployment can finish between being listed and receiving the
# cancellation request. Vercel returns HTTP 400 in that case, so
# verify that it is terminal rather than hiding an API failure.
if deployment_response="$(
curl --silent --show-error --fail-with-body \
--header "Authorization: Bearer $VERCEL_TOKEN" \
"https://api.vercel.com/v13/deployments/$deployment_id?teamId=$VERCEL_ORG_ID"
)"; then
ready_state="$(jq -er .readyState <<< "$deployment_response")"
case "$ready_state" in
READY|ERROR|CANCELED|DELETED|BLOCKED)
echo "$deployment_label Vercel deployment $deployment_id already reached $ready_state."
return 0
;;
esac
fi
printf '%s\n' "$cancel_response" >&2
echo "Unable to cancel $deployment_label Vercel deployment $deployment_id." >&2
return "$cancel_exit_code"
}
cancel_superseded_deployments() {
local project_id="$1"
local build_scope="$2"
local deployment_label="$3"
local ready_state
local until=""
local list_response
local next
local deployment_id
local -a request_args
local -a deployment_ids
if [[ -z "$project_id" ]]; then
return 0
fi
for ready_state in QUEUED INITIALIZING BUILDING; do
until=""
while true; do
request_args=(
--silent
--show-error
--fail-with-body
--get
--header "Authorization: Bearer $VERCEL_TOKEN"
--data-urlencode "projectId=$project_id"
--data-urlencode "state=$ready_state"
--data-urlencode "limit=100"
--data-urlencode "teamId=$VERCEL_ORG_ID"
)
if [[ -n "$until" ]]; then
request_args+=(--data-urlencode "until=$until")
fi
if ! list_response="$(
curl "${request_args[@]}" "https://api.vercel.com/v7/deployments"
)"; then
printf '%s\n' "$list_response" >&2
echo "Unable to list $deployment_label Vercel deployments." >&2
return 1
fi
mapfile -t deployment_ids < <(
jq -r \
--arg build_scope "$build_scope" \
--arg pull_request "$PULL_REQUEST_NUMBER" \
'.deployments[]
| select((.meta.pullRequest? // "") == $pull_request)
| select((.meta.buildScope? // "") == $build_scope)
| (.uid // .id)' \
<<< "$list_response"
)
for deployment_id in "${deployment_ids[@]}"; do
cancel_vercel_deployment "$deployment_id" "superseded $deployment_label" || return 1
done
next="$(jq -er '.pagination.next // ""' <<< "$list_response")"
if [[ -z "$next" ]]; then
break
fi
if [[ "$next" == "$until" ]]; then
echo "Vercel returned a repeated pagination cursor while listing $deployment_label deployments." >&2
return 1
fi
until="$next"
done
done
}
finalize_preview_comment() {
local exit_code=$?
local cleanup_failed=false
trap - EXIT INT TERM
if [[ "$preview_finished" != true ]]; then
if [[ -n "$english_id" ]] && ! cancel_vercel_deployment "$english_id" "English"; then
cleanup_failed=true
fi
if [[ -n "$translations_id" ]] && ! cancel_vercel_deployment "$translations_id" "translations"; then
cleanup_failed=true
fi
if [[ -n "$preview_url" ]] && ! update_preview_comment_with_retry "🔴 Failed"; then
echo "::warning::Unable to mark the preview comment as failed."
fi
fi
if [[ "$cleanup_failed" == true && "$exit_code" -eq 0 ]]; then
exit_code=1
fi
exit "$exit_code"
}
# GitHub's concurrency group stops the superseded Actions run, but
# Vercel continues any deployment already created by that runner.
# Cancel matching in-progress deployments before creating this run's
# replacements. This also covers abrupt runner termination where the
# old run's EXIT trap did not execute.
cancel_superseded_deployments "$VERCEL_PROJECT_ID" "english-preview" "English"
if [[ "$TRANSLATION_SCOPE" == "all" ]]; then
cancel_superseded_deployments "$VERCEL_TRANSLATIONS_PROJECT_ID" "translations-preview" "translations"
fi
trap finalize_preview_comment EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if ! project_response="$(
curl --silent --show-error --fail-with-body \
--header "Authorization: Bearer $VERCEL_TOKEN" \
"https://api.vercel.com/v9/projects/$VERCEL_PROJECT_ID?teamId=$VERCEL_ORG_ID"
)"; then
printf '%s\n' "$project_response" >&2
exit 1
fi
project_name="$(jq -er .name <<< "$project_response")"
english_payload="$(
jq --null-input \
--arg approved_sha "$APPROVED_SHA" \
--arg git_ref "$GIT_REF" \
--arg head_repository "$HEAD_REPOSITORY" \
--arg head_sha "$HEAD_SHA" \
--arg project_id "$VERCEL_PROJECT_ID" \
--arg project_name "$project_name" \
--arg pull_request "$PULL_REQUEST_NUMBER" \
--arg repository "$repository_name" \
--arg repository_owner "$repository_owner" \
--arg trust "$TRUST" \
'{
name: $project_name,
project: $project_id,
gitSource: {
type: "github",
org: $repository_owner,
repo: $repository,
ref: $git_ref,
sha: $approved_sha
},
build: {env: {
DOCS_DEPLOY_TARGET: "english",
DOCS_LOCALES: "none",
DOCS_AVAILABLE_LOCALES: "all",
DOCS_REMOTES: "none"
}},
meta: {
buildScope: "english-preview",
sourceRepository: ($repository_owner + "/" + $repository),
pullRequestHeadRepository: $head_repository,
pullRequestHeadSha: $head_sha,
pullRequest: $pull_request,
approvedSha: $approved_sha,
trust: $trust
}
}'
)"
if ! english_response="$(
curl --silent --show-error --fail-with-body \
--request POST \
--header "Authorization: Bearer $VERCEL_TOKEN" \
--header "Content-Type: application/json" \
--data "$english_payload" \
"https://api.vercel.com/v13/deployments?forceNew=1&teamId=$VERCEL_ORG_ID"
)"; then
printf '%s\n' "$english_response" >&2
exit 1
fi
english_id="$(jq -er .id <<< "$english_response")"
deployment_url="$(jq -er .url <<< "$english_response")"
preview_url="https://${deployment_url#https://}"
english_state="🟡 Building"
translations_state="Production fallback"
echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT"
echo "English Vercel deployment: $preview_url"
if [[ ! "$preview_url" =~ ^https://[^[:space:]]+$ ]]; then
echo "Vercel returned an invalid preview URL." >&2
exit 1
fi
if [[ "$TRANSLATION_SCOPE" == "all" ]]; then
if [[ -z "$VERCEL_TRANSLATIONS_PROJECT_ID" ]]; then
echo "docs-translations-all requires the VERCEL_TRANSLATIONS_PROJECT_ID repository secret." >&2
exit 1
fi
if ! translations_project_response="$(
curl --silent --show-error --fail-with-body \
--header "Authorization: Bearer $VERCEL_TOKEN" \
"https://api.vercel.com/v9/projects/$VERCEL_TRANSLATIONS_PROJECT_ID?teamId=$VERCEL_ORG_ID"
)"; then
printf '%s\n' "$translations_project_response" >&2
exit 1
fi
translations_project_name="$(jq -er .name <<< "$translations_project_response")"
translations_payload="$(
jq --null-input \
--arg approved_sha "$APPROVED_SHA" \
--arg git_ref "$GIT_REF" \
--arg head_repository "$HEAD_REPOSITORY" \
--arg head_sha "$HEAD_SHA" \
--arg project_id "$VERCEL_TRANSLATIONS_PROJECT_ID" \
--arg project_name "$translations_project_name" \
--arg pull_request "$PULL_REQUEST_NUMBER" \
--arg repository "$repository_name" \
--arg repository_owner "$repository_owner" \
--arg trust "$TRUST" \
'{
name: $project_name,
project: $project_id,
gitSource: {
type: "github",
org: $repository_owner,
repo: $repository,
ref: $git_ref,
sha: $approved_sha
},
build: {env: {
DOCS_DEPLOY_TARGET: "translations",
DOCS_LOCALES: "all",
DOCS_AVAILABLE_LOCALES: "all",
DOCS_REMOTES: "none"
}},
meta: {
buildScope: "translations-preview",
sourceRepository: ($repository_owner + "/" + $repository),
pullRequestHeadRepository: $head_repository,
pullRequestHeadSha: $head_sha,
pullRequest: $pull_request,
approvedSha: $approved_sha,
trust: $trust,
translations: "all"
}
}'
)"
if ! translations_response="$(
curl --silent --show-error --fail-with-body \
--request POST \
--header "Authorization: Bearer $VERCEL_TOKEN" \
--header "Content-Type: application/json" \
--data "$translations_payload" \
"https://api.vercel.com/v13/deployments?forceNew=1&teamId=$VERCEL_ORG_ID"
)"; then
printf '%s\n' "$translations_response" >&2
exit 1
fi
translations_id="$(jq -er .id <<< "$translations_response")"
translations_deployment_url="$(jq -er .url <<< "$translations_response")"
translations_url="https://${translations_deployment_url#https://}"
translations_state="🟡 Building"
echo "translations_url=$translations_url" >> "$GITHUB_OUTPUT"
echo "Translations Vercel deployment: $translations_url"
fi
if ! update_preview_comment_with_retry "🟡 Building"; then
echo "::warning::Unable to mark the preview comment as building."
fi
# Leave five minutes for the EXIT trap to update the comment before
# the 60-minute job timeout terminates the runner.
for _ in {1..330}; do
if [[ "$english_state" != "🟢 Ready" ]]; then
english_response="$(curl --silent --show-error --fail-with-body \
--header "Authorization: Bearer $VERCEL_TOKEN" \
"https://api.vercel.com/v13/deployments/$english_id?teamId=$VERCEL_ORG_ID")"
ready_state="$(jq -er .readyState <<< "$english_response")"
case "$ready_state" in
READY) english_state="🟢 Ready" ;;
ERROR|CANCELED|DELETED|BLOCKED)
jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "English deployment did not complete")' <<< "$english_response" >&2
exit 1
;;
QUEUED|INITIALIZING|BUILDING) ;;
*) echo "Unexpected English deployment state: $ready_state" >&2; exit 1 ;;
esac
fi
if [[ -n "$translations_id" && "$translations_state" != "🟢 Ready" ]]; then
translations_response="$(curl --silent --show-error --fail-with-body \
--header "Authorization: Bearer $VERCEL_TOKEN" \
"https://api.vercel.com/v13/deployments/$translations_id?teamId=$VERCEL_ORG_ID")"
ready_state="$(jq -er .readyState <<< "$translations_response")"
case "$ready_state" in
READY) translations_state="🟢 Ready" ;;
ERROR|CANCELED|DELETED|BLOCKED)
jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "Translations deployment did not complete")' <<< "$translations_response" >&2
exit 1
;;
QUEUED|INITIALIZING|BUILDING) ;;
*) echo "Unexpected translations deployment state: $ready_state" >&2; exit 1 ;;
esac
fi
if [[ "$english_state" == "🟢 Ready" && ( -z "$translations_id" || "$translations_state" == "🟢 Ready" ) ]]; then
break
fi
sleep 10
done
if [[ "$english_state" != "🟢 Ready" || ( -n "$translations_id" && "$translations_state" != "🟢 Ready" ) ]]; then
echo "Timed out waiting for the Vercel preview deployments." >&2
exit 1
fi
if ! update_preview_comment_with_retry "🟢 Ready"; then
echo "::warning::Unable to mark the preview comment as ready."
fi
preview_finished=true