diff --git a/.github/workflows/remote-docs-preview.yml b/.github/workflows/remote-docs-preview.yml index 5678f7817..f34799d67 100644 --- a/.github/workflows/remote-docs-preview.yml +++ b/.github/workflows/remote-docs-preview.yml @@ -11,7 +11,7 @@ on: source_repository: description: Repository containing the pull request required: true - default: ClickHouse/airgapped-docs + default: ClickHouse/airgap-docs type: string pull_request_number: description: Open pull request to preview @@ -105,8 +105,16 @@ jobs: DOCS_PREVIEW_ALIAS: manual-validation run: node bin/validate-deploy-request.ts - - name: Install the Vercel CLI - run: npm install --global vercel@59.11.7 + - name: Resolve the trusted documentation site revision + id: site + working-directory: site + run: | + site_sha="$(git rev-parse HEAD)" + if [[ ! "$site_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Git returned an invalid documentation site SHA." >&2 + exit 1 + fi + echo "sha=$site_sha" >> "$GITHUB_OUTPUT" - name: Mint a source-repository token id: source-token @@ -145,43 +153,129 @@ jobs: echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" echo "head_repository=$head_repository" >> "$GITHUB_OUTPUT" - # The CLI uploads only the trusted Nimbus source. The Vercel deployment - # exchanges its own OIDC identity for a short-lived, repository-scoped - # token through Vercel Connect. The Actions token never enters the build. - - name: Deploy and wait for the preview + # Vercel fetches the trusted Nimbus main revision through the project's + # Git connection. The deployment exchanges its own OIDC identity for a + # short-lived, repository-scoped token through Vercel Connect; the + # Actions token never enters the build. + - name: Trigger and wait for the source preview id: deploy - working-directory: site env: DOCS_REMOTE_NAME: ${{ inputs.remote_name }} DOCS_REMOTE_REF: ${{ steps.pull-request.outputs.head_sha }} DOCS_REMOTE_REPOSITORY: ${{ inputs.source_repository }} DOCS_REMOTE_SOURCE_REPOSITORY: ${{ steps.pull-request.outputs.head_repository }} PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }} + SITE_SHA: ${{ steps.site.outputs.sha }} VERCEL_ORG_ID: ${{ inputs.vercel_org_id || vars.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ inputs.vercel_project_id || vars.VERCEL_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | - preview_url="$( - vercel deploy \ - --yes \ - --archive=tgz \ - --target=remote-preview \ - --token "$VERCEL_TOKEN" \ - --build-env "DOCS_LOCALE=en" \ - --build-env "DOCS_REMOTE_NAME=$DOCS_REMOTE_NAME" \ - --build-env "DOCS_REMOTE_REPOSITORY=$DOCS_REMOTE_REPOSITORY" \ - --build-env "DOCS_REMOTE_SOURCE_REPOSITORY=$DOCS_REMOTE_SOURCE_REPOSITORY" \ - --build-env "DOCS_REMOTE_REF=$DOCS_REMOTE_REF" \ - --meta "sourceRepository=$DOCS_REMOTE_REPOSITORY" \ - --meta "sourceHeadRepository=$DOCS_REMOTE_SOURCE_REPOSITORY" \ - --meta "pullRequest=$PULL_REQUEST_NUMBER" \ - --meta "approvedSha=$DOCS_REMOTE_REF" + 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")" + + 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 site_sha "$SITE_SHA" \ + '{ + name: $project_name, + project: $project_id, + customEnvironmentSlugOrId: "connect-preview", + gitSource: { + type: "github", + org: "ClickHouse", + repo: "mintlify-docs-dev", + ref: "main", + sha: $site_sha + }, + build: {env: { + DOCS_LOCALE: "en", + DOCS_REMOTES: "all", + DOCS_REMOTE_NAME: $remote_name, + DOCS_REMOTE_REPOSITORY: $remote_repository, + DOCS_REMOTE_SOURCE_REPOSITORY: $source_repository, + DOCS_REMOTE_REF: $approved_sha + }}, + meta: { + buildScope: "source-preview", + sourceRepository: $remote_repository, + sourceHeadRepository: $source_repository, + pullRequest: $pull_request, + approvedSha: $approved_sha, + siteSha: $site_sha + } + }' )" + + if ! deployment_response="$( + curl --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://}" + echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" + echo "Vercel deployment: $preview_url" + + for _ in {1..360}; do + 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 + 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) + jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "Vercel deployment did not complete")' \ + <<< "$deployment_response" >&2 + exit 1 + ;; + QUEUED|INITIALIZING|BUILDING) + sleep 10 + ;; + *) + echo "Unexpected Vercel deployment state: $ready_state" >&2 + exit 1 + ;; + esac + done + + if [[ "$ready_state" != "READY" ]]; then + echo "Timed out waiting for Vercel deployment $deployment_id." >&2 + exit 1 + fi if [[ ! "$preview_url" =~ ^https://[^[:space:]]+$ ]]; then echo "Vercel returned an invalid preview URL." >&2 exit 1 fi - echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" - name: Add the preview link to the pull request if: github.repository == inputs.source_repository diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index eb9e78a48..1d013db98 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -40,7 +40,7 @@ jobs: exit 1 fi - - name: Resolve the pull request head + - name: Resolve the pull request revision id: pull-request env: GH_TOKEN: ${{ github.token }} @@ -63,17 +63,26 @@ jobs: 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 - target="remote-preview" trust="trusted-branch" else - target="preview" trust="untrusted-fork" fi echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" echo "head_repository=$head_repository" >> "$GITHUB_OUTPUT" - echo "target=$target" >> "$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 @@ -128,71 +137,133 @@ jobs: echo "scope=$locale_scope" >> "$GITHUB_OUTPUT" echo "summary=$locale_summary" >> "$GITHUB_OUTPUT" - - name: Check out the pull request revision - uses: actions/checkout@v6 - with: - repository: ${{ steps.pull-request.outputs.head_repository }} - ref: ${{ steps.pull-request.outputs.head_sha }} - path: site - persist-credentials: false - - - name: Reject symbolic links in uploaded source - run: | - symlink="$(find site -type l -print -quit)" - if [[ -n "$symlink" ]]; then - echo "Fork and branch previews may not upload symbolic links: $symlink" >&2 - exit 1 - fi - - - name: Verify the checked-out revision - working-directory: site - env: - EXPECTED_SHA: ${{ steps.pull-request.outputs.head_sha }} - run: | - actual_sha="$(git rev-parse HEAD)" - if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then - echo "Checked out $actual_sha instead of approved revision $EXPECTED_SHA." >&2 - exit 1 - fi - - - name: Install the Vercel CLI - run: npm install --global vercel@59.11.7 - - # Do not install dependencies or run repository code in this job. The - # source is uploaded to Vercel, where standard Preview has no Connect - # attachment and trusted branches use the remote-preview environment. - - name: Deploy and wait for the preview + # GitHub owns refs/pull//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 - working-directory: site env: - APPROVED_SHA: ${{ steps.pull-request.outputs.head_sha }} + 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 }} - SOURCE_REPOSITORY: ${{ steps.pull-request.outputs.head_repository }} TRANSLATION_SCOPE: ${{ steps.translations.outputs.scope }} TRUST: ${{ steps.pull-request.outputs.trust }} VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} - VERCEL_TARGET: ${{ steps.pull-request.outputs.target }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | - preview_url="$( - vercel deploy \ - --yes \ - --archive=tgz \ - --target="$VERCEL_TARGET" \ - --token "$VERCEL_TOKEN" \ - --build-env "DOCS_LOCALES=$TRANSLATION_SCOPE" \ - --meta "sourceRepository=$SOURCE_REPOSITORY" \ - --meta "pullRequest=$PULL_REQUEST_NUMBER" \ - --meta "approvedSha=$APPROVED_SHA" \ - --meta "trust=$TRUST" \ - --meta "translations=$TRANSLATION_SCOPE" + repository_owner="${GITHUB_REPOSITORY%%/*}" + repository_name="${GITHUB_REPOSITORY#*/}" + + 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")" + + 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 translations "$TRANSLATION_SCOPE" \ + --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_LOCALES: $translations, + DOCS_REMOTES: "none" + }}, + meta: { + buildScope: "base-preview", + sourceRepository: ($repository_owner + "/" + $repository), + pullRequestHeadRepository: $head_repository, + pullRequestHeadSha: $head_sha, + pullRequest: $pull_request, + approvedSha: $approved_sha, + trust: $trust, + translations: $translations + } + }' )" + + if ! deployment_response="$( + curl --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://}" + echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" + echo "Vercel deployment: $preview_url" + + for _ in {1..360}; do + 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 + 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) + jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "Vercel deployment did not complete")' \ + <<< "$deployment_response" >&2 + exit 1 + ;; + QUEUED|INITIALIZING|BUILDING) + sleep 10 + ;; + *) + echo "Unexpected Vercel deployment state: $ready_state" >&2 + exit 1 + ;; + esac + done + + if [[ "$ready_state" != "READY" ]]; then + echo "Timed out waiting for Vercel deployment $deployment_id." >&2 + exit 1 + fi if [[ ! "$preview_url" =~ ^https://[^[:space:]]+$ ]]; then echo "Vercel returned an invalid preview URL." >&2 exit 1 fi - echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" - name: Add the preview link to the pull request env: diff --git a/.github/workflows/site-production.yml b/.github/workflows/site-production.yml index 730c4b9d1..bbd6b69ba 100644 --- a/.github/workflows/site-production.yml +++ b/.github/workflows/site-production.yml @@ -4,6 +4,23 @@ on: push: branches: [main] workflow_dispatch: + workflow_call: + inputs: + vercel_org_id: + description: Vercel organization ID + required: true + type: string + vercel_project_id: + description: Vercel project ID + required: true + type: string + secrets: + VERCEL_TOKEN: + required: true + outputs: + production_url: + description: URL of the completed Vercel production deployment + value: ${{ jobs.production.outputs.production_url }} permissions: contents: read @@ -17,6 +34,8 @@ jobs: name: Build on Vercel runs-on: ubuntu-latest timeout-minutes: 120 + outputs: + production_url: ${{ steps.deploy.outputs.production_url }} steps: - name: Require the default branch for manual production deployments if: github.event_name == 'workflow_dispatch' @@ -28,37 +47,131 @@ jobs: exit 1 fi - - name: Check out the trusted production revision - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Install the Vercel CLI - run: npm install --global vercel@59.11.7 + - name: Resolve the trusted documentation site revision + id: site + env: + SITE_REPOSITORY: ClickHouse/mintlify-docs-dev + run: | + if [[ "$GITHUB_REPOSITORY" == "$SITE_REPOSITORY" ]]; then + site_sha="$GITHUB_SHA" + else + site_sha="$({ + git ls-remote "https://github.com/$SITE_REPOSITORY.git" refs/heads/main + } | awk 'NR == 1 { print $1 }')" + fi + if [[ ! "$site_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Could not resolve the trusted documentation site revision." >&2 + exit 1 + fi + echo "sha=$site_sha" >> "$GITHUB_OUTPUT" - - name: Deploy all locales and wait for production + - name: Trigger and wait for the Vercel production deployment id: deploy env: - VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} + SITE_SHA: ${{ steps.site.outputs.sha }} + VERCEL_ORG_ID: ${{ inputs.vercel_org_id || vars.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ inputs.vercel_project_id || vars.VERCEL_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | - production_url="$( - vercel deploy \ - --yes \ - --prod \ - --archive=tgz \ - --token "$VERCEL_TOKEN" \ - --build-env "DOCS_LOCALES=all" \ - --meta "sourceRepository=$GITHUB_REPOSITORY" \ - --meta "approvedSha=$GITHUB_SHA" \ - --meta "translations=all" + 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")" + + payload="$( + jq --null-input \ + --arg approved_sha "$SITE_SHA" \ + --arg project_id "$VERCEL_PROJECT_ID" \ + --arg project_name "$project_name" \ + --arg triggering_repository "$GITHUB_REPOSITORY" \ + --arg triggering_sha "$GITHUB_SHA" \ + '{ + name: $project_name, + project: $project_id, + target: "production", + gitSource: { + type: "github", + org: "ClickHouse", + repo: "mintlify-docs-dev", + ref: "main", + sha: $approved_sha + }, + build: {env: { + DOCS_LOCALES: "all", + DOCS_REMOTES: "all" + }}, + meta: { + buildScope: "production", + sourceRepository: "ClickHouse/mintlify-docs-dev", + approvedSha: $approved_sha, + triggeringRepository: $triggering_repository, + triggeringSha: $triggering_sha, + translations: "all" + } + }' )" + + if ! deployment_response="$( + curl --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")" + production_url="https://${deployment_url#https://}" + echo "production_url=$production_url" >> "$GITHUB_OUTPUT" + echo "Vercel deployment: $production_url" + + for _ in {1..720}; do + 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 + 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) + jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "Vercel deployment did not complete")' \ + <<< "$deployment_response" >&2 + exit 1 + ;; + QUEUED|INITIALIZING|BUILDING) + sleep 10 + ;; + *) + echo "Unexpected Vercel deployment state: $ready_state" >&2 + exit 1 + ;; + esac + done + + if [[ "$ready_state" != "READY" ]]; then + echo "Timed out waiting for Vercel deployment $deployment_id." >&2 + exit 1 + fi if [[ ! "$production_url" =~ ^https://[^[:space:]]+$ ]]; then echo "Vercel returned an invalid production URL." >&2 exit 1 fi - echo "production_url=$production_url" >> "$GITHUB_OUTPUT" - name: Publish deployment summary env: diff --git a/bin/fetch-remotes.ts b/bin/fetch-remotes.ts index 7462ca083..835bb82ec 100644 --- a/bin/fetch-remotes.ts +++ b/bin/fetch-remotes.ts @@ -4,11 +4,11 @@ // 2. a GitHub tarball authenticated by GH_TOKEN / GITHUB_TOKEN outside Vercel, // 3. an anonymous GitHub tarball for public repositories, // 4. a shallow GitHub SSH checkout for local development. -// Production always reads each repository's `main` branch. A pull-request -// preview may replace exactly one source with an immutable commit SHA and omits -// the other remote sources. Standard Vercel previews deliberately omit private -// sources; only production and the `remote-preview` custom environment may use -// Vercel Connect. Remote content is copied but never parsed or imported here. +// Production always reads each repository's `main` branch. A source-repository +// preview replaces exactly one source with an immutable commit SHA and omits +// the other remote sources. Base-repository previews omit every remote. Only +// production and the `connect-preview` custom environment may use Vercel +// Connect. Remote content is copied but never parsed or imported here. // Usage: node bin/fetch-remotes.ts import fs from "node:fs"; import path from "node:path"; @@ -28,9 +28,9 @@ if (process.env.VERCEL === "1" && !vercelTarget) { "fetch-remotes: VERCEL_TARGET_ENV/VERCEL_ENV is required; enable Vercel system environment variables", ); } -const isUntrustedVercelPreview = process.env.VERCEL === "1" +const isCredentialFreeVercelEnvironment = process.env.VERCEL === "1" && vercelTarget !== "production" - && vercelTarget !== "remote-preview"; + && vercelTarget !== "connect-preview"; fs.mkdirSync(stateDir, { recursive: true }); type Authentication = "anonymous" | "environment-token" | "vercel-connect"; @@ -57,7 +57,7 @@ async function githubAuthentication(remote: Remote, repository: string): Promise "fetch-remotes: VERCEL_OIDC_TOKEN is required when DOCS_GITHUB_CONNECTOR is configured", ); } - if (isUntrustedVercelPreview) { + if (isCredentialFreeVercelEnvironment) { throw new Error( `fetch-remotes: Vercel Connect is not allowed in the ${vercelTarget || "unknown"} environment`, ); @@ -135,6 +135,17 @@ if (scope.remotePreview && !remoteNames.has(scope.remotePreview.name)) { for (const r of manifest.remotes) { const mount = path.join(root, r.mount); + if (!scope.remotes) { + cleanMount(mount); + const reason = "excluded-from-base-preview"; + fs.writeFileSync( + path.join(stateDir, `${r.name}.json`), + JSON.stringify({ name: r.name, repo: r.repo, ref: "main", skipped: true, reason }, null, 2), + ); + console.log(`fetch-remotes: ${r.name} omitted by build scope (${reason})`); + continue; + } + const selectedPreview = scope.remotePreview?.name === r.name ? scope.remotePreview : undefined; const excludedFromRemotePreview = Boolean(scope.remotePreview && !selectedPreview); if (excludedFromRemotePreview) { @@ -148,10 +159,10 @@ for (const r of manifest.remotes) { continue; } - if (isUntrustedVercelPreview && r.private) { + if (isCredentialFreeVercelEnvironment && r.private) { if (selectedPreview) { throw new Error( - `fetch-remotes: private remote ${r.name} previews must target the remote-preview Vercel environment`, + `fetch-remotes: private remote ${r.name} previews must target the connect-preview Vercel environment`, ); } cleanMount(mount); diff --git a/bin/gen-sidebar.ts b/bin/gen-sidebar.ts index fdb15c6cc..57a04494c 100644 --- a/bin/gen-sidebar.ts +++ b/bin/gen-sidebar.ts @@ -150,7 +150,11 @@ function remoteIsOmitted(remote: (typeof remotes)[number]): boolean { const state = readJson(stateFile) as Obj; if (!state.skipped) return false; const reason = String(state.reason ?? ""); - if (reason !== "excluded-from-remote-preview" && reason !== "excluded-from-untrusted-vercel-preview") { + if ( + reason !== "excluded-from-base-preview" + && reason !== "excluded-from-remote-preview" + && reason !== "excluded-from-untrusted-vercel-preview" + ) { throw new Error(`gen-sidebar: remote ${remote.name} has an unknown omission reason: ${reason}`); } return true; diff --git a/bin/prepare-public.ts b/bin/prepare-public.ts index e8d590870..70c0481d6 100644 --- a/bin/prepare-public.ts +++ b/bin/prepare-public.ts @@ -132,7 +132,10 @@ for (const remote of manifest.remotes) { throw new Error(`prepare-public: fetch state for remote "${remote.name}" does not match remotes.json`); } if (state.skipped) { - if (state.reason === "excluded-from-untrusted-vercel-preview") continue; + if ( + state.reason === "excluded-from-base-preview" + || state.reason === "excluded-from-untrusted-vercel-preview" + ) continue; throw new Error(`prepare-public: remote "${remote.name}" was omitted but its assets were requested`); } @@ -155,4 +158,9 @@ for (const remote of manifest.remotes) { } } -console.log(`prepare-public: ${scope.remotePreview ? `remote preview ${scope.remotePreview.name}` : "full site"} -> ${path.relative(root, output)}`); +const scopeLabel = scope.remotePreview + ? `source preview ${scope.remotePreview.name}` + : scope.remotes + ? "full site" + : "base preview"; +console.log(`prepare-public: ${scopeLabel} -> ${path.relative(root, output)}`); diff --git a/reports/nimbus-poc-notes.md b/reports/nimbus-poc-notes.md index a3ddfc27a..99e57aed6 100644 --- a/reports/nimbus-poc-notes.md +++ b/reports/nimbus-poc-notes.md @@ -156,7 +156,7 @@ them at their current URLs. `ClickHouse/airgap-docs` (40 pages) mounts at `products/clickhouse-private`, and `bin/gen-sidebar.ts` expands the Mintlify `sourceRef` group from the remote's own `docs.json`. The preparation step also replaces the remote's declared `{{variable}}` references before MDX compilation. Locally the -fetcher uses a GitHub token or SSH. On Vercel, production and the `remote-preview` +fetcher uses a GitHub token or SSH. On Vercel, production and the `connect-preview` Custom Environment exchange their deployment OIDC identity for a short-lived, repository-scoped token through Vercel Connect. Standard Preview deployments fetch public sources anonymously and diff --git a/src/README.md b/src/README.md index 3eea28914..e2ced7ca4 100644 --- a/src/README.md +++ b/src/README.md @@ -27,11 +27,12 @@ Mintlify-flavoured MDX build (see `src/plugins/vite-mintlify-snippets.ts` and | `DOCS_INCLUDE` | Comma-separated globs restricting the English collection (spikes, scoped previews). | | `DOCS_LOCALE` | The one locale Worker to build (`en`, `es`, `pt-BR`, and so on); unset = English. | | `DOCS_LOCALES` | Translations to add to the English Vercel artifact: `none`, `all`, or a comma-separated list such as `es,fr`. Vercel production always builds `all`. | +| `DOCS_REMOTES` | Registered remote-source scope: `none` for a base-repository preview and `all` for source previews and production. | | `DOCS_REMOTE_NAME`, `DOCS_REMOTE_REPOSITORY`, `DOCS_REMOTE_REF` | CI-only tuple selecting one registered remote at an immutable commit for an English pull-request preview. | | `DOCS_REMOTE_SOURCE_REPOSITORY` | CI-derived repository that owns the preview SHA. It defaults to the registered repository and differs only for a fork PR. | | `DOCS_REMOTES_PREFETCHED=1` | Requires the remote mounts and fetch-state files supplied by the credentialed CI fetch job. | | `DOCS_PREVIEW_ALIAS` | Lowercase Cloudflare alias used by `pnpm run deploy:preview`. | -| `DOCS_GITHUB_CONNECTOR` | Vercel Connect GitHub connector UID, for example `github/clickhouse-docs`. Configure it only for `production` and the `remote-preview` Custom Environment. | +| `DOCS_GITHUB_CONNECTOR` | Vercel Connect GitHub connector UID, for example `github/clickhouse-docs`. Configure it only for `production` and the `connect-preview` Custom Environment. | | `DOCS_OUT_DIR`, `DOCS_CACHE_DIR` | Isolated output and cache directories (parallel builds never share `dist/`). | | `NODE_OPTIONS=--max-old-space-size=8192` | Recommended for full builds (peak RSS ~3 GB). | @@ -54,8 +55,8 @@ Remote repositories create previews through `.github/workflows/remote-docs-preview.yml`. The caller invokes the reusable workflow manually with a pull request number. It uses a repository-scoped GitHub App token only to resolve the immutable head SHA and the branch or fork -repository that owns it, then deploys trusted Nimbus `main` to the -`remote-preview` Vercel environment. The Vercel build uses +repository that owns it, then asks Vercel to build trusted Nimbus `main` in the +`connect-preview` environment. The Vercel build uses its OIDC identity to request a short-lived, `contents:read` token from Vercel Connect for the branch or fork repository. Public repositories are fetched anonymously. `bin/fetch-remotes.ts` exits before @@ -64,43 +65,57 @@ processing. The Actions token never enters Vercel. Maintainers can also run the workflow directly from the `mintlify-docs-dev` Actions page by providing the registered source, repository, and open pull request number. -Standard Vercel Preview deployments are deliberately tokenless. They fetch -public sources anonymously and omit private sources. Trusted branch previews -that need the complete site must target the `remote-preview` Custom Environment. +Standard Vercel Preview deployments are deliberately tokenless. Base-repository +pull requests use this environment and set `DOCS_REMOTES=none`, regardless of +whether their head branch belongs to the primary repository or a fork. Nimbus application pull requests use `.github/workflows/site-preview.yml`. -The base-branch workflow resolves the current head SHA without running -pull-request code in Actions, uploads that revision with the Vercel CLI, and -updates one preview comment on the pull request. A branch in the primary -repository is sent to the Connect-enabled `remote-preview` environment. A fork -is sent to standard Preview, where the connector is not attached and private -sources are omitted. +The base-branch workflow resolves GitHub's immutable +`refs/pull//merge` revision without checking out or running +pull-request code in Actions. It asks Vercel to fetch that revision through the +project's Git connection and updates one preview comment on the pull request. +Both primary-repository branches and forks use standard Preview and omit every +registered remote source. + +Source-repository pull requests use +`.github/workflows/remote-docs-preview.yml`. Vercel builds trusted Nimbus +`main`, selects exactly one registered source with `DOCS_REMOTE_*`, and fetches +the pull request's immutable head revision. Trusted branches and forks have the +same source-only build scope. They use `connect-preview` so private registered +sources can obtain a short-lived token; public sources remain anonymously +fetchable. + +After a source-repository change reaches its trusted production branch, that +repository calls `.github/workflows/site-production.yml`. The reusable workflow +builds the latest trusted Nimbus `main` with every registered remote and every +translation, rather than promoting the source-only preview. Pull-request previews build English only by default. Add `docs-translations-all` to include every translated collection, or add one or more locale labels such as `docs-translations-es` and `docs-translations-pt-br`. Adding or removing one of these labels starts a new preview with the resulting locale set. `.github/workflows/site-production.yml` -deploys `main` through the same Vercel project with English and every -translation after each merge. Both workflows can also be invoked manually from -the default branch. +asks Vercel to fetch the merged `main` commit through the same Git connection +and build English with every translation. Both workflows can also be invoked +manually from the default branch. Vercel must be provisioned as follows: 1. Keep the single Git-connected Vercel project, but leave automatic Git - deployments disabled as specified in `vercel.json`; GitHub Actions owns both - preview and production deployment creation. + deployments disabled as specified in `vercel.json`; GitHub Actions creates + Git-backed preview and production deployments through the Vercel API. 2. Enable automatic System Environment Variables for the project. -3. Create the `remote-preview` Custom Environment. +3. Create the `connect-preview` Custom Environment. 4. Create a Vercel-managed GitHub connector named `clickhouse-docs` and install it only for the private repositories registered in `remotes.json` and any private forks that are explicitly allowed to receive previews. -5. Attach `github/clickhouse-docs` to `production` and `remote-preview`. Do not +5. Attach `github/clickhouse-docs` to `production` and `connect-preview`. Do not attach it to standard `preview`. 6. Set `DOCS_GITHUB_CONNECTOR=github/clickhouse-docs` in `production` and - `remote-preview`, but not in standard `preview`. -7. Keep standard Preview free of secrets and privileged integrations: a fork - pull request controls the uploaded build source in that environment. + `connect-preview`, but not in standard `preview`. +7. Keep standard Preview free of secrets and privileged integrations. Every + base-repository pull request builds from the primary repository's synthetic + merge ref in this environment and omits registered remotes. 8. Add `VERCEL_ORG_ID` and `VERCEL_PROJECT_ID` as GitHub Actions variables and `VERCEL_TOKEN` as a GitHub Actions secret. 9. Keep the Vercel build command as `pnpm run build:vercel` and the output diff --git a/src/lib/scope.ts b/src/lib/scope.ts index b7b4d5960..9be18a67e 100644 --- a/src/lib/scope.ts +++ b/src/lib/scope.ts @@ -9,6 +9,7 @@ * * { * "locale": "en", + * "remotes": true, * "reference": false, * "remotePreview": { * "name": "clickhouse-private", @@ -41,6 +42,8 @@ export interface BuildScope { locales: Locale[]; /** Whether `reference/**` is part of the build. */ reference: boolean; + /** Whether registered remote sources participate in this build. */ + remotes: boolean; /** Build only this registered remote at an immutable preview revision. */ remotePreview?: RemotePreview; source: "env" | "file" | "default"; @@ -48,6 +51,7 @@ export interface BuildScope { interface ScopeFile { locale?: unknown; + remotes?: unknown; reference?: unknown; remotePreview?: unknown; } @@ -128,9 +132,25 @@ function parseReference(value: unknown, source: string): boolean { } } +function parseRemotes(value: unknown, source: string): boolean { + if (typeof value === "boolean") return value; + if (typeof value !== "string") { + throw new Error(`${source} must be all or none`); + } + switch (value.trim().toLowerCase()) { + case "all": + return true; + case "none": + return false; + default: + throw new Error(`${source} must be all or none`); + } +} + export function readScope(root = process.cwd()): BuildScope { const envLocale = (process.env.DOCS_LOCALE ?? "").trim(); const envLocales = (process.env.DOCS_LOCALES ?? "").trim(); + const envRemotes = (process.env.DOCS_REMOTES ?? "").trim(); const envReference = (process.env.DOCS_REFERENCE ?? "").trim().toLowerCase(); const envRemoteName = (process.env.DOCS_REMOTE_NAME ?? "").trim(); const envRemoteRepository = (process.env.DOCS_REMOTE_REPOSITORY ?? "").trim(); @@ -191,6 +211,11 @@ export function readScope(root = process.cwd()): BuildScope { : fileScope?.reference !== undefined ? parseReference(fileScope.reference, ".preview-scope.json reference") : true; + const remotes = envRemotes + ? parseRemotes(envRemotes, "DOCS_REMOTES") + : fileScope?.remotes !== undefined + ? parseRemotes(fileScope.remotes, ".preview-scope.json remotes") + : true; const remotePreview = hasRemoteEnvironment ? parseRemotePreview( { @@ -206,11 +231,18 @@ export function readScope(root = process.cwd()): BuildScope { if (remotePreview && (locale !== "en" || locales.length > 0)) { throw new Error("Remote pull-request previews are English-only; omit DOCS_LOCALES"); } - if (envLocale || envLocales || envReference || hasRemoteEnvironment) source = "env"; + if (remotePreview && !remotes) { + throw new Error("Remote pull-request previews require DOCS_REMOTES=all"); + } + if (isVercelProduction && !remotes) { + throw new Error("Vercel production builds require DOCS_REMOTES=all"); + } + if (envLocale || envLocales || envRemotes || envReference || hasRemoteEnvironment) source = "env"; return { locale, locales, reference, + remotes, remotePreview, source, };