diff --git a/.github/workflows/remote-docs-preview.yml b/.github/workflows/remote-docs-preview.yml new file mode 100644 index 0000000..f34f3b6 --- /dev/null +++ b/.github/workflows/remote-docs-preview.yml @@ -0,0 +1,484 @@ +name: Deploy remote documentation preview + +# The authorization and Vercel Connect security model are documented in the +# remote-docs-preview section of README.md. + +on: + workflow_call: + inputs: + remote_name: + description: Source name registered in the central docs remotes.json + required: true + type: string + pull_request_number: + description: Open pull request to preview + required: true + type: number + secrets: + VERCEL_TOKEN: + required: true + VERCEL_ORG_ID: + required: true + VERCEL_PROJECT_ID: + required: true + outputs: + preview_url: + description: URL of the completed Vercel preview + value: ${{ jobs.preview.outputs.preview_url }} + +permissions: {} + +jobs: + preview: + name: Build docs preview on Vercel + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + DOCS_REPOSITORY: ClickHouse/mintlify-docs-dev + DOCS_REF: main + permissions: + pull-requests: write + concurrency: + group: remote-docs-preview-${{ github.repository }}-${{ inputs.remote_name }}-${{ inputs.pull_request_number }} + cancel-in-progress: true + outputs: + preview_url: ${{ steps.deploy.outputs.preview_url }} + steps: + - name: Resolve the approved revisions + id: request + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_HAS_DOCS_PREVIEW_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'docs-preview') }} + EVENT_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_LABEL: ${{ github.event.label.name }} + EVENT_NAME: ${{ github.event_name }} + EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }} + REMOTE_NAME: ${{ inputs.remote_name }} + SOURCE_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + if [[ "$EVENT_NAME" != "pull_request_target" ]]; then + echo "::error::Remote documentation previews require a pull_request_target event." + exit 1 + fi + case "$EVENT_ACTION" in + labeled) + if [[ "$EVENT_LABEL" != "docs-preview" || "$EVENT_HAS_DOCS_PREVIEW_LABEL" != "true" ]]; then + echo "::error::A labeled event must add the docs-preview label." + exit 1 + fi + ;; + synchronize) + if [[ "$EVENT_HAS_DOCS_PREVIEW_LABEL" != "true" ]]; then + echo "::error::A synchronize event requires the docs-preview label." + exit 1 + fi + ;; + reopened) + if [[ "$EVENT_HAS_DOCS_PREVIEW_LABEL" != "true" ]]; then + echo "::error::A reopened event requires the docs-preview label." + exit 1 + fi + ;; + *) + echo "::error::Remote documentation previews require a docs-preview labeled, synchronize, or reopened event." + exit 1 + ;; + esac + if [[ "$EVENT_BASE_REF" != "$DEFAULT_BRANCH" ]]; then + echo "::error::The pull request must target the repository's default branch." + exit 1 + fi + if [[ ! "$REMOTE_NAME" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "::error::remote_name must contain only lowercase letters, numbers, and hyphens." + exit 1 + fi + if [[ ! "$PULL_REQUEST_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::pull_request_number must be a positive integer." + exit 1 + fi + if [[ "$PULL_REQUEST_NUMBER" != "$EVENT_PULL_REQUEST_NUMBER" ]]; then + echo "::error::pull_request_number must match the labeled pull request." + exit 1 + fi + + pull_request="$(gh api "repos/$SOURCE_REPOSITORY/pulls/$PULL_REQUEST_NUMBER")" + if [[ "$(jq -r .state <<< "$pull_request")" != "open" ]]; then + echo "::error::Pull request $SOURCE_REPOSITORY#$PULL_REQUEST_NUMBER is not open." + exit 1 + fi + + live_head_sha="$(jq -er .head.sha <<< "$pull_request")" + live_head_repository="$(jq -er .head.repo.full_name <<< "$pull_request")" + site_sha="$(gh api "repos/$DOCS_REPOSITORY/commits/$DOCS_REF" --jq .sha)" + if [[ ! "$EVENT_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::The event payload contains an invalid pull-request head SHA." + exit 1 + fi + if [[ ! "$EVENT_HEAD_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "::error::The event payload contains an invalid pull-request head repository." + exit 1 + fi + if [[ ! "$site_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::GitHub returned an invalid central docs SHA." + exit 1 + fi + if [[ "$live_head_sha" != "$EVENT_HEAD_SHA" || "$live_head_repository" != "$EVENT_HEAD_REPOSITORY" ]]; then + echo "::error::The pull request head changed after this event; wait for the next event before creating a preview." + exit 1 + fi + if ! jq -e --arg label "docs-preview" 'any(.labels[]; .name == $label)' <<< "$pull_request" >/dev/null; then + echo "::error::The docs-preview label was removed before this preview could start." + exit 1 + fi + + { + echo "head_sha=$EVENT_HEAD_SHA" + echo "head_repository=$EVENT_HEAD_REPOSITORY" + echo "site_sha=$site_sha" + } >> "$GITHUB_OUTPUT" + + # The deployment security model is documented in README.md. + - name: Trigger and wait for the Vercel Connect preview + id: deploy + env: + DOCS_REMOTE_NAME: ${{ inputs.remote_name }} + DOCS_REMOTE_REF: ${{ steps.request.outputs.head_sha }} + DOCS_REMOTE_REPOSITORY: ${{ github.repository }} + DOCS_REMOTE_SOURCE_REPOSITORY: ${{ steps.request.outputs.head_repository }} + DOCS_REMOTE_PULL_REQUEST_REF: refs/pull/${{ inputs.pull_request_number }}/head + PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }} + SITE_SHA: ${{ steps.request.outputs.site_sha }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: | + set -euo pipefail + + deployment_id="" + deployment_finished=false + # Reserve five minutes of this 60-minute job for cancellation and a + # useful failure report. The 40-minute Vercel build limit starts when + # Vercel reports BUILDING, rather than charging queue time against it. + job_deadline=$((SECONDS + 3300)) + build_deadline=0 + + curl_request() { + curl --connect-timeout 10 --max-time 30 "$@" + } + + cancel_vercel_deployment() { + local deployment_id_to_cancel="$1" + local deployment_label="$2" + local cancel_response + local cancel_exit_code + local deployment_response + local ready_state + + if cancel_response="$( + curl_request --silent --show-error --fail-with-body \ + --request PATCH \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v12/deployments/$deployment_id_to_cancel/cancel?teamId=$VERCEL_ORG_ID" + )"; then + echo "Canceled $deployment_label Vercel deployment $deployment_id_to_cancel." + return 0 + else + cancel_exit_code=$? + fi + + if deployment_response="$( + curl_request --silent --show-error --fail-with-body \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v13/deployments/$deployment_id_to_cancel?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_to_cancel already reached $ready_state." + return 0 + ;; + esac + fi + + printf '%s\n' "$cancel_response" >&2 + echo "Unable to cancel $deployment_label Vercel deployment $deployment_id_to_cancel." >&2 + return "$cancel_exit_code" + } + + cancel_superseded_deployments() { + local until="" + local list_response + local next + local superseded_id + local deployment_ids_json + local -a request_args + local -a deployment_ids + + while true; do + request_args=( + --silent + --show-error + --fail-with-body + --get + --header "Authorization: Bearer $VERCEL_TOKEN" + --data-urlencode "projectId=$VERCEL_PROJECT_ID" + --data-urlencode "state=QUEUED,INITIALIZING,BUILDING" + --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 "${request_args[@]}" "https://api.vercel.com/v7/deployments" + )"; then + printf '%s\n' "$list_response" >&2 + echo "Unable to list source-preview Vercel deployments." >&2 + return 1 + fi + + if ! deployment_ids_json="$( + jq -ce \ + --arg pull_request "$PULL_REQUEST_NUMBER" \ + --arg remote_name "$DOCS_REMOTE_NAME" \ + --arg source_repository "$DOCS_REMOTE_REPOSITORY" \ + '[.deployments[] + | select((.meta.buildScope? // "") == "source-preview") + | select((.meta.sourceRepository? // "") == $source_repository) + | select((.meta.remoteName? // "") == $remote_name) + | select((.meta.pullRequest? // "") == $pull_request) + | (.uid // .id)]' \ + <<< "$list_response" + )"; then + echo "Vercel returned an invalid deployment list." >&2 + return 1 + fi + + deployment_ids=() + if [[ "$deployment_ids_json" != "[]" ]]; then + mapfile -t deployment_ids < <(jq -r '.[]' <<< "$deployment_ids_json") + fi + for superseded_id in "${deployment_ids[@]}"; do + if ! cancel_vercel_deployment "$superseded_id" "superseded source-preview"; then + echo "::warning::Unable to cancel superseded source-preview deployment $superseded_id; continuing with the new preview." + fi + 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 source-preview deployments." >&2 + return 1 + fi + until="$next" + done + } + + finalize_deployment() { + local exit_code=$? + trap - EXIT + # GitHub sends TERM shortly after INT. Keep the cleanup request + # alive long enough to cancel the Vercel deployment. + trap '' INT TERM + if [[ "$deployment_finished" != true && -n "$deployment_id" ]]; then + if ! cancel_vercel_deployment "$deployment_id" "source-preview"; then + echo "::warning::Unable to cancel the interrupted source-preview deployment." + if [[ "$exit_code" -eq 0 ]]; then + exit_code=1 + fi + fi + fi + exit "$exit_code" + } + + if ! cancel_superseded_deployments; then + echo "::warning::Unable to cancel superseded source-preview deployments; continuing with the new preview." + fi + + trap finalize_deployment EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + if ! project_response="$( + curl_request --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")" + docs_owner="${DOCS_REPOSITORY%%/*}" + docs_repo="${DOCS_REPOSITORY##*/}" + if [[ ! "$DOCS_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid central docs repository: $DOCS_REPOSITORY" >&2 + exit 1 + fi + + payload="$( + jq --null-input \ + --arg approved_sha "$DOCS_REMOTE_REF" \ + --arg project_id "$VERCEL_PROJECT_ID" \ + --arg project_name "$project_name" \ + --arg pull_request "$PULL_REQUEST_NUMBER" \ + --arg remote_name "$DOCS_REMOTE_NAME" \ + --arg remote_repository "$DOCS_REMOTE_REPOSITORY" \ + --arg source_repository "$DOCS_REMOTE_SOURCE_REPOSITORY" \ + --arg pull_request_ref "$DOCS_REMOTE_PULL_REQUEST_REF" \ + --arg site_sha "$SITE_SHA" \ + --arg docs_owner "$docs_owner" \ + --arg docs_repo "$docs_repo" \ + --arg docs_ref "$DOCS_REF" \ + '{ + name: $project_name, + project: $project_id, + customEnvironmentSlugOrId: "connect-preview", + gitSource: { + type: "github", + org: $docs_owner, + repo: $docs_repo, + ref: $docs_ref, + sha: $site_sha + }, + build: {env: { + DOCS_DEPLOY_TARGET: "english", + DOCS_LOCALES: "none", + DOCS_AVAILABLE_LOCALES: "all", + DOCS_REMOTES: "all", + DOCS_REMOTE_NAME: $remote_name, + DOCS_REMOTE_REPOSITORY: $remote_repository, + DOCS_REMOTE_SOURCE_REPOSITORY: $source_repository, + DOCS_REMOTE_PULL_REQUEST_REF: $pull_request_ref, + DOCS_REMOTE_REF: $approved_sha + }}, + meta: { + buildScope: "source-preview", + sourceRepository: $remote_repository, + sourceHeadRepository: $source_repository, + remoteName: $remote_name, + pullRequest: $pull_request, + approvedSha: $approved_sha, + siteSha: $site_sha + } + }' + )" + + if ! deployment_response="$( + curl_request --silent --show-error --fail-with-body \ + --request POST \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + --header "Content-Type: application/json" \ + --data "$payload" \ + "https://api.vercel.com/v13/deployments?forceNew=1&teamId=$VERCEL_ORG_ID" + )"; then + printf '%s\n' "$deployment_response" >&2 + exit 1 + fi + + deployment_id="$(jq -er .id <<< "$deployment_response")" + deployment_url="$(jq -er .url <<< "$deployment_response")" + preview_url="https://${deployment_url#https://}" + if [[ ! "$preview_url" =~ ^https://[^[:space:]]+$ ]]; then + echo "Vercel returned an invalid preview URL." >&2 + exit 1 + fi + echo "Vercel deployment: $preview_url" + + ready_state="" + while (( SECONDS < job_deadline && (build_deadline == 0 || SECONDS < build_deadline) )); do + if ! deployment_response="$( + curl_request --silent --show-error --fail-with-body \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v13/deployments/$deployment_id?teamId=$VERCEL_ORG_ID" + )"; then + printf '%s\n' "$deployment_response" >&2 + exit 1 + fi + + ready_state="$(jq -er .readyState <<< "$deployment_response")" + case "$ready_state" in + READY) + break + ;; + ERROR|CANCELED|DELETED|BLOCKED) + deployment_finished=true + jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "Vercel deployment did not complete")' \ + <<< "$deployment_response" >&2 + exit 1 + ;; + QUEUED|INITIALIZING) + wake_deadline=$job_deadline + ;; + BUILDING) + if (( build_deadline == 0 )); then + build_deadline=$((SECONDS + 2400)) + fi + wake_deadline=$build_deadline + if (( job_deadline < wake_deadline )); then + wake_deadline=$job_deadline + fi + ;; + *) + echo "Unexpected Vercel deployment state: $ready_state" >&2 + exit 1 + ;; + esac + + case "$ready_state" in + QUEUED|INITIALIZING|BUILDING) + remaining_seconds=$((wake_deadline - SECONDS)) + if (( remaining_seconds <= 0 )); then + break + fi + sleep_seconds=10 + if (( remaining_seconds < sleep_seconds )); then + sleep_seconds=$remaining_seconds + fi + sleep "$sleep_seconds" & + wait "$!" + ;; + esac + done + + if [[ "$ready_state" != "READY" ]]; then + echo "Timed out waiting for Vercel deployment $deployment_id." >&2 + exit 1 + fi + deployment_finished=true + echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" + + - name: Add the preview link to the pull request + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} + PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }} + REMOTE_NAME: ${{ inputs.remote_name }} + REMOTE_SHA: ${{ steps.request.outputs.head_sha }} + run: | + set -euo pipefail + + marker="" + docs_url="${PREVIEW_URL%/}/docs" + body="$(printf '%s\n\nDocumentation preview for %s: %s' "$marker" "$REMOTE_SHA" "$docs_url")" + comment_id="$( + gh api --paginate "repos/$GH_REPO/issues/$PULL_REQUEST_NUMBER/comments" \ + | jq -r --arg marker "$marker" \ + '.[] | select(.user.login == "github-actions[bot]") | select(.body | startswith($marker)) | .id' \ + | tail -n 1 + )" + 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 diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 5ab44a6..f5ee20a 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -12,6 +12,10 @@ # with refs that are stale or unpinned. rules: + dangerous-triggers: + ignore: + # See the remote-docs-preview security model in README.md. + - caller-remote-docs-preview.yml unpinned-uses: ignore: # Callers are told to reference these reusable workflows at `@main` — that @@ -22,3 +26,4 @@ rules: # you add an example caller. - caller-claude-pr-triage.yml - caller-claude-docs-drift.yml + - caller-remote-docs-preview.yml diff --git a/README.md b/README.md index b1f8105..41ffc3d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,47 @@ so the logic lives in one place and per-repo specifics are passed as inputs. ## Workflows +### `remote-docs-preview.yml` - Request a remote documentation preview + +Starts a scoped documentation preview for a pull request in a repository +registered by `ClickHouse/mintlify-docs-dev/remotes.json`. A maintainer enables +previews by adding the `docs-preview` label to a pull request targeting the +remote repository's default branch. The label remains effective until removed, +so later commits refresh the preview. Each run validates its trusted event and +pins that event's exact head SHA, then directly +creates a Vercel deployment of the current trusted Nimbus `main` revision in the +`connect-preview` Custom Environment. + +The workflow never checks out or executes pull-request content and never passes +a GitHub credential to Vercel. Inside the deployment, Vercel Connect exchanges +the deployment's OIDC identity for a short-lived `contents:read` token scoped to +the registered source repository. Nimbus fetches its `refs/pull//head` +ref, verifies that it resolves to the event SHA, then removes the token before +it begins processing Markdown or MDX. The shared workflow waits for the +deployment and comments the resulting URL on the source pull request. + +| Input | Required | Purpose | +|---|---|---| +| `remote_name` | yes | Source name in the central `remotes.json` registry. | +| `pull_request_number` | yes | Open pull request whose exact head SHA should be previewed. | + +Required secrets are `VERCEL_TOKEN`, `VERCEL_ORG_ID`, and +`VERCEL_PROJECT_ID`. Define them once as organization Actions secrets and grant +them to the registered source repositories; callers can then pass the three +secrets explicitly without duplicating their values per repository. The Vercel +project must provide `DOCS_GITHUB_CONNECTOR` in its `connect-preview` Custom +Environment and allow that environment to use the Vercel Connect GitHub app. + +See [`examples/caller-remote-docs-preview.yml`](examples/caller-remote-docs-preview.yml) +for a copy-paste `pull_request_target` caller. Set `remote_name` to the source's +registered name and configure the caller's native `paths` filter for the files +that should be eligible, such as `docs/**`. Remove `paths` when every pull +request should be eligible. The configured `paths` filter applies to every +eligible event. The caller listens for `labeled`, `synchronize`, and `reopened`; +the job runs only when the PR matches that filter and either adds `docs-preview` +or already has it. The shared workflow independently verifies the label, action, +pull request number, event SHA, and default target branch. + ### `claude-docs-drift.yml` - Dispatch centralized docs drift checks Sends a source pull request to the central docs-drift worker in diff --git a/examples/caller-remote-docs-preview.yml b/examples/caller-remote-docs-preview.yml new file mode 100644 index 0000000..ae0b7ec --- /dev/null +++ b/examples/caller-remote-docs-preview.yml @@ -0,0 +1,37 @@ +# Install as .github/workflows/docs-preview.yml in a repository registered as a +# remote documentation source in ClickHouse/mintlify-docs-dev/remotes.json. +# +# A maintainer enables previews for a pull request by adding the `docs-preview` +# label. While the label remains, each new commit refreshes the preview. See +# README.md for the pull_request_target and Vercel Connect security model. +name: Preview documentation + +run-name: Preview documentation for PR #${{ github.event.pull_request.number }} + +on: + pull_request_target: + types: [labeled, synchronize, reopened] + # Configure this for the directory exported by this repository. Remove the + # paths filter if every pull request should be eligible for a docs preview. + paths: + - "docs/**" + +permissions: + pull-requests: write + +jobs: + preview: + # Both conditions must hold: the PR changes the configured paths above and + # a maintainer has enabled previews by applying docs-preview. + if: >- + contains(github.event.pull_request.labels.*.name, 'docs-preview') && + (github.event.action != 'labeled' || github.event.label.name == 'docs-preview') + uses: ClickHouse/integrations-shared-workflows/.github/workflows/remote-docs-preview.yml@main + with: + # This must match the source's name in the central remotes.json registry. + remote_name: example-docs + pull_request_number: ${{ github.event.pull_request.number }} + secrets: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}