diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs new file mode 100644 index 0000000000..51711c6d86 --- /dev/null +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -0,0 +1,842 @@ +// Run with node --test; these workflow helpers need no workspace dependencies. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { + authorizePreview, + commentPreview, + isCurrentPreview, + previewAlias, + previewUrl, + requireDeploymentApproval, + validateAssets, +} from '../docs-fork-preview.mjs'; + +const versionId = '11111111-1111-4111-8111-111111111111'; +const versionUrl = 'https://11111111-viteplus-dev.voidzero-docs.workers.dev'; + +function grantApproval(state, runId = 987) { + const approval = { + environments: [{ id: 10, name: 'docs-preview' }], + state: 'approved', + user: { login: 'reviewer' }, + }; + state.approvals.set(runId, [approval]); + return { approval }; +} + +function uploadOutput(overrides = {}) { + return `${JSON.stringify({ + type: 'version-upload', + version: 1, + worker_name: 'viteplus-dev', + version_id: versionId, + preview_url: versionUrl, + preview_alias_url: previewUrl(2684), + ...overrides, + })}\n`; +} + +function fixture() { + const source = { id: 42, full_name: 'contributor/vite-plus', owner: { login: 'contributor' } }; + const context = { + eventName: 'workflow_run', + runId: 987, + repo: { owner: 'voidzero-dev', repo: 'vite-plus' }, + payload: { + workflow_run: { + id: 123, + run_attempt: 1, + path: '.github/workflows/build-docs-fork-preview.yml', + event: 'pull_request', + conclusion: 'success', + head_sha: 'a'.repeat(40), + head_branch: 'docs-update', + head_repository: source, + actor: { login: 'maintainer' }, + pull_requests: [], + }, + }, + }; + const pr = { + number: 2684, + state: 'open', + labels: [{ name: 'docs-preview' }], + base: { ref: 'main', repo: { full_name: 'voidzero-dev/vite-plus' } }, + head: { sha: 'a'.repeat(40), ref: 'docs-update', repo: structuredClone(source) }, + }; + const state = { + pulls: [pr], + artifacts: [{ id: 456, name: 'docs-fork-preview-1', expired: false }], + comments: [], + outputs: {}, + writes: [], + requests: [], + permission: 'write', + reviewerPermission: 'write', + approvals: new Map(), + }; + const github = { + rest: { + repos: { + getCollaboratorPermissionLevel: async (params) => { + state.requests.push(params); + return { + data: { + permission: + params.username === 'reviewer' ? state.reviewerPermission : state.permission, + }, + }; + }, + }, + pulls: { + list() {}, + get: async (params) => { + state.requests.push(params); + return { data: structuredClone(pr) }; + }, + }, + actions: { + listWorkflowRunArtifacts() {}, + getReviewsForRun: async (params) => { + state.requests.push(params); + return { data: state.approvals.get(params.run_id) ?? [] }; + }, + }, + issues: { + listComments() {}, + createComment: async (params) => state.writes.push({ method: 'create', ...params }), + updateComment: async (params) => state.writes.push({ method: 'update', ...params }), + }, + }, + paginate: async (method, params) => { + state.requests.push(params); + if (method === github.rest.pulls.list) { + return structuredClone(state.pulls); + } + if (method === github.rest.actions.listWorkflowRunArtifacts) { + return state.artifacts; + } + if (method === github.rest.issues.listComments) { + return state.comments; + } + throw new Error('Unexpected GitHub request'); + }, + }; + const core = { + info() {}, + setOutput: (key, value) => { + state.outputs[key] = value; + }, + }; + return { github, context, core, pr, state, ...grantApproval(state) }; +} + +await test('uses the package preview build and protected deployment pattern', async () => { + const build = await readFile( + new URL('../../workflows/build-docs-fork-preview.yml', import.meta.url), + 'utf8', + ); + const deploy = await readFile( + new URL('../../workflows/deploy-docs-fork-preview.yml', import.meta.url), + 'utf8', + ); + const helper = await readFile(new URL('../docs-fork-preview.mjs', import.meta.url), 'utf8'); + assert.doesNotMatch( + build + deploy + helper, + /pull_request_target|statuses:|createCommitStatus|previewApprovalState/, + ); + assert.match(build, /github\.event\.action == 'labeled' &&/); + assert.match(build, /github\.event\.label\.name == 'docs-preview'/); + assert.match(build, /ref: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/); + assert.match(build, /cache: false/); + assert.doesNotMatch(build, /secrets\.|environment:|id-token:|: write/); + assert.match( + deploy, + /workflow_run:\n\s+workflows: \['Build Docs Fork Preview'\]\n\s+types: \[completed\]/, + ); + assert.doesNotMatch(deploy, /\n approve:/); + const authorize = deploy.split('\n authorize:\n')[1].split('\n deploy:\n')[0]; + assert.doesNotMatch(authorize, /environment:|secrets\.|: write/); + const job = deploy.split('\n deploy:\n')[1]; + assert.match(job, /environment:\n\s+name: docs-preview/); + assert.match( + job, + /name: 'Deploy PR #\$\{\{ needs\.authorize\.outputs\.pr \}\} at \$\{\{ github\.event\.workflow_run\.head_sha \}\}'/, + ); + assert.match(job, /ref: \$\{\{ github\.sha \}\}/); + const approval = job.indexOf('await requireDeploymentApproval({ github, context });'); + assert.ok(approval >= 0); + assert.ok(approval < job.indexOf('- name: Install Wrangler')); + assert.ok(approval < job.indexOf('- uses: actions/download-artifact@')); + assert.match(job, /artifact-ids: \$\{\{ needs\.authorize\.outputs\.artifact-id \}\}/); + assert.match(job, /run-id: \$\{\{ github\.event\.workflow_run\.id \}\}/); + assert.ok(job.indexOf('await isCurrentPreview(') < job.indexOf('- name: Upload preview version')); + assert.match(job, /if: steps\.current\.outputs\.current == 'true'/); +}); + +await test('isolates build concurrency by PR and SHA, including delayed old runs', async () => { + const yaml = await readFile( + new URL('../../workflows/build-docs-fork-preview.yml', import.meta.url), + 'utf8', + ); + const template = yaml.match(/concurrency:\n\s+group: (.+)\n\s+cancel-in-progress: true/)?.[1]; + assert.ok(template); + const group = (number, sha) => + template + .replaceAll('${{ github.event.pull_request.number }}', String(number)) + .replaceAll('${{ github.event.pull_request.head.sha }}', sha); + const newer = group(2684, 'b'.repeat(40)); + const delayed = group(2684, 'a'.repeat(40)); + assert.notEqual(delayed, newer); + assert.notEqual(group(2685, 'b'.repeat(40)), newer); + assert.equal(group(2684, 'b'.repeat(40)), newer); +}); + +await test('reuses the PR origin across commits, builds, and reruns while pinning artifacts', async () => { + const build = await readFile( + new URL('../../workflows/build-docs-fork-preview.yml', import.meta.url), + 'utf8', + ); + const deploy = await readFile( + new URL('../../workflows/deploy-docs-fork-preview.yml', import.meta.url), + 'utf8', + ); + const originTemplate = build.match(/DOCS_SITE_ORIGIN: (.+)/)?.[1]; + const artifactTemplate = build.match(/name: (docs-fork-preview-.+)/)?.[1]; + assert.ok(originTemplate); + assert.ok(artifactTemplate); + for (const [number, runId, attempt, sha] of [ + [2684, 123, 1, 'a'.repeat(40)], + [2684, 124, 1, 'b'.repeat(40)], + [2684, 124, 2, 'b'.repeat(40)], + [2685, 125, 1, 'a'.repeat(40)], + ]) { + const f = fixture(); + f.pr.number = number; + f.pr.head.sha = sha; + f.context.payload.workflow_run.id = runId; + f.context.payload.workflow_run.run_attempt = attempt; + f.context.payload.workflow_run.head_sha = sha; + f.state.artifacts[0].name = artifactTemplate.replace( + '${{ github.run_attempt }}', + String(attempt), + ); + await authorizePreview(f); + const origin = originTemplate.replace( + '${{ github.event.pull_request.number }}', + String(number), + ); + assert.equal(origin, `https://pr-${number}-viteplus-dev.voidzero-docs.workers.dev`); + assert.equal(origin, f.state.outputs['preview-url']); + assert.equal(f.state.outputs['preview-alias'], `pr-${number}`); + assert.equal(f.state.outputs['artifact-id'], 456); + } + assert.match(deploy, /preview-alias: \$\{\{ steps\.preview\.outputs\.preview-alias \}\}/); + assert.match(deploy, /PREVIEW_ALIAS: \$\{\{ needs\.authorize\.outputs\.preview-alias \}\}/); + assert.match(deploy, /--preview-alias "\$PREVIEW_ALIAS"/); +}); + +await test('uses each PR origin for shell and PowerShell installer links', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'docs-preview-installers-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const snapshots = []; + for (const number of [2684, 2685]) { + const root = join(directory, String(number)); + const scripts = join(root, 'docs', '.vitepress', 'scripts'); + const output = join(root, 'docs', 'public'); + const installers = join(root, 'packages', 'cli'); + for (const path of [scripts, output, installers]) { + await mkdir(path, { recursive: true }); + } + const script = join(scripts, 'copy-installers.mjs'); + await copyFile( + new URL('../../../docs/.vitepress/scripts/copy-installers.mjs', import.meta.url), + script, + ); + for (const name of ['install.sh', 'install.ps1', 'install-legacy.sh', 'install-legacy.ps1']) { + await copyFile( + new URL(`../../../packages/cli/${name}`, import.meta.url), + join(installers, name), + ); + } + const origin = previewUrl(number); + execFileSync(process.execPath, [script], { env: { ...process.env, DOCS_SITE_ORIGIN: origin } }); + const shell = await readFile(join(output, 'install.sh'), 'utf8'); + const powershell = await readFile(join(output, 'install.ps1'), 'utf8'); + assert.ok(shell.includes(`${origin}/install-legacy.sh`)); + assert.ok(powershell.includes(`${origin}/install-legacy.ps1`)); + snapshots.push({ origin, shell, powershell }); + } + assert.equal(new Set(snapshots.map((snapshot) => snapshot.origin)).size, 2); + for (const snapshot of snapshots) { + for (const other of snapshots) { + if (other.origin !== snapshot.origin) { + assert.ok(!snapshot.shell.includes(other.origin)); + assert.ok(!snapshot.powershell.includes(other.origin)); + } + } + } +}); + +await test('pins an artifact to the triggering build attempt', async () => { + const f = fixture(); + f.state.artifacts.push({ id: 789, name: 'docs-fork-preview-2', expired: false }); + await authorizePreview(f); + assert.equal(f.state.outputs['artifact-id'], 456); + assert.equal(f.state.outputs['preview-alias'], 'pr-2684'); + f.context.payload.workflow_run.run_attempt = 2; + await authorizePreview(f); + assert.equal(f.state.outputs['artifact-id'], 789); + assert.equal(f.state.outputs['preview-alias'], 'pr-2684'); + f.context.payload.workflow_run.run_attempt = 3; + await assert.rejects(authorizePreview(f), /Expected one active docs-fork-preview-3/); +}); + +await test('validates build identities before making requests', async () => { + for (const value of [undefined, 0, -1, 1.5, NaN, '123', Number.MAX_SAFE_INTEGER + 1]) { + for (const [runId, attempt] of [ + [value, 1], + [123, value], + ]) { + const f = fixture(); + f.context.payload.workflow_run.id = runId; + f.context.payload.workflow_run.run_attempt = attempt; + await assert.rejects(authorizePreview(f), /Invalid docs preview build identity/); + assert.deepEqual(f.state.requests, []); + } + } +}); + +await test('keeps PR aliases within DNS limits', () => { + const alias = previewAlias(Number.MAX_SAFE_INTEGER); + assert.match(alias, /^[a-z][a-z0-9-]*$/); + assert.ok(`${alias}-viteplus-dev`.length <= 63); +}); + +await test('serializes deployments per PR with the default queue and lets running uploads finish', async () => { + const yaml = await readFile( + new URL('../../workflows/deploy-docs-fork-preview.yml', import.meta.url), + 'utf8', + ); + assert.match( + yaml, + /concurrency:\n\s+group: deploy-docs-fork-preview-\$\{\{ needs\.authorize\.outputs\.pr \}\}\n\s+cancel-in-progress: false/, + ); +}); + +await test('authorizes a fork with an empty workflow_run PR list and pins its artifact', async () => { + const f = fixture(); + // The authorize job must be able to request review before approval exists. + f.state.approvals.clear(); + await authorizePreview(f); + assert.deepEqual(f.state.requests, [ + { + owner: 'voidzero-dev', + repo: 'vite-plus', + state: 'open', + base: 'main', + head: 'contributor:docs-update', + }, + { owner: 'voidzero-dev', repo: 'vite-plus', username: 'maintainer' }, + { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 123 }, + { owner: 'voidzero-dev', repo: 'vite-plus', pull_number: 2684 }, + { owner: 'voidzero-dev', repo: 'vite-plus', username: 'maintainer' }, + ]); + assert.deepEqual(f.state.outputs, { + pr: 2684, + 'artifact-id': 456, + 'preview-alias': 'pr-2684', + 'preview-url': 'https://pr-2684-viteplus-dev.voidzero-docs.workers.dev', + }); + await assert.rejects(requireDeploymentApproval(f), /needs maintainer approval/); + assert.equal(await isCurrentPreview(f, 2684), false); +}); + +await test('checks approval for the current deployment run, not the build run', async () => { + const f = fixture(); + await requireDeploymentApproval(f); + assert.deepEqual(f.state.requests, [ + { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 987 }, + { owner: 'voidzero-dev', repo: 'vite-plus', username: 'reviewer' }, + ]); +}); + +await test('does not approve a new commit when a maintainer applies an unrelated label', async () => { + const f = fixture(); + assert.equal(await isCurrentPreview(f, 2684), true); + // A fork can change its build triggers. The persistent label and original + // actor cannot approve B; its new deployment run needs its own review. + f.pr.head.sha = 'b'.repeat(40); + f.context.payload.workflow_run.head_sha = f.pr.head.sha; + f.context.payload.workflow_run.id = 124; + f.context.runId = 988; + await authorizePreview(f); + assert.equal(f.state.outputs.pr, 2684); + await assert.rejects(requireDeploymentApproval(f), /needs maintainer approval/); + assert.equal(await isCurrentPreview(f, 2684), false); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); + grantApproval(f.state, f.context.runId); + await requireDeploymentApproval(f); + assert.equal(await isCurrentPreview(f, 2684), true); +}); + +for (const [name, mutate] of [ + ['no review or an unprotected environment', (f) => f.state.approvals.clear()], + [ + 'a build-run review', + (f) => { + f.state.approvals.clear(); + grantApproval(f.state, 123); + }, + ], + [ + 'another deployment review', + (f) => { + f.state.approvals.clear(); + grantApproval(f.state, 986); + }, + ], + ['another environment', (f) => (f.approval.environments[0].name = 'release')], + ['missing environments', (f) => (f.approval.environments = undefined)], + ['no environments', (f) => (f.approval.environments = [])], + ['a missing reviewer', (f) => (f.approval.user = undefined)], + ...['pending', 'rejected', undefined].map((state) => [ + state + ' review', + (f) => (f.approval.state = state), + ]), + ...['read', 'triage', 'none', undefined].map((permission) => [ + permission + ' reviewer permission', + (f) => (f.state.reviewerPermission = permission), + ]), +]) { + await test('refuses deployment and commenting with ' + name, async () => { + const f = fixture(); + mutate(f); + await assert.rejects(requireDeploymentApproval(f), /needs maintainer approval/); + assert.equal(await isCurrentPreview(f, 2684), false); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); + }); +} + +for (const permission of ['admin', 'maintain', 'write']) { + await test('accepts an environment reviewer with ' + permission + ' permission', async () => { + const f = fixture(); + f.state.reviewerPermission = permission; + await requireDeploymentApproval(f); + }); +} + +for (const state of ['pending', 'rejected']) { + for (const first of [true, false]) { + await test( + 'rejects mixed ' + state + ' and approved reviews regardless of history order: ' + first, + async () => { + const f = fixture(); + const review = { ...f.approval, state }; + f.state.approvals.set(987, first ? [review, f.approval] : [f.approval, review]); + await assert.rejects(requireDeploymentApproval(f), /needs maintainer approval/); + }, + ); + } +} + +await test('ignores reviews for unrelated environments', async () => { + const f = fixture(); + f.state.approvals.get(987).unshift({ state: 'rejected', environments: [{ name: 'release' }] }); + await requireDeploymentApproval(f); +}); + +await test('rechecks approval revocation before upload and commenting', async () => { + const f = fixture(); + await requireDeploymentApproval(f); + f.approval.state = 'rejected'; + assert.equal(await isCurrentPreview(f, 2684), false); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); +}); + +for (const [area, method] of [ + ['actions', 'getReviewsForRun'], + ['repos', 'getCollaboratorPermissionLevel'], +]) { + await test('fails closed when the deployment approval ' + method + ' lookup fails', async () => { + const f = fixture(); + f.github.rest[area][method] = async () => { + throw new Error('GitHub API failed'); + }; + await assert.rejects(requireDeploymentApproval(f), /GitHub API failed/); + await assert.rejects(isCurrentPreview(f, 2684), /GitHub API failed/); + await assert.rejects(commentPreview(f, 2684, uploadOutput()), /GitHub API failed/); + assert.deepEqual(f.state.writes, []); + }); +} + +for (const runId of [undefined, 0, -1, '987', Number.MAX_SAFE_INTEGER + 1]) { + await test('rejects an invalid trusted deployment run ID: ' + runId, async () => { + const f = fixture(); + f.context.runId = runId; + await assert.rejects(requireDeploymentApproval(f), /Invalid docs preview workflow run/); + assert.deepEqual(f.state.requests, []); + }); +} + +for (const [name, mutate] of [ + ['new commit', (f) => (f.pr.head.sha = 'b'.repeat(40))], + ['label removal', (f) => (f.pr.labels = [])], + ['PR closure', (f) => (f.pr.state = 'closed')], + ['base change', (f) => (f.pr.base.ref = 'release')], + ['requester permission removal', (f) => (f.state.permission = 'read')], +]) { + await test('rechecks ' + name + ' before requesting environment review', async () => { + const f = fixture(); + const paginate = f.github.paginate; + f.github.paginate = async (method, params) => { + const result = await paginate(method, params); + if (method === f.github.rest.actions.listWorkflowRunArtifacts) { + mutate(f); + } + return result; + }; + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + }); +} + +await test('fails closed when the PR recheck before queueing fails', async () => { + const f = fixture(); + f.github.rest.pulls.get = async () => { + throw new Error('GitHub PR lookup failed'); + }; + await assert.rejects(authorizePreview(f), /GitHub PR lookup failed/); + assert.deepEqual(f.state.outputs, {}); +}); + +for (const [field, value] of [ + ['path', '.github/workflows/spoof.yml'], + ['event', 'push'], + ['conclusion', 'failure'], + ['head_sha', 'invalid'], + ['head_repository', null], + ['head_branch', ''], +]) { + await test(`rejects a run with invalid ${field}`, async () => { + const f = fixture(); + f.context.payload.workflow_run[field] = value; + await assert.rejects(authorizePreview(f), /Invalid docs preview workflow run/); + assert.deepEqual(f.state.outputs, {}); + }); +} + +await test('rejects a workflow running in another repository', async () => { + const f = fixture(); + f.context.repo.owner = 'contributor'; + await assert.rejects(authorizePreview(f), /Invalid docs preview workflow run/); +}); + +await test('does not authorize or comment on a label event', async () => { + const f = fixture(); + f.context.eventName = 'pull_request_target'; + await assert.rejects(authorizePreview(f), /Invalid docs preview workflow run/); + await assert.rejects( + commentPreview(f, 2684, uploadOutput()), + /Invalid docs preview workflow run/, + ); + assert.deepEqual(f.state.outputs, {}); + assert.deepEqual(f.state.writes, []); +}); + +await test('leaves same-repository previews to the existing integration', async () => { + const f = fixture(); + f.context.payload.workflow_run.head_repository.full_name = 'voidzero-dev/vite-plus'; + await authorizePreview(f); + assert.deepEqual(f.state.requests, []); + assert.deepEqual(f.state.outputs, {}); +}); + +for (const { name, mutate } of [ + { name: 'closed', mutate: (pr) => (pr.state = 'closed') }, + { name: 'no preview label', mutate: (pr) => (pr.labels = []) }, + { name: 'missing labels', mutate: (pr) => (pr.labels = undefined) }, + { name: 'unrelated label', mutate: (pr) => (pr.labels = [{ name: 'preview-build' }]) }, + { name: 'stale commit', mutate: (pr) => (pr.head.sha = 'b'.repeat(40)) }, + { name: 'other source repository', mutate: (pr) => (pr.head.repo.id = 99) }, + { name: 'renamed source repository', mutate: (pr) => (pr.head.repo.full_name = 'someone/other') }, + { name: 'other source branch', mutate: (pr) => (pr.head.ref = 'other') }, + { name: 'other base branch', mutate: (pr) => (pr.base.ref = 'release') }, + { name: 'other base repository', mutate: (pr) => (pr.base.repo.full_name = 'someone/other') }, + { name: 'deleted fork', mutate: (pr) => (pr.head.repo = null) }, +]) { + await test(`skips a PR with ${name}, including the check immediately before upload`, async () => { + const f = fixture(); + mutate(f.pr); + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + assert.equal(await isCurrentPreview(f, 2684), false); + }); +} + +await test('rejects ambiguous PR matches', async () => { + const f = fixture(); + f.state.pulls.push({ ...f.pr, number: 2685 }); + await assert.rejects(authorizePreview(f), /More than one PR/); +}); + +for (const permission of ['admin', 'maintain', 'write']) { + await test(`accepts a labeled preview requested with ${permission} permission`, async () => { + const f = fixture(); + f.state.permission = permission; + await authorizePreview(f); + assert.equal(f.state.outputs.pr, 2684); + assert.equal(await isCurrentPreview(f, 2684), true); + }); +} + +for (const permission of ['read', 'triage', 'none', undefined]) { + await test(`rejects an original run actor with ${permission} permission`, async () => { + const f = fixture(); + f.state.permission = permission; + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + assert.equal(await isCurrentPreview(f, 2684), false); + assert.ok(f.state.requests.every((request) => !('run_id' in request))); + }); +} + +await test('rejects a missing original run actor', async () => { + const f = fixture(); + delete f.context.payload.workflow_run.actor; + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + assert.equal(await isCurrentPreview(f, 2684), false); +}); + +await test('does not authorize an outsider run when a maintainer reruns it', async () => { + const f = fixture(); + f.context.payload.workflow_run.actor = { login: 'contributor' }; + f.context.payload.workflow_run.triggering_actor = { login: 'maintainer' }; + f.state.permission = 'read'; + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + assert.ok(f.state.requests.some((request) => request.username === 'contributor')); + assert.ok(f.state.requests.every((request) => request.username !== 'maintainer')); +}); + +await test('fails closed when the requester permission check fails', async () => { + const f = fixture(); + f.github.rest.repos.getCollaboratorPermissionLevel = async () => { + throw new Error('GitHub permission check failed'); + }; + await assert.rejects(authorizePreview(f), /GitHub permission check failed/); + assert.deepEqual(f.state.outputs, {}); + await assert.rejects(isCurrentPreview(f, 2684), /GitHub permission check failed/); +}); + +await test('rechecks label removal after authorization and before commenting', async () => { + const f = fixture(); + await authorizePreview(f); + assert.equal(f.state.outputs.pr, 2684); + f.pr.labels = []; + assert.equal(await isCurrentPreview(f, 2684), false); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); +}); + +await test('rechecks revoked requester permission after authorization', async () => { + const f = fixture(); + await authorizePreview(f); + assert.equal(f.state.outputs.pr, 2684); + f.state.permission = 'read'; + assert.equal(await isCurrentPreview(f, 2684), false); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); +}); + +await test('does not reuse an approved run for a new commit while the label remains', async () => { + const f = fixture(); + await authorizePreview(f); + f.pr.head.sha = 'b'.repeat(40); + assert.equal(await isCurrentPreview(f, 2684), false); + + // Even if a fork changes its workflow to run on pushes, the new run does + // not inherit permission from the actor of the earlier labeled run. + f.context.payload.workflow_run.head_sha = f.pr.head.sha; + f.context.payload.workflow_run.actor = { login: 'contributor' }; + f.state.permission = 'read'; + f.state.outputs = {}; + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); +}); + +await test('skips successful helper-only or unrelated label runs without artifacts', async () => { + const f = fixture(); + f.state.artifacts = []; + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); +}); + +for (const artifacts of [ + [{ id: 456, name: 'docs-fork-preview-1', expired: true }], + [{ id: 456, name: 'other', expired: false }], + [ + { id: 456, name: 'docs-fork-preview-1', expired: false }, + { id: 789, name: 'docs-fork-preview-1', expired: false }, + ], +]) { + await test(`rejects missing, expired, or ambiguous artifacts: ${JSON.stringify(artifacts)}`, async () => { + const f = fixture(); + f.state.artifacts = artifacts; + await assert.rejects(authorizePreview(f), /Expected one active/); + assert.deepEqual(f.state.outputs, {}); + }); +} + +await test('ignores a contributor comment that copies the bot marker', async () => { + const f = fixture(); + f.state.comments.push({ + id: 100, + user: { login: 'contributor' }, + body: '', + }); + await commentPreview(f, 2684, uploadOutput()); + assert.equal(f.state.writes[0].method, 'create'); + assert.equal(f.state.writes[0].issue_number, 2684); + assert.equal( + f.state.writes[0].body, + `\nCloudflare documentation preview: ${previewUrl(2684)}\n\nCommit: ${'a'.repeat(40)}`, + ); +}); + +await test('updates the existing bot comment', async () => { + const f = fixture(); + f.state.comments.push({ + id: 101, + user: { login: 'github-actions[bot]' }, + body: '\nPrevious preview', + }); + await commentPreview(f, 2684, uploadOutput()); + assert.equal(f.state.writes[0].method, 'update'); + assert.equal(f.state.writes[0].comment_id, 101); + assert.ok(f.state.writes[0].body.includes(previewUrl(2684))); +}); + +await test('does not comment if the PR changes during upload', async () => { + const f = fixture(); + f.pr.head.sha = 'b'.repeat(40); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); +}); + +await test('updates one preview comment with the same PR URL across commits and reruns', async () => { + const f = fixture(); + await commentPreview(f, 2684, uploadOutput()); + const previousBody = f.state.writes[0].body; + f.state.comments.push({ + id: 101, + user: { login: 'github-actions[bot]' }, + body: previousBody, + }); + f.state.writes = []; + + for (const attempt of [1, 2]) { + f.context.payload.workflow_run.id = 124; + f.context.payload.workflow_run.run_attempt = attempt; + f.context.payload.workflow_run.head_sha = 'b'.repeat(40); + f.pr.head.sha = 'b'.repeat(40); + f.context.runId = 987 + attempt; + grantApproval(f.state, f.context.runId); + await commentPreview( + f, + 2684, + uploadOutput({ + version_id: '22222222-2222-4222-8222-222222222222', + preview_url: 'https://22222222-viteplus-dev.voidzero-docs.workers.dev', + }), + ); + } + + assert.equal(f.state.writes.length, 2); + for (const comment of f.state.writes) { + assert.equal(comment.method, 'update'); + assert.equal(comment.comment_id, 101); + assert.equal(comment.body, previousBody.replace('a'.repeat(40), 'b'.repeat(40))); + assert.deepEqual(comment.body.match(/https:\/\/\S+/g), [previewUrl(2684)]); + } +}); + +await test('reads the PR alias from Wrangler JSONL with other records and blank lines', async () => { + const f = fixture(); + await commentPreview(f, 2684, `\n${JSON.stringify({ type: 'other' })}\n${uploadOutput()}\n`); + assert.ok(f.state.writes[0].body.includes(previewUrl(2684))); + assert.ok(!f.state.writes[0].body.includes(versionUrl)); +}); + +for (const [name, output] of [ + ['missing upload', ''], + ['duplicate uploads', uploadOutput() + uploadOutput()], + ['invalid JSON', '{'], + ['unsupported output version', uploadOutput({ version: 2 })], + ['another Worker', uploadOutput({ worker_name: 'other' })], + ['invalid version ID', uploadOutput({ version_id: 'invalid' })], + ['disabled preview URLs', uploadOutput({ preview_url: undefined })], + ['alias instead of version URL', uploadOutput({ preview_url: previewUrl(2684) })], + ['missing PR alias', uploadOutput({ preview_alias_url: undefined })], + ['another PR alias', uploadOutput({ preview_alias_url: previewUrl(2685) })], + [ + 'build attempt alias', + uploadOutput({ + preview_alias_url: 'https://build-123-1-viteplus-dev.voidzero-docs.workers.dev', + }), + ], + [ + 'another version URL', + uploadOutput({ preview_url: versionUrl.replace('11111111', '22222222') }), + ], + ['another host', uploadOutput({ preview_url: 'https://example.com' })], +]) { + await test(`does not comment for Wrangler output with ${name}`, async () => { + const f = fixture(); + await assert.rejects(commentPreview(f, 2684, output)); + assert.deepEqual(f.state.writes, []); + }); +} + +await test('rejects invalid PR numbers before using them in URLs or requests', async () => { + for (const number of [ + undefined, + 0, + -1, + 1.5, + NaN, + Number.MAX_SAFE_INTEGER + 1, + '2684', + '2684\nother-output=true', + ]) { + assert.throws(() => previewAlias(number), /Invalid pull request number/); + assert.throws(() => previewUrl(number), /Invalid pull request number/); + await assert.rejects(isCurrentPreview(fixture(), number), /Invalid pull request number/); + } +}); + +await test('accepts a static site and rejects links outside the artifact', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'docs-preview-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + await writeFile(join(directory, 'index.html'), 'Preview'); + await mkdir(join(directory, 'assets')); + await writeFile(join(directory, 'assets', 'app.js'), 'window.preview = true;'); + await validateAssets(directory); + await symlink(join(directory, 'index.html'), join(directory, 'assets', 'link')); + await assert.rejects(validateAssets(directory), /must be regular files/); +}); + +await test('rejects an artifact without a site index', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'docs-preview-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + await assert.rejects(validateAssets(directory), /ENOENT/); +}); diff --git a/.github/scripts/docs-fork-preview.mjs b/.github/scripts/docs-fork-preview.mjs new file mode 100644 index 0000000000..d3f8fa99c5 --- /dev/null +++ b/.github/scripts/docs-fork-preview.mjs @@ -0,0 +1,256 @@ +import { lstat, readdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const repository = 'voidzero-dev/vite-plus'; +const buildWorkflow = '.github/workflows/build-docs-fork-preview.yml'; +const marker = ''; +const previewLabel = 'docs-preview'; + +function validatePrNumber(number) { + if (!Number.isSafeInteger(number) || number <= 0) { + throw new Error('Invalid pull request number'); + } +} + +export function previewAlias(number) { + validatePrNumber(number); + return `pr-${number}`; +} + +export function previewUrl(number) { + return `https://${previewAlias(number)}-viteplus-dev.voidzero-docs.workers.dev`; +} + +function previewRun(context) { + const run = context.payload.workflow_run; + if ( + `${context.repo.owner}/${context.repo.repo}` !== repository || + context.eventName !== 'workflow_run' || + !Number.isSafeInteger(context.runId) || + context.runId <= 0 || + run?.path !== buildWorkflow || + run.event !== 'pull_request' || + run.conclusion !== 'success' || + !/^[a-f0-9]{40}$/.test(run.head_sha) || + !run.head_repository?.id || + !run.head_repository.owner?.login || + !run.head_repository.full_name || + !run.head_branch + ) { + throw new Error('Invalid docs preview workflow run'); + } + if (![run.id, run.run_attempt].every((value) => Number.isSafeInteger(value) && value > 0)) { + throw new Error('Invalid docs preview build identity'); + } + return run; +} + +function matchesPreview(pr, run) { + return ( + pr.state === 'open' && + pr.labels?.some((label) => label.name === previewLabel) === true && + pr.base.repo.full_name === repository && + pr.base.ref === 'main' && + pr.head.repo?.full_name !== repository && + pr.head.repo?.id === run.head_repository.id && + pr.head.repo?.full_name === run.head_repository.full_name && + pr.head.ref === run.head_branch && + pr.head.sha === run.head_sha + ); +} + +async function hasWritePermission({ github, context }, login) { + if (!login) { + return false; + } + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: login, + }); + return ['admin', 'maintain', 'write'].includes(data.permission); +} + +async function hasDeploymentApproval({ github, context }) { + previewRun(context); + // Use the current trusted deployment run, never an ID from the fork's + // artifact or the build run. This run's immutable event identifies the build. + // Checking the history also fails closed if the environment has no rules. + const { data } = await github.rest.actions.getReviewsForRun({ + ...context.repo, + run_id: context.runId, + }); + const reviews = data.filter((review) => + review.environments?.some((environment) => environment.name === previewLabel), + ); + // The API does not document history order. Refuse mixed decisions instead + // of guessing which one is newest; reapply the label to request a new run. + if (reviews.length === 0 || reviews.some((review) => review.state !== 'approved')) { + return false; + } + for (const review of reviews) { + if (await hasWritePermission({ github, context }, review.user?.login)) { + return true; + } + } + return false; +} + +export async function requireDeploymentApproval({ github, context }) { + if (!(await hasDeploymentApproval({ github, context }))) { + throw new Error( + 'This run needs maintainer approval for docs-preview. Configure required reviewers in the environment, then reapply the label and approve the new deployment.', + ); + } +} + +export async function authorizePreview({ github, context, core }) { + const run = previewRun(context); + if (run.head_repository.full_name === repository) { + return; + } + + // workflow_run.pull_requests and commit association can be empty for forks. + // Resolve by source owner/branch, then require the exact repo and current SHA. + const pulls = await github.paginate(github.rest.pulls.list, { + ...context.repo, + state: 'open', + base: 'main', + head: `${run.head_repository.owner.login}:${run.head_branch}`, + }); + const candidates = pulls.filter((pr) => matchesPreview(pr, run)); + if (candidates.length === 0) { + core.info('No open fork PR with docs-preview has this head commit; skipping the preview.'); + return; + } + if (candidates.length !== 1) { + throw new Error('More than one PR matches the docs preview run'); + } + // Use actor, not triggering_actor: rerunning an outsider's build must not + // turn it into a maintainer's preview request. + if (!(await hasWritePermission({ github, context }, run.actor?.login))) { + core.info('The original run actor does not have write permission; skipping the preview.'); + return; + } + + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, + run_id: run.id, + }); + // Helper-only and unrelated label runs can succeed without building docs. + if (artifacts.length === 0) { + core.info('The workflow produced no artifacts; skipping the preview.'); + return; + } + // A rerun has its own artifact. Deploy only the triggering attempt's output, + // even when a delayed deployment lists artifacts from newer attempts. + const artifactName = `docs-fork-preview-${run.run_attempt}`; + const matches = artifacts.filter((a) => a.name === artifactName && !a.expired); + if (matches.length !== 1) { + throw new Error(`Expected one active ${artifactName} artifact from the triggering run`); + } + // Environment approval happens after this job. Recheck the PR before + // requesting it, then recheck again after the deployment job's approval wait. + const { data: current } = await github.rest.pulls.get({ + ...context.repo, + pull_number: candidates[0].number, + }); + if ( + !matchesPreview(current, run) || + !(await hasWritePermission({ github, context }, run.actor?.login)) + ) { + core.info('The PR changed or preview permission was revoked; skipping the deployment queue.'); + return; + } + validatePrNumber(candidates[0].number); + core.setOutput('pr', candidates[0].number); + core.setOutput('artifact-id', matches[0].id); + core.setOutput('preview-alias', previewAlias(candidates[0].number)); + core.setOutput('preview-url', previewUrl(candidates[0].number)); +} + +export async function isCurrentPreview({ github, context }, number) { + validatePrNumber(number); + const run = previewRun(context); + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); + return ( + matchesPreview(pr, run) && + (await hasWritePermission({ github, context }, run.actor?.login)) && + (await hasDeploymentApproval({ github, context })) + ); +} + +function uploadedPreviewUrl(output, expectedAliasUrl) { + // WRANGLER_OUTPUT_FILE_PATH contains JSONL, not console output. Require one + // upload from this job, its version URL, and the alias used by its installers. + const uploads = output + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => JSON.parse(line)) + .filter((entry) => entry?.type === 'version-upload'); + if (uploads.length !== 1) { + throw new Error('Expected one version-upload record from Wrangler'); + } + const [upload] = uploads; + if ( + upload.version !== 1 || + upload.worker_name !== 'viteplus-dev' || + !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(upload.version_id) || + upload.preview_url !== + `https://${upload.version_id.slice(0, 8)}-viteplus-dev.voidzero-docs.workers.dev` || + upload.preview_alias_url !== expectedAliasUrl + ) { + throw new Error( + 'Invalid preview URLs from Wrangler; check that Preview URLs and the PR alias are configured', + ); + } + return upload.preview_alias_url; +} + +export async function commentPreview({ github, context, core }, number, output) { + if (!(await isCurrentPreview({ github, context }, number))) { + core.info('The PR changed or preview permission was revoked; skipping the preview comment.'); + return; + } + const run = previewRun(context); + const body = `${marker}\nCloudflare documentation preview: ${uploadedPreviewUrl(output, previewUrl(number))}\n\nCommit: ${run.head_sha}`; + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, + issue_number: number, + }); + const existing = comments.find( + (comment) => comment.user?.login === 'github-actions[bot]' && comment.body?.startsWith(marker), + ); + if (existing) { + await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ ...context.repo, issue_number: number, body }); + } +} + +async function validateAssetEntry(path) { + const stat = await lstat(path); + if (stat.isDirectory()) { + for (const name of await readdir(path)) { + await validateAssetEntry(join(path, name)); + } + } else if (!stat.isFile()) { + throw new Error(`Preview assets must be regular files: ${path}`); + } +} + +export async function validateAssets(directory) { + // Never follow links from an untrusted artifact: a link could upload files + // outside the artifact directory, including deployment credentials. + await validateAssetEntry(directory); + if (!(await lstat(join(directory, 'index.html'))).isFile()) { + throw new Error('Preview assets must include index.html'); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + if (process.argv[2] !== 'validate-assets' || !process.argv[3]) { + throw new Error('Usage: node docs-fork-preview.mjs validate-assets '); + } + await validateAssets(process.argv[3]); +} diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml new file mode 100644 index 0000000000..ca14f774bd --- /dev/null +++ b/.github/workflows/build-docs-fork-preview.yml @@ -0,0 +1,108 @@ +name: Build Docs Fork Preview + +# Maintainers: review the current fork commit, then apply docs-preview. +# New commits do not rebuild automatically; remove and reapply the label. +# This PR-controlled gate saves build work. It is not a security boundary: +# the trusted deploy workflow requires environment approval for each build. +# This follows publish-preview.yml; only static artifacts cross to deployment. +permissions: {} + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled] + paths: + - 'docs/**' + - 'packages/cli/install.sh' + - 'packages/cli/install.ps1' + - 'packages/cli/install-legacy.sh' + - 'packages/cli/install-legacy.ps1' + - '.github/workflows/build-docs-fork-preview.yml' + - '.github/workflows/deploy-docs-fork-preview.yml' + - '.github/scripts/docs-fork-preview.mjs' + - '.github/scripts/__tests__/docs-fork-preview.mjs' + +defaults: + run: + shell: bash + +jobs: + test: + name: Test docs preview helpers + # Keep helper checks automatic for same-repository PRs. Fork code runs + # only when a maintainer explicitly requests a docs preview. + if: >- + github.repository == 'voidzero-dev/vite-plus' && + ( + github.event.pull_request.head.repo.full_name == github.repository || + (github.event.action == 'labeled' && github.event.label.name == 'docs-preview') + ) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check fork preview requester + if: github.event.pull_request.head.repo.full_name != github.repository + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: context.actor, + }); + if (!['admin', 'maintain', 'write'].includes(data.permission)) { + core.setFailed('A maintainer with write permission must request the docs preview.'); + } + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - run: node --test .github/scripts/__tests__/docs-fork-preview.mjs + + build: + name: Build fork docs + needs: test + if: >- + github.repository == 'voidzero-dev/vite-plus' && + github.event.pull_request.head.repo.full_name != github.repository && + github.event.action == 'labeled' && + github.event.label.name == 'docs-preview' + runs-on: ubuntu-latest + timeout-minutes: 15 + # A delayed old run must not cancel a build for a newer approved commit. + concurrency: + group: build-docs-fork-preview-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Build the same commit that workflow_run.head_sha identifies. + ref: ${{ github.event.pull_request.head.sha }} + + - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.20.0 + with: + working-directory: docs + cache: false + + - name: Build static docs + run: vp run build:cloudflare + working-directory: docs + env: + # Use the PR's stable alias for absolute links and piped installers. + # Keep this origin aligned with previewUrl in docs-fork-preview.mjs. + DOCS_SITE_ORIGIN: https://pr-${{ github.event.pull_request.number }}-viteplus-dev.voidzero-docs.workers.dev + + # This job has no deployment secrets or write token. Its artifact is + # untrusted static content, never executable input to the deploy job. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docs-fork-preview-${{ github.run_attempt }} + path: docs/.vitepress/dist + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/deploy-docs-fork-preview.yml b/.github/workflows/deploy-docs-fork-preview.yml new file mode 100644 index 0000000000..d7c5c0206a --- /dev/null +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -0,0 +1,167 @@ +name: Deploy Docs Fork Preview + +run-name: 'Deploy docs preview at ${{ github.event.workflow_run.head_sha }} from build #${{ github.event.workflow_run.id }}' + +# Fork PRs cannot receive deployment credentials. This workflow runs from +# the default branch, validates the originating workflow and current PR, +# and uploads only static assets. It never checks out or executes fork code. +# This file must be on main before workflow_run can trigger it. +# +# Like publish-preview.yml and publish-preview-register.yml, a label requests +# a secret-free build and a required-reviewer environment gates deployment. +# Maintainer setup: restrict docs-preview to main and configure required +# reviewers with repository write permission. Add the CLOUDFLARE_ACCOUNT_ID +# variable and CLOUDFLARE_API_TOKEN secret there. Scope the token to the Worker +# account with Workers Scripts: Edit permission. +# Enable Preview URLs for viteplus-dev on voidzero-docs.workers.dev. +# Apply docs-preview after reviewing a fork PR's current commit. Remove and +# reapply it after updates, then approve the deployment for that build's SHA. +# The label is rechecked before upload and commenting; removing it cannot undo +# an upload that already started. The PR-controlled build gate is not a security +# boundary. The trusted workflow checks the original actor's write permission +# and requires environment approval for this deployment run, not an old commit. +# An absent or unprotected environment must not silently permit deployment: +# the deploy job also checks GitHub's review history before using credentials. +# Each PR reuses one alias for its docs and installer URLs. An approved upload +# updates that address. Aliases are subject to Cloudflare's retention limits. +on: # zizmor: ignore[dangerous-triggers] + workflow_run: + workflows: ['Build Docs Fork Preview'] + types: [completed] + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + authorize: + if: >- + github.event_name == 'workflow_run' && + github.repository == 'voidzero-dev/vite-plus' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.path == '.github/workflows/build-docs-fork-preview.yml' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read + pull-requests: read + outputs: + pr: ${{ steps.preview.outputs.pr }} + artifact-id: ${{ steps.preview.outputs.artifact-id }} + preview-alias: ${{ steps.preview.outputs.preview-alias }} + preview-url: ${{ steps.preview.outputs.preview-url }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: .github/scripts + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + id: preview + with: + script: | + const { authorizePreview } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/docs-fork-preview.mjs`); + await authorizePreview({ github, context, core }); + + deploy: + name: 'Deploy PR #${{ needs.authorize.outputs.pr }} at ${{ github.event.workflow_run.head_sha }}' + needs: authorize + if: github.event_name == 'workflow_run' && needs.authorize.outputs.pr != '' + runs-on: ubuntu-latest + timeout-minutes: 10 + # Serialize uploads per PR and let the running deployment finish. + # Keep only the last queued job; recheck the head before uploading. + concurrency: + group: deploy-docs-fork-preview-${{ needs.authorize.outputs.pr }} + cancel-in-progress: false + environment: + name: docs-preview + url: ${{ needs.authorize.outputs.preview-url }} + permissions: + contents: read + actions: read + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: | + .github/scripts + docs + + - name: Require deployment approval + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { requireDeploymentApproval } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/docs-fork-preview.mjs`); + await requireDeploymentApproval({ github, context }); + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + # Install only the deployment tool, outside both the checkout and the + # artifact. No PR dependencies, scripts, config, or caches are used here. + - name: Install Wrangler + run: npm install --prefix "$RUNNER_TEMP/docs-preview-tools" --ignore-scripts --no-package-lock wrangler@4.127.1 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.authorize.outputs.artifact-id }} + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/docs-preview-assets + merge-multiple: true + + - name: Validate static assets + run: node .github/scripts/docs-fork-preview.mjs validate-assets "$RUNNER_TEMP/docs-preview-assets" + + - name: Recheck the PR before upload + id: current + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + PR_NUMBER: ${{ needs.authorize.outputs.pr }} + with: + script: | + const { isCurrentPreview } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/docs-fork-preview.mjs`); + core.setOutput('current', await isCurrentPreview({ github, context }, Number(process.env.PR_NUMBER))); + + - name: Upload preview version + if: steps.current.outputs.current == 'true' + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PR_NUMBER: ${{ needs.authorize.outputs.pr }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + PREVIEW_ALIAS: ${{ needs.authorize.outputs.preview-alias }} + WRANGLER_SEND_METRICS: 'false' + WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/docs-preview-upload.jsonl + run: | + if [[ -z "$CLOUDFLARE_ACCOUNT_ID" || -z "$CLOUDFLARE_API_TOKEN" ]]; then + echo '::error::Configure the CLOUDFLARE_ACCOUNT_ID variable and CLOUDFLARE_API_TOKEN secret in the docs-preview environment.' + exit 1 + fi + "$RUNNER_TEMP/docs-preview-tools/node_modules/.bin/wrangler" versions upload \ + --config "$GITHUB_WORKSPACE/docs/wrangler.jsonc" \ + --assets "$RUNNER_TEMP/docs-preview-assets" \ + --preview-alias "$PREVIEW_ALIAS" \ + --message "Docs preview for PR #$PR_NUMBER ($HEAD_SHA)" + + - name: Comment on the fork PR + if: steps.current.outputs.current == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + PR_NUMBER: ${{ needs.authorize.outputs.pr }} + WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/docs-preview-upload.jsonl + with: + script: | + const { readFile } = await import('node:fs/promises'); + const { commentPreview } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/docs-fork-preview.mjs`); + const output = await readFile(process.env.WRANGLER_OUTPUT_FILE_PATH, 'utf8'); + await commentPreview({ github, context, core }, Number(process.env.PR_NUMBER), output);