From bed07bf376dd04f57e9c74f5acccd0de89bac9ab Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:29:24 +0800 Subject: [PATCH 01/12] ci: add Cloudflare docs previews for fork PRs --- .../scripts/__tests__/docs-fork-preview.mjs | 226 ++++++++++++++++++ .github/scripts/docs-fork-preview.mjs | 135 +++++++++++ .github/workflows/build-docs-fork-preview.yml | 78 ++++++ .../workflows/deploy-docs-fork-preview.yml | 130 ++++++++++ CONTRIBUTING.md | 32 +++ 5 files changed, 601 insertions(+) create mode 100644 .github/scripts/__tests__/docs-fork-preview.mjs create mode 100644 .github/scripts/docs-fork-preview.mjs create mode 100644 .github/workflows/build-docs-fork-preview.yml create mode 100644 .github/workflows/deploy-docs-fork-preview.yml diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs new file mode 100644 index 0000000000..2ca3e32a28 --- /dev/null +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -0,0 +1,226 @@ +// Run with node --test; these workflow helpers need no workspace dependencies. +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, 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, + previewUrl, + validateAssets, +} from '../docs-fork-preview.mjs'; + +function fixture() { + const source = { id: 42, full_name: 'contributor/vite-plus', owner: { login: 'contributor' } }; + const context = { + repo: { owner: 'voidzero-dev', repo: 'vite-plus' }, + payload: { + workflow_run: { + id: 123, + 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, + pull_requests: [], + }, + }, + }; + const pr = { + number: 2684, + state: 'open', + 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', expired: false }], + comments: [], + outputs: {}, + writes: [], + requests: [], + }; + const github = { + rest: { + pulls: { list() {}, get: async () => ({ data: pr }) }, + actions: { listWorkflowRunArtifacts() {} }, + 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 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 }; +} + +test('authorizes a fork with an empty workflow_run PR list and pins its artifact', async () => { + const f = fixture(); + 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', run_id: 123 }, + ]); + assert.deepEqual(f.state.outputs, { + pr: 2684, + 'artifact-id': 456, + 'preview-url': 'https://pr-2684-viteplus-dev.voidzero-docs.workers.dev', + }); +}); + +for (const [field, value] of [ + ['path', '.github/workflows/spoof.yml'], + ['event', 'push'], + ['conclusion', 'failure'], + ['head_sha', 'invalid'], + ['head_repository', null], + ['head_branch', ''], +]) { + 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, {}); + }); +} + +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/); +}); + +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: '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) }, +]) { + 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); + }); +} + +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 artifacts of [ + [], + [{ id: 456, name: 'docs-fork-preview', expired: true }], + [{ id: 456, name: 'other', expired: false }], + [ + { id: 456, name: 'docs-fork-preview', expired: false }, + { id: 789, name: 'docs-fork-preview', expired: false }, + ], +]) { + 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, {}); + }); +} + +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); + assert.equal(f.state.writes[0].method, 'create'); + assert.equal(f.state.writes[0].issue_number, 2684); + assert.match(f.state.writes[0].body, /Commit: a{40}$/); +}); + +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); + assert.equal(f.state.writes[0].method, 'update'); + assert.equal(f.state.writes[0].comment_id, 101); +}); + +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); + assert.deepEqual(f.state.writes, []); +}); + +test('rejects invalid PR numbers before using them in URLs or requests', async () => { + for (const number of [0, -1, 1.5, NaN, '2684', '2684\nother-output=true']) { + assert.throws(() => previewUrl(number), /Invalid pull request number/); + await assert.rejects(isCurrentPreview(fixture(), number), /Invalid pull request number/); + } +}); + +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/); +}); + +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..254cc17221 --- /dev/null +++ b/.github/scripts/docs-fork-preview.mjs @@ -0,0 +1,135 @@ +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 = ''; + +export function previewUrl(number) { + if (!Number.isSafeInteger(number) || number <= 0) { + throw new Error('Invalid pull request number'); + } + return `https://pr-${number}-viteplus-dev.voidzero-docs.workers.dev`; +} + +function previewRun(context) { + const run = context.payload.workflow_run; + if ( + `${context.repo.owner}/${context.repo.repo}` !== repository || + 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'); + } + return run; +} + +function matchesPreview(pr, run) { + return ( + pr.state === 'open' && + 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 + ); +} + +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 has this head commit; skipping the preview.'); + return; + } + if (candidates.length !== 1) { + throw new Error('More than one PR matches the docs preview run'); + } + + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, + run_id: run.id, + }); + const matches = artifacts.filter((a) => a.name === 'docs-fork-preview' && !a.expired); + if (matches.length !== 1) { + throw new Error('Expected one active docs-fork-preview artifact from the triggering run'); + } + core.setOutput('pr', candidates[0].number); + core.setOutput('artifact-id', matches[0].id); + core.setOutput('preview-url', previewUrl(candidates[0].number)); +} + +export async function isCurrentPreview({ github, context }, number) { + previewUrl(number); + const run = previewRun(context); + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); + return matchesPreview(pr, run); +} + +export async function commentPreview({ github, context, core }, number) { + if (!(await isCurrentPreview({ github, context }, number))) { + core.info('The PR closed or changed during upload; skipping the preview comment.'); + return; + } + const body = `${marker}\nCloudflare documentation preview: ${previewUrl(number)}\n\nCommit: ${context.payload.workflow_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..0d781a88af --- /dev/null +++ b/.github/workflows/build-docs-fork-preview.yml @@ -0,0 +1,78 @@ +name: Build Docs Fork Preview + +permissions: {} + +on: + pull_request: + branches: [main] + 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' + +concurrency: + group: build-docs-fork-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + test: + name: Test docs preview helpers + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - 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 + runs-on: ubuntu-latest + timeout-minutes: 15 + 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: + 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 + 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..dfd6974d0c --- /dev/null +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -0,0 +1,130 @@ +name: Deploy Docs Fork Preview + +# 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. +on: # zizmor: ignore[dangerous-triggers] + workflow_run: + workflows: ['Build Docs Fork Preview'] + types: [completed] + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + authorize: + if: >- + 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 + permissions: + contents: read + actions: read + pull-requests: read + outputs: + pr: ${{ steps.preview.outputs.pr }} + artifact-id: ${{ steps.preview.outputs.artifact-id }} + 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: + needs: authorize + if: needs.authorize.outputs.pr != '' + runs-on: ubuntu-latest + timeout-minutes: 10 + # Serialize uploads for a PR. An older run rechecks the head after waiting, + # so it cannot overwrite a preview that a newer run has already uploaded. + 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 + + - 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 }} + WRANGLER_SEND_METRICS: 'false' + run: | + if [[ -z "$CLOUDFLARE_ACCOUNT_ID" || -z "$CLOUDFLARE_API_TOKEN" ]]; then + echo '::error::Configure CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in the docs-preview environment. See CONTRIBUTING.md.' + 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 "pr-$PR_NUMBER" \ + --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 }} + with: + script: | + const { commentPreview } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/docs-fork-preview.mjs`); + await commentPreview({ github, context, core }, Number(process.env.PR_NUMBER)); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a2f66a24e..1e261d8732 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,6 +159,38 @@ UPDATE_SNAPSHOTS=1 just snapshot-test create The full case/step/interaction reference (including the `vpt` helper tool and milestone conventions for interactive tests) lives in `crates/vp_cli_snapshots/tests/cli_snapshots/README.md`; the design rationale is in `rfcs/interactive-snapshot-tests.md`. +## Documentation previews from forks + +Documentation PRs from forks receive a Cloudflare preview from the +`Build Docs Fork Preview` and `Deploy Docs Fork Preview` workflows. The build +runs without deployment credentials. The deployment workflow uses code from +the default branch and uploads the static build artifact to `viteplus-dev`. +It checks the source repository, branch, and current PR commit before upload. +The preview URL stays the same across updates to a PR. + +Repository maintainers must configure the GitHub environment `docs-preview`: + +- Set the environment variable `CLOUDFLARE_ACCOUNT_ID` to the account that owns + `viteplus-dev`. +- Add the environment secret `CLOUDFLARE_API_TOKEN` with permission to upload + Worker versions to that account. Use the narrowest available token scope. +- Enable Preview URLs for `viteplus-dev` in Cloudflare. The expected Workers + subdomain is `voidzero-docs.workers.dev`. + +The deployment workflow must be on `main` before GitHub can trigger it through +`workflow_run`. After setup, push a documentation change to an open fork PR. +GitHub may require approval for the contributor's first workflow run. A +successful deployment adds or updates a comment on the original PR, with a URL +such as `https://pr-2684-viteplus-dev.voidzero-docs.workers.dev` and the built +commit. `wrangler versions upload` does not promote the version to production. + +Same-repository PRs continue to use the existing preview integrations. To test +the fork preview helpers locally, run: + +```bash +node --test .github/scripts/__tests__/docs-fork-preview.mjs +``` + ## Submitting Pull Requests Prioritize stacked pull requests when your work splits into reviewable layers, for example a refactor PR with the feature PR that depends on it stacked on top. Reviewers handle a stack of small PRs faster than one large PR, and each layer merges on its own. From e2a39d11d213677db4e1a5db99bdb8f5909a8c6c Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:29:26 +0800 Subject: [PATCH 02/12] test: await docs preview test registrations --- .../scripts/__tests__/docs-fork-preview.mjs | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs index 2ca3e32a28..b3121344a9 100644 --- a/.github/scripts/__tests__/docs-fork-preview.mjs +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -77,7 +77,7 @@ function fixture() { return { github, context, core, pr, state }; } -test('authorizes a fork with an empty workflow_run PR list and pins its artifact', async () => { +await test('authorizes a fork with an empty workflow_run PR list and pins its artifact', async () => { const f = fixture(); await authorizePreview(f); assert.deepEqual(f.state.requests, [ @@ -105,7 +105,7 @@ for (const [field, value] of [ ['head_repository', null], ['head_branch', ''], ]) { - test(`rejects a run with invalid ${field}`, async () => { + 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/); @@ -113,13 +113,13 @@ for (const [field, value] of [ }); } -test('rejects a workflow running in another repository', async () => { +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/); }); -test('leaves same-repository previews to the existing integration', async () => { +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); @@ -137,7 +137,7 @@ for (const { name, mutate } of [ { name: 'other base repository', mutate: (pr) => (pr.base.repo.full_name = 'someone/other') }, { name: 'deleted fork', mutate: (pr) => (pr.head.repo = null) }, ]) { - test(`skips a PR with ${name}, including the check immediately before upload`, async () => { + await test(`skips a PR with ${name}, including the check immediately before upload`, async () => { const f = fixture(); mutate(f.pr); await authorizePreview(f); @@ -146,7 +146,7 @@ for (const { name, mutate } of [ }); } -test('rejects ambiguous PR matches', async () => { +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/); @@ -161,7 +161,7 @@ for (const artifacts of [ { id: 789, name: 'docs-fork-preview', expired: false }, ], ]) { - test(`rejects missing, expired, or ambiguous artifacts: ${JSON.stringify(artifacts)}`, async () => { + 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/); @@ -169,7 +169,7 @@ for (const artifacts of [ }); } -test('ignores a contributor comment that copies the bot marker', async () => { +await test('ignores a contributor comment that copies the bot marker', async () => { const f = fixture(); f.state.comments.push({ id: 100, @@ -182,7 +182,7 @@ test('ignores a contributor comment that copies the bot marker', async () => { assert.match(f.state.writes[0].body, /Commit: a{40}$/); }); -test('updates the existing bot comment', async () => { +await test('updates the existing bot comment', async () => { const f = fixture(); f.state.comments.push({ id: 101, @@ -194,21 +194,21 @@ test('updates the existing bot comment', async () => { assert.equal(f.state.writes[0].comment_id, 101); }); -test('does not comment if the PR changes during upload', async () => { +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); assert.deepEqual(f.state.writes, []); }); -test('rejects invalid PR numbers before using them in URLs or requests', async () => { +await test('rejects invalid PR numbers before using them in URLs or requests', async () => { for (const number of [0, -1, 1.5, NaN, '2684', '2684\nother-output=true']) { assert.throws(() => previewUrl(number), /Invalid pull request number/); await assert.rejects(isCurrentPreview(fixture(), number), /Invalid pull request number/); } }); -test('accepts a static site and rejects links outside the artifact', async (t) => { +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'); @@ -219,7 +219,7 @@ test('accepts a static site and rejects links outside the artifact', async (t) = await assert.rejects(validateAssets(directory), /must be regular files/); }); -test('rejects an artifact without a site index', async (t) => { +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/); From 99875b4ffc1c06b33baf6c69fcc747fc3b9d153a Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:29:28 +0800 Subject: [PATCH 03/12] ci: pin docs preview comments to uploaded versions --- .../scripts/__tests__/docs-fork-preview.mjs | 86 ++++++++++++++++++- .github/scripts/docs-fork-preview.mjs | 30 ++++++- .../workflows/deploy-docs-fork-preview.yml | 6 +- CONTRIBUTING.md | 10 ++- 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs index b3121344a9..6f9a63945f 100644 --- a/.github/scripts/__tests__/docs-fork-preview.mjs +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -13,6 +13,21 @@ import { validateAssets, } from '../docs-fork-preview.mjs'; +const versionId = '11111111-1111-4111-8111-111111111111'; +const versionUrl = 'https://11111111-viteplus-dev.voidzero-docs.workers.dev'; + +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 = { @@ -176,10 +191,13 @@ await test('ignores a contributor comment that copies the bot marker', async () user: { login: 'contributor' }, body: '', }); - await commentPreview(f, 2684); + await commentPreview(f, 2684, uploadOutput()); assert.equal(f.state.writes[0].method, 'create'); assert.equal(f.state.writes[0].issue_number, 2684); - assert.match(f.state.writes[0].body, /Commit: a{40}$/); + assert.equal( + f.state.writes[0].body, + `\nCloudflare documentation preview: ${versionUrl}\n\nCommit: ${'a'.repeat(40)}\n\nLatest uploaded preview (may show another commit): ${previewUrl(2684)}`, + ); }); await test('updates the existing bot comment', async () => { @@ -189,18 +207,78 @@ await test('updates the existing bot comment', async () => { user: { login: 'github-actions[bot]' }, body: '\nPrevious preview', }); - await commentPreview(f, 2684); + 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(versionUrl)); }); 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); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); +}); + +await test('keeps the previous comment tied to its version when the PR changes during upload', 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 = []; + + // B passes the pre-upload check, then C arrives while B moves the PR alias. + f.context.payload.workflow_run.head_sha = 'b'.repeat(40); + f.pr.head.sha = 'b'.repeat(40); + assert.equal(await isCurrentPreview(f, 2684), true); + f.pr.head.sha = 'c'.repeat(40); + await commentPreview( + f, + 2684, + uploadOutput({ + version_id: '22222222-2222-4222-8222-222222222222', + preview_url: 'https://22222222-viteplus-dev.voidzero-docs.workers.dev', + }), + ); + assert.deepEqual(f.state.writes, []); + assert.equal(f.state.comments[0].body, previousBody); + assert.ok(previousBody.includes(`Cloudflare documentation preview: ${versionUrl}`)); + assert.ok(previousBody.includes(`Commit: ${'a'.repeat(40)}`)); +}); + +await test('reads the version URL 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(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 })], + ['moving alias', uploadOutput({ preview_url: previewUrl(2684) })], + [ + '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 [0, -1, 1.5, NaN, '2684', '2684\nother-output=true']) { assert.throws(() => previewUrl(number), /Invalid pull request number/); diff --git a/.github/scripts/docs-fork-preview.mjs b/.github/scripts/docs-fork-preview.mjs index 254cc17221..d65d35d7d8 100644 --- a/.github/scripts/docs-fork-preview.mjs +++ b/.github/scripts/docs-fork-preview.mjs @@ -87,12 +87,38 @@ export async function isCurrentPreview({ github, context }, number) { return matchesPreview(pr, run); } -export async function commentPreview({ github, context, core }, number) { +function uploadedPreviewUrl(output) { + // WRANGLER_OUTPUT_FILE_PATH contains JSONL, not console output. Require one + // upload from this job and its version URL; never substitute the moving alias. + 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` + ) { + throw new Error( + 'Invalid version preview URL from Wrangler; check that Preview URLs are enabled', + ); + } + return upload.preview_url; +} + +export async function commentPreview({ github, context, core }, number, output) { if (!(await isCurrentPreview({ github, context }, number))) { core.info('The PR closed or changed during upload; skipping the preview comment.'); return; } - const body = `${marker}\nCloudflare documentation preview: ${previewUrl(number)}\n\nCommit: ${context.payload.workflow_run.head_sha}`; + const body = `${marker}\nCloudflare documentation preview: ${uploadedPreviewUrl(output)}\n\nCommit: ${context.payload.workflow_run.head_sha}\n\nLatest uploaded preview (may show another commit): ${previewUrl(number)}`; const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: number, diff --git a/.github/workflows/deploy-docs-fork-preview.yml b/.github/workflows/deploy-docs-fork-preview.yml index dfd6974d0c..cad6bfd512 100644 --- a/.github/workflows/deploy-docs-fork-preview.yml +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -108,6 +108,7 @@ jobs: PR_NUMBER: ${{ needs.authorize.outputs.pr }} HEAD_SHA: ${{ github.event.workflow_run.head_sha }} 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 CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in the docs-preview environment. See CONTRIBUTING.md.' @@ -124,7 +125,10 @@ jobs: 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`); - await commentPreview({ github, context, core }, Number(process.env.PR_NUMBER)); + const output = await readFile(process.env.WRANGLER_OUTPUT_FILE_PATH, 'utf8'); + await commentPreview({ github, context, core }, Number(process.env.PR_NUMBER), output); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e261d8732..65f7b78722 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -166,7 +166,7 @@ Documentation PRs from forks receive a Cloudflare preview from the runs without deployment credentials. The deployment workflow uses code from the default branch and uploads the static build artifact to `viteplus-dev`. It checks the source repository, branch, and current PR commit before upload. -The preview URL stays the same across updates to a PR. +The PR alias stays the same across updates to a PR. Repository maintainers must configure the GitHub environment `docs-preview`: @@ -180,9 +180,11 @@ Repository maintainers must configure the GitHub environment `docs-preview`: The deployment workflow must be on `main` before GitHub can trigger it through `workflow_run`. After setup, push a documentation change to an open fork PR. GitHub may require approval for the contributor's first workflow run. A -successful deployment adds or updates a comment on the original PR, with a URL -such as `https://pr-2684-viteplus-dev.voidzero-docs.workers.dev` and the built -commit. `wrangler versions upload` does not promote the version to production. +successful deployment adds or updates a comment on the original PR. The comment +links to a fixed Worker version for the stated commit. It also includes the PR +alias, such as `https://pr-2684-viteplus-dev.voidzero-docs.workers.dev`, which can +point to a later upload. `wrangler versions upload` does not promote the version +to production. Same-repository PRs continue to use the existing preview integrations. To test the fork preview helpers locally, run: From 2154910e99b8a939e19f8ab930c0fb880cd00b87 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:29:29 +0800 Subject: [PATCH 04/12] ci: temporarily exercise fork docs build on PR 2691 --- .github/workflows/build-docs-fork-preview.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml index 0d781a88af..d53e0d7a1f 100644 --- a/.github/workflows/build-docs-fork-preview.yml +++ b/.github/workflows/build-docs-fork-preview.yml @@ -43,9 +43,13 @@ jobs: build: name: Build fork docs needs: test + # TEMP: Exercise this job on PR #2691. Remove the exception before merge. if: >- github.repository == 'voidzero-dev/vite-plus' && - github.event.pull_request.head.repo.full_name != github.repository + ( + github.event.pull_request.head.repo.full_name != github.repository || + github.event.pull_request.number == 2691 + ) runs-on: ubuntu-latest timeout-minutes: 15 permissions: From a42b9e92ee8329ee7d745116434fe62f20b42f11 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:29:31 +0800 Subject: [PATCH 05/12] ci: remove temporary docs preview build exception --- .github/workflows/build-docs-fork-preview.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml index d53e0d7a1f..0d781a88af 100644 --- a/.github/workflows/build-docs-fork-preview.yml +++ b/.github/workflows/build-docs-fork-preview.yml @@ -43,13 +43,9 @@ jobs: build: name: Build fork docs needs: test - # TEMP: Exercise this job on PR #2691. Remove the exception before merge. if: >- github.repository == 'voidzero-dev/vite-plus' && - ( - github.event.pull_request.head.repo.full_name != github.repository || - github.event.pull_request.number == 2691 - ) + github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest timeout-minutes: 15 permissions: From d9ac431587aac02f5e6c1cca59fe5f8ca250ed32 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:29:33 +0800 Subject: [PATCH 06/12] docs: keep fork preview setup with the workflow --- .../workflows/deploy-docs-fork-preview.yml | 8 ++++- CONTRIBUTING.md | 34 ------------------- 2 files changed, 7 insertions(+), 35 deletions(-) diff --git a/.github/workflows/deploy-docs-fork-preview.yml b/.github/workflows/deploy-docs-fork-preview.yml index cad6bfd512..6f6302727c 100644 --- a/.github/workflows/deploy-docs-fork-preview.yml +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -3,6 +3,12 @@ name: Deploy Docs Fork Preview # 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. +# +# Maintainer setup: restrict the docs-preview environment to main. 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. on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ['Build Docs Fork Preview'] @@ -111,7 +117,7 @@ jobs: WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/docs-preview-upload.jsonl run: | if [[ -z "$CLOUDFLARE_ACCOUNT_ID" || -z "$CLOUDFLARE_API_TOKEN" ]]; then - echo '::error::Configure CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in the docs-preview environment. See CONTRIBUTING.md.' + 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 \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65f7b78722..9a2f66a24e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,40 +159,6 @@ UPDATE_SNAPSHOTS=1 just snapshot-test create The full case/step/interaction reference (including the `vpt` helper tool and milestone conventions for interactive tests) lives in `crates/vp_cli_snapshots/tests/cli_snapshots/README.md`; the design rationale is in `rfcs/interactive-snapshot-tests.md`. -## Documentation previews from forks - -Documentation PRs from forks receive a Cloudflare preview from the -`Build Docs Fork Preview` and `Deploy Docs Fork Preview` workflows. The build -runs without deployment credentials. The deployment workflow uses code from -the default branch and uploads the static build artifact to `viteplus-dev`. -It checks the source repository, branch, and current PR commit before upload. -The PR alias stays the same across updates to a PR. - -Repository maintainers must configure the GitHub environment `docs-preview`: - -- Set the environment variable `CLOUDFLARE_ACCOUNT_ID` to the account that owns - `viteplus-dev`. -- Add the environment secret `CLOUDFLARE_API_TOKEN` with permission to upload - Worker versions to that account. Use the narrowest available token scope. -- Enable Preview URLs for `viteplus-dev` in Cloudflare. The expected Workers - subdomain is `voidzero-docs.workers.dev`. - -The deployment workflow must be on `main` before GitHub can trigger it through -`workflow_run`. After setup, push a documentation change to an open fork PR. -GitHub may require approval for the contributor's first workflow run. A -successful deployment adds or updates a comment on the original PR. The comment -links to a fixed Worker version for the stated commit. It also includes the PR -alias, such as `https://pr-2684-viteplus-dev.voidzero-docs.workers.dev`, which can -point to a later upload. `wrangler versions upload` does not promote the version -to production. - -Same-repository PRs continue to use the existing preview integrations. To test -the fork preview helpers locally, run: - -```bash -node --test .github/scripts/__tests__/docs-fork-preview.mjs -``` - ## Submitting Pull Requests Prioritize stacked pull requests when your work splits into reviewable layers, for example a refactor PR with the feature PR that depends on it stacked on top. Reviewers handle a stack of small PRs faster than one large PR, and each layer merges on its own. From e80d6da9abc50fc7d143952d299089c235bbdf92 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:29:36 +0800 Subject: [PATCH 07/12] ci: require maintainer opt-in for fork docs previews --- .../scripts/__tests__/docs-fork-preview.mjs | 107 +++++++++++++++++- .github/scripts/docs-fork-preview.mjs | 30 ++++- .github/workflows/build-docs-fork-preview.yml | 37 +++++- .../workflows/deploy-docs-fork-preview.yml | 5 + 4 files changed, 170 insertions(+), 9 deletions(-) diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs index 6f9a63945f..2b539e9393 100644 --- a/.github/scripts/__tests__/docs-fork-preview.mjs +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -41,6 +41,7 @@ function fixture() { head_sha: 'a'.repeat(40), head_branch: 'docs-update', head_repository: source, + actor: { login: 'maintainer' }, pull_requests: [], }, }, @@ -48,6 +49,7 @@ function fixture() { 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) }, }; @@ -58,9 +60,16 @@ function fixture() { outputs: {}, writes: [], requests: [], + permission: 'write', }; const github = { rest: { + repos: { + getCollaboratorPermissionLevel: async (params) => { + state.requests.push(params); + return { data: { permission: state.permission } }; + }, + }, pulls: { list() {}, get: async () => ({ data: pr }) }, actions: { listWorkflowRunArtifacts() {} }, issues: { @@ -103,6 +112,7 @@ await test('authorizes a fork with an empty workflow_run PR list and pins its ar base: 'main', head: 'contributor:docs-update', }, + { owner: 'voidzero-dev', repo: 'vite-plus', username: 'maintainer' }, { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 123 }, ]); assert.deepEqual(f.state.outputs, { @@ -144,6 +154,9 @@ await test('leaves same-repository previews to the existing integration', async 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') }, @@ -167,8 +180,100 @@ await test('rejects ambiguous PR matches', async () => { 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', expired: true }], [{ id: 456, name: 'other', expired: false }], [ diff --git a/.github/scripts/docs-fork-preview.mjs b/.github/scripts/docs-fork-preview.mjs index d65d35d7d8..7ff0c9e7f2 100644 --- a/.github/scripts/docs-fork-preview.mjs +++ b/.github/scripts/docs-fork-preview.mjs @@ -5,6 +5,7 @@ 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'; export function previewUrl(number) { if (!Number.isSafeInteger(number) || number <= 0) { @@ -34,6 +35,7 @@ function previewRun(context) { 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 && @@ -44,6 +46,19 @@ function matchesPreview(pr, run) { ); } +async function requestedByMaintainer({ github, context }, run) { + // Use the original actor, not triggering_actor: a maintainer re-running an + // outsider's workflow must not grant that run deployment permission. + if (!run.actor?.login) { + return false; + } + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: run.actor.login, + }); + return ['admin', 'maintain', 'write'].includes(data.permission); +} + export async function authorizePreview({ github, context, core }) { const run = previewRun(context); if (run.head_repository.full_name === repository) { @@ -60,17 +75,26 @@ export async function authorizePreview({ github, context, core }) { }); const candidates = pulls.filter((pr) => matchesPreview(pr, run)); if (candidates.length === 0) { - core.info('No open fork PR has this head commit; skipping the preview.'); + 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'); } + if (!(await requestedByMaintainer({ github, context }, run))) { + 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; + } const matches = artifacts.filter((a) => a.name === 'docs-fork-preview' && !a.expired); if (matches.length !== 1) { throw new Error('Expected one active docs-fork-preview artifact from the triggering run'); @@ -84,7 +108,7 @@ export async function isCurrentPreview({ github, context }, number) { previewUrl(number); const run = previewRun(context); const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); - return matchesPreview(pr, run); + return matchesPreview(pr, run) && (await requestedByMaintainer({ github, context }, run)); } function uploadedPreviewUrl(output) { @@ -115,7 +139,7 @@ function uploadedPreviewUrl(output) { export async function commentPreview({ github, context, core }, number, output) { if (!(await isCurrentPreview({ github, context }, number))) { - core.info('The PR closed or changed during upload; skipping the preview comment.'); + core.info('The PR changed or preview permission was revoked; skipping the preview comment.'); return; } const body = `${marker}\nCloudflare documentation preview: ${uploadedPreviewUrl(output)}\n\nCommit: ${context.payload.workflow_run.head_sha}\n\nLatest uploaded preview (may show another commit): ${previewUrl(number)}`; diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml index 0d781a88af..a48d3d52e9 100644 --- a/.github/workflows/build-docs-fork-preview.yml +++ b/.github/workflows/build-docs-fork-preview.yml @@ -1,10 +1,15 @@ 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 checks the label and original run actor again. permissions: {} on: pull_request: branches: [main] + types: [opened, synchronize, reopened, labeled] paths: - 'docs/**' - 'packages/cli/install.sh' @@ -16,10 +21,6 @@ on: - '.github/scripts/docs-fork-preview.mjs' - '.github/scripts/__tests__/docs-fork-preview.mjs' -concurrency: - group: build-docs-fork-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true - defaults: run: shell: bash @@ -27,10 +28,30 @@ defaults: 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 @@ -45,9 +66,15 @@ jobs: needs: test if: >- github.repository == 'voidzero-dev/vite-plus' && - github.event.pull_request.head.repo.full_name != github.repository + 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 + # Unrelated label events must not cancel a requested preview build. + concurrency: + group: build-docs-fork-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: contents: read steps: diff --git a/.github/workflows/deploy-docs-fork-preview.yml b/.github/workflows/deploy-docs-fork-preview.yml index 6f6302727c..f890bd5cf8 100644 --- a/.github/workflows/deploy-docs-fork-preview.yml +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -9,6 +9,11 @@ name: Deploy Docs Fork Preview # 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. The label is rechecked before upload and commenting; +# removing it cannot undo an upload that already started. +# The build workflow is PR-controlled, so authorization also requires its +# original run actor to have write permission in this repository. on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ['Build Docs Fork Preview'] From 25b244190f81e02b5ae1e0a518f64567a3e67a30 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:29:39 +0800 Subject: [PATCH 08/12] ci: bind fork docs preview approval to the exact commit --- .../scripts/__tests__/docs-fork-preview.mjs | 369 +++++++++++++++++- .github/scripts/docs-fork-preview.mjs | 118 +++++- .../workflows/approve-docs-fork-preview.yml | 39 ++ .github/workflows/build-docs-fork-preview.yml | 7 +- .../workflows/deploy-docs-fork-preview.yml | 7 +- 5 files changed, 531 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/approve-docs-fork-preview.yml diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs index 2b539e9393..ed7934370d 100644 --- a/.github/scripts/__tests__/docs-fork-preview.mjs +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -1,11 +1,12 @@ // Run with node --test; these workflow helpers need no workspace dependencies. import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { 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 { + approvePreview, authorizePreview, commentPreview, isCurrentPreview, @@ -16,6 +17,27 @@ import { const versionId = '11111111-1111-4111-8111-111111111111'; const versionUrl = 'https://11111111-viteplus-dev.voidzero-docs.workers.dev'; +function grantApproval(state, sha = 'a'.repeat(40), number = 2684, runId = 987) { + const status = { + context: `docs-preview/pr-${number}`, + state: 'success', + creator: { login: 'github-actions[bot]' }, + target_url: `https://github.com/voidzero-dev/vite-plus/actions/runs/${runId}`, + }; + const approval = { + id: runId, + repository: { full_name: 'voidzero-dev/vite-plus' }, + path: '.github/workflows/approve-docs-fork-preview.yml', + event: 'pull_request_target', + display_title: `Approve docs preview for PR #${number} at ${sha} (docs-preview)`, + status: 'completed', + conclusion: 'success', + }; + state.statuses.set(sha, [status]); + state.approvals.set(runId, approval); + return { status, approval }; +} + function uploadOutput(overrides = {}) { return `${JSON.stringify({ type: 'version-upload', @@ -61,17 +83,32 @@ function fixture() { writes: [], requests: [], permission: 'write', + statuses: new Map(), + approvals: new Map(), + sleeps: [], }; const github = { rest: { repos: { + listCommitStatusesForRef() {}, + createCommitStatus: async (params) => state.writes.push({ method: 'status', ...params }), getCollaboratorPermissionLevel: async (params) => { state.requests.push(params); return { data: { permission: state.permission } }; }, }, pulls: { list() {}, get: async () => ({ data: pr }) }, - actions: { listWorkflowRunArtifacts() {} }, + actions: { + listWorkflowRunArtifacts() {}, + getWorkflowRun: async (params) => { + state.requests.push(params); + const approval = state.approvals.get(params.run_id); + if (!approval) { + throw new Error('Approval workflow run not found'); + } + return { data: approval }; + }, + }, issues: { listComments() {}, createComment: async (params) => state.writes.push({ method: 'create', ...params }), @@ -89,6 +126,9 @@ function fixture() { if (method === github.rest.issues.listComments) { return state.comments; } + if (method === github.rest.repos.listCommitStatusesForRef) { + return state.statuses.get(params.ref) ?? []; + } throw new Error('Unexpected GitHub request'); }, }; @@ -98,9 +138,165 @@ function fixture() { state.outputs[key] = value; }, }; - return { github, context, core, pr, state }; + const sleep = async (ms) => state.sleeps.push(ms); + return { github, context, core, pr, state, sleep, ...grantApproval(state) }; +} + +function approvalFixture() { + const f = fixture(); + f.context.eventName = 'pull_request_target'; + f.context.actor = 'maintainer'; + f.context.runId = 987; + f.context.payload = { + action: 'labeled', + label: { name: 'docs-preview' }, + pull_request: structuredClone(f.pr), + }; + return f; +} + +await test('records approval for the immutable label event commit', async () => { + const f = approvalFixture(); + await approvePreview(f); + assert.deepEqual(f.state.writes, [ + { + method: 'status', + owner: 'voidzero-dev', + repo: 'vite-plus', + sha: 'a'.repeat(40), + context: 'docs-preview/pr-2684', + state: 'success', + description: 'Maintainer approved this commit for a docs preview', + target_url: 'https://github.com/voidzero-dev/vite-plus/actions/runs/987', + }, + ]); +}); + +for (const { name, mutate } of [ + { name: 'another repository', mutate: (c) => (c.repo.owner = 'contributor') }, + { name: 'an untrusted trigger', mutate: (c) => (c.eventName = 'pull_request') }, + { name: 'a push', mutate: (c) => (c.payload.action = 'synchronize') }, + { name: 'label removal', mutate: (c) => (c.payload.action = 'unlabeled') }, + { name: 'an unrelated label', mutate: (c) => (c.payload.label.name = 'bug') }, + { name: 'a missing label', mutate: (c) => (c.payload.label = undefined) }, + { name: 'a missing PR', mutate: (c) => (c.payload.pull_request = undefined) }, + { name: 'an invalid SHA', mutate: (c) => (c.payload.pull_request.head.sha = 'invalid') }, + { name: 'a deleted fork', mutate: (c) => (c.payload.pull_request.head.repo = null) }, + { name: 'a missing fork ID', mutate: (c) => (c.payload.pull_request.head.repo.id = undefined) }, + { name: 'a missing fork name', mutate: (c) => (c.payload.pull_request.head.repo.full_name = '') }, + { name: 'a missing branch', mutate: (c) => (c.payload.pull_request.head.ref = '') }, + { name: 'an invalid run ID', mutate: (c) => (c.runId = '987') }, + { name: 'a zero run ID', mutate: (c) => (c.runId = 0) }, + { name: 'an unsafe run ID', mutate: (c) => (c.runId = Number.MAX_SAFE_INTEGER + 1) }, + { name: 'an invalid PR number', mutate: (c) => (c.payload.pull_request.number = '2684') }, +]) { + await test(`does not record approval for ${name}`, async () => { + const f = approvalFixture(); + mutate(f.context); + await assert.rejects(approvePreview(f), /Invalid/); + assert.deepEqual(f.state.writes, []); + }); +} + +for (const { name, mutate } of [ + { name: 'a closed PR', mutate: (f) => (f.context.payload.pull_request.state = 'closed') }, + { name: 'a missing preview label', mutate: (f) => (f.context.payload.pull_request.labels = []) }, + { + name: 'a same-repository PR', + mutate: (f) => (f.context.payload.pull_request.head.repo.full_name = 'voidzero-dev/vite-plus'), + }, + { + name: 'another base branch', + mutate: (f) => (f.context.payload.pull_request.base.ref = 'release'), + }, + { name: 'a missing actor', mutate: (f) => (f.context.actor = undefined) }, + ...['read', 'triage', 'none', undefined].map((permission) => ({ + name: `${permission} permission`, + mutate: (f) => (f.state.permission = permission), + })), +]) { + await test(`does not record approval with ${name}`, async () => { + const f = approvalFixture(); + mutate(f); + await assert.rejects(approvePreview(f), /A maintainer must apply/); + assert.deepEqual(f.state.writes, []); + }); +} + +for (const { name, mutate } of [ + { name: 'a new commit', mutate: (pr) => (pr.head.sha = 'b'.repeat(40)) }, + { name: 'label removal', mutate: (pr) => (pr.labels = []) }, + { name: 'PR closure', mutate: (pr) => (pr.state = 'closed') }, + { name: 'a base change', mutate: (pr) => (pr.base.ref = 'release') }, +]) { + await test(`does not record approval after ${name} during label handling`, async () => { + const f = approvalFixture(); + mutate(f.pr); + await assert.rejects(approvePreview(f), /The PR changed after the label event/); + assert.deepEqual(f.state.writes, []); + }); +} + +await test('never transfers approval to a commit pushed during the status write', async () => { + const f = approvalFixture(); + const create = f.github.rest.repos.createCommitStatus; + f.github.rest.repos.createCommitStatus = async (params) => { + f.pr.head.sha = 'b'.repeat(40); + await create(params); + }; + await approvePreview(f); + assert.equal(f.state.writes[0].sha, 'a'.repeat(40)); +}); + +for (const [area, method] of [ + ['repos', 'getCollaboratorPermissionLevel'], + ['pulls', 'get'], + ['repos', 'createCommitStatus'], +]) { + await test(`fails closed when approval ${method} fails`, async () => { + const f = approvalFixture(); + f.github.rest[area][method] = async () => { + throw new Error('GitHub API failed'); + }; + await assert.rejects(approvePreview(f), /GitHub API failed/); + assert.deepEqual(f.state.writes, []); + }); } +await test('keeps the trusted workflow run name aligned with the approval proof', async () => { + const yaml = await readFile( + new URL('../../workflows/approve-docs-fork-preview.yml', import.meta.url), + 'utf8', + ); + const template = yaml.match(/^run-name: '(.+)'$/m)?.[1]; + assert.ok(template); + const f = fixture(); + f.approval.display_title = template + .replaceAll('${{ github.event.pull_request.number }}', '2684') + .replaceAll('${{ github.event.pull_request.head.sha }}', f.pr.head.sha) + .replaceAll('${{ github.event.label.name }}', 'docs-preview'); + await authorizePreview(f); + assert.equal(f.state.outputs.pr, 2684); +}); + +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('authorizes a fork with an empty workflow_run PR list and pins its artifact', async () => { const f = fixture(); await authorizePreview(f); @@ -114,6 +310,8 @@ await test('authorizes a fork with an empty workflow_run PR list and pins its ar }, { owner: 'voidzero-dev', repo: 'vite-plus', username: 'maintainer' }, { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 123 }, + { owner: 'voidzero-dev', repo: 'vite-plus', ref: 'a'.repeat(40), per_page: 100 }, + { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 987 }, ]); assert.deepEqual(f.state.outputs, { pr: 2684, @@ -122,6 +320,170 @@ await test('authorizes a fork with an empty workflow_run PR list and pins its ar }); }); +await test('does not approve a new commit when a maintainer applies an unrelated label', async () => { + const f = fixture(); + await authorizePreview(f); + assert.equal(f.state.outputs.pr, 2684); + + // The fork changes its workflow to build on any label. A maintainer applies + // bug to B while docs-preview remains from A: actor and label checks pass. + f.pr.head.sha = 'b'.repeat(40); + f.context.payload.workflow_run.head_sha = f.pr.head.sha; + f.state.outputs = {}; + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + assert.equal(await isCurrentPreview(f, 2684), false); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); + + // Only a new trusted approval for B permits deployment. + grantApproval(f.state, f.pr.head.sha, f.pr.number, 988); + await authorizePreview(f); + assert.equal(f.state.outputs.pr, 2684); +}); + +for (const { name, mutate } of [ + { name: 'missing status', mutate: (f) => f.state.statuses.clear() }, + { name: 'another PR status', mutate: (f) => (f.status.context = 'docs-preview/pr-2685') }, + { name: 'failed status', mutate: (f) => (f.status.state = 'failure') }, + { name: 'pending status', mutate: (f) => (f.status.state = 'pending') }, + { name: 'manual status', mutate: (f) => (f.status.creator.login = 'contributor') }, + { name: 'missing creator', mutate: (f) => (f.status.creator = null) }, + { name: 'missing URL', mutate: (f) => (f.status.target_url = null) }, + { name: 'external URL', mutate: (f) => (f.status.target_url = 'https://example.com/987') }, + { + name: 'fork run URL', + mutate: (f) => + (f.status.target_url = 'https://github.com/contributor/vite-plus/actions/runs/987'), + }, + ...['0', '-987', '0987', '987/attempts/1', '987?other=true', '9007199254740992'].map((id) => ({ + name: `invalid run URL ${id}`, + mutate: (f) => + (f.status.target_url = `https://github.com/voidzero-dev/vite-plus/actions/runs/${id}`), + })), + { name: 'mismatched proof ID', mutate: (f) => (f.approval.id = 988) }, + { + name: 'fork proof', + mutate: (f) => (f.approval.repository.full_name = 'contributor/vite-plus'), + }, + { name: 'missing proof repository', mutate: (f) => (f.approval.repository = null) }, + { name: 'untrusted workflow', mutate: (f) => (f.approval.path = '.github/workflows/spoof.yml') }, + { name: 'PR-controlled proof', mutate: (f) => (f.approval.event = 'pull_request') }, + { name: 'manual proof', mutate: (f) => (f.approval.event = 'workflow_dispatch') }, + { + name: 'proof for another PR', + mutate: (f) => (f.approval.display_title = f.approval.display_title.replace('#2684', '#2685')), + }, + { + name: 'proof for another SHA', + mutate: (f) => + (f.approval.display_title = f.approval.display_title.replace('a'.repeat(40), 'b'.repeat(40))), + }, + { + name: 'proof for another label', + mutate: (f) => + (f.approval.display_title = f.approval.display_title.replace('(docs-preview)', '(bug)')), + }, + { name: 'missing proof title', mutate: (f) => (f.approval.display_title = undefined) }, + ...['failure', 'cancelled', 'skipped', null].map((conclusion) => ({ + name: `${conclusion} proof`, + mutate: (f) => (f.approval.conclusion = conclusion), + })), +]) { + await test(`rejects ${name} during authorization and rechecks`, async () => { + const f = fixture(); + mutate(f); + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + assert.equal(await isCurrentPreview(f, 2684), false); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); + }); +} + +await test('does not accept an old proof URL copied into a new commit status', async () => { + const f = fixture(); + f.pr.head.sha = 'b'.repeat(40); + f.context.payload.workflow_run.head_sha = f.pr.head.sha; + f.state.statuses.set(f.pr.head.sha, [structuredClone(f.status)]); + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + assert.equal(await isCurrentPreview(f, 2684), false); +}); + +await test('uses the latest status instead of an older successful approval', async () => { + const f = fixture(); + f.state.statuses.get(f.pr.head.sha).unshift({ ...f.status, state: 'failure' }); + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + assert.deepEqual(f.state.sleeps, []); +}); + +for (const pending of ['missing status', 'running workflow']) { + await test(`waits for the trusted approval when there is a ${pending}`, async () => { + const f = fixture(); + if (pending === 'missing status') { + f.state.statuses.clear(); + } else { + f.approval.status = 'in_progress'; + f.approval.conclusion = null; + } + f.sleep = async (ms) => { + f.state.sleeps.push(ms); + grantApproval(f.state); + }; + await authorizePreview(f); + assert.equal(f.state.outputs.pr, 2684); + assert.deepEqual(f.state.sleeps, [5000]); + }); + + await test(`stops waiting after a bounded interval for a ${pending}`, async () => { + const f = fixture(); + if (pending === 'missing status') { + f.state.statuses.clear(); + } else { + f.approval.status = 'in_progress'; + f.approval.conclusion = null; + } + await authorizePreview(f); + assert.deepEqual(f.state.outputs, {}); + assert.deepEqual(f.state.sleeps, Array(23).fill(5000)); + assert.equal(await isCurrentPreview(f, 2684), false); + }); +} + +for (const api of ['statuses', 'workflow proof']) { + await test(`fails closed when the ${api} lookup fails`, async () => { + const f = fixture(); + if (api === 'statuses') { + const paginate = f.github.paginate; + f.github.paginate = async (method, params) => { + if (method === f.github.rest.repos.listCommitStatusesForRef) { + throw new Error('GitHub API failed'); + } + return paginate(method, params); + }; + } else { + f.github.rest.actions.getWorkflowRun = async () => { + throw new Error('GitHub API failed'); + }; + } + await assert.rejects(authorizePreview(f), /GitHub API failed/); + assert.deepEqual(f.state.outputs, {}); + await assert.rejects(isCurrentPreview(f, 2684), /GitHub API failed/); + }); +} + +await test('rechecks approval revocation after authorization', async () => { + const f = fixture(); + await authorizePreview(f); + assert.equal(f.state.outputs.pr, 2684); + f.status.state = 'failure'; + assert.equal(await isCurrentPreview(f, 2684), false); + await commentPreview(f, 2684, uploadOutput()); + assert.deepEqual(f.state.writes, []); +}); + for (const [field, value] of [ ['path', '.github/workflows/spoof.yml'], ['event', 'push'], @@ -339,6 +701,7 @@ await test('keeps the previous comment tied to its version when the PR changes d // B passes the pre-upload check, then C arrives while B moves the PR alias. f.context.payload.workflow_run.head_sha = 'b'.repeat(40); f.pr.head.sha = 'b'.repeat(40); + grantApproval(f.state, f.pr.head.sha, f.pr.number, 988); assert.equal(await isCurrentPreview(f, 2684), true); f.pr.head.sha = 'c'.repeat(40); await commentPreview( diff --git a/.github/scripts/docs-fork-preview.mjs b/.github/scripts/docs-fork-preview.mjs index 7ff0c9e7f2..7e12b6b06c 100644 --- a/.github/scripts/docs-fork-preview.mjs +++ b/.github/scripts/docs-fork-preview.mjs @@ -1,11 +1,14 @@ import { lstat, readdir } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { setTimeout } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; const repository = 'voidzero-dev/vite-plus'; const buildWorkflow = '.github/workflows/build-docs-fork-preview.yml'; +const approvalWorkflow = '.github/workflows/approve-docs-fork-preview.yml'; const marker = ''; const previewLabel = 'docs-preview'; +const approvalRunPrefix = `https://github.com/${repository}/actions/runs/`; export function previewUrl(number) { if (!Number.isSafeInteger(number) || number <= 0) { @@ -59,7 +62,96 @@ async function requestedByMaintainer({ github, context }, run) { return ['admin', 'maintain', 'write'].includes(data.permission); } -export async function authorizePreview({ github, context, core }) { +export async function approvePreview({ github, context, core }) { + const snapshot = context.payload.pull_request; + if ( + `${context.repo.owner}/${context.repo.repo}` !== repository || + context.eventName !== 'pull_request_target' || + context.payload.action !== 'labeled' || + context.payload.label?.name !== previewLabel || + !/^[a-f0-9]{40}$/.test(snapshot?.head?.sha) || + !snapshot.head.repo?.id || + !snapshot.head.repo.full_name || + !snapshot.head.ref || + !Number.isSafeInteger(context.runId) || + context.runId <= 0 + ) { + throw new Error('Invalid docs preview approval event'); + } + previewUrl(snapshot.number); + const run = { + head_sha: snapshot.head.sha, + head_branch: snapshot.head.ref, + head_repository: snapshot.head.repo, + actor: { login: context.actor }, + }; + if (!matchesPreview(snapshot, run) || !(await requestedByMaintainer({ github, context }, run))) { + throw new Error('A maintainer must apply docs-preview to an open fork PR'); + } + const { data: current } = await github.rest.pulls.get({ + ...context.repo, + pull_number: snapshot.number, + }); + if (!matchesPreview(current, run)) { + throw new Error('The PR changed after the label event; review it and reapply docs-preview'); + } + await github.rest.repos.createCommitStatus({ + ...context.repo, + sha: snapshot.head.sha, + context: `docs-preview/pr-${snapshot.number}`, + state: 'success', + description: 'Maintainer approved this commit for a docs preview', + target_url: `${approvalRunPrefix}${context.runId}`, + }); + core.info(`Recorded docs preview approval for PR #${snapshot.number} at ${snapshot.head.sha}.`); +} + +async function previewApprovalState({ github, context }, number, sha) { + const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { + ...context.repo, + ref: sha, + per_page: 100, + }); + // GitHub returns statuses newest first. A label on an older commit is not + // permission to publish this one, even when a maintainer triggers its run. + const status = statuses.find((entry) => entry.context === `docs-preview/pr-${number}`); + if (!status) { + return 'pending'; + } + const target = status.target_url; + if ( + status.state !== 'success' || + status.creator?.login !== 'github-actions[bot]' || + !target?.startsWith(approvalRunPrefix) + ) { + return 'denied'; + } + const id = target.slice(approvalRunPrefix.length); + if (!/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(Number(id))) { + return 'denied'; + } + const { data: approval } = await github.rest.actions.getWorkflowRun({ + ...context.repo, + run_id: Number(id), + }); + // The status is only an index. Verify its proof against a trusted workflow + // run so copying a status or a run URL cannot approve a different PR/SHA. + if ( + approval.id !== Number(id) || + approval.repository?.full_name !== repository || + approval.path !== approvalWorkflow || + approval.event !== 'pull_request_target' || + approval.display_title !== `Approve docs preview for PR #${number} at ${sha} (${previewLabel})` + ) { + return 'denied'; + } + if (approval.status !== 'completed') { + return 'pending'; + } + return approval.conclusion === 'success' ? 'approved' : 'denied'; +} + +export async function authorizePreview({ github, context, core, sleep = setTimeout }) { const run = previewRun(context); if (run.head_repository.full_name === repository) { return; @@ -99,6 +191,24 @@ export async function authorizePreview({ github, context, core }) { if (matches.length !== 1) { throw new Error('Expected one active docs-fork-preview artifact from the triggering run'); } + // The build and trusted label handler start independently. Allow the small + // metadata-only approval job to finish, but never deploy without its proof. + let approval; + for (let attempt = 0; attempt < 24; attempt++) { + approval = await previewApprovalState({ github, context }, candidates[0].number, run.head_sha); + if (approval !== 'pending') { + break; + } + if (attempt < 23) { + await sleep(5000); + } + } + if (approval !== 'approved') { + core.info( + 'No successful trusted approval for this PR commit; remove and reapply docs-preview.', + ); + return; + } core.setOutput('pr', candidates[0].number); core.setOutput('artifact-id', matches[0].id); core.setOutput('preview-url', previewUrl(candidates[0].number)); @@ -108,7 +218,11 @@ export async function isCurrentPreview({ github, context }, number) { previewUrl(number); const run = previewRun(context); const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); - return matchesPreview(pr, run) && (await requestedByMaintainer({ github, context }, run)); + return ( + matchesPreview(pr, run) && + (await requestedByMaintainer({ github, context }, run)) && + (await previewApprovalState({ github, context }, number, run.head_sha)) === 'approved' + ); } function uploadedPreviewUrl(output) { diff --git a/.github/workflows/approve-docs-fork-preview.yml b/.github/workflows/approve-docs-fork-preview.yml new file mode 100644 index 0000000000..72df55e36a --- /dev/null +++ b/.github/workflows/approve-docs-fork-preview.yml @@ -0,0 +1,39 @@ +name: Approve Docs Fork Preview + +# This name binds the approval run to the label event's PR and exact head SHA. +# Keep it aligned with previewApprovalState in docs-fork-preview.mjs. +run-name: 'Approve docs preview for PR #${{ github.event.pull_request.number }} at ${{ github.event.pull_request.head.sha }} (${{ github.event.label.name }})' + +# Metadata only: never check out fork code, install dependencies, or build here. +# pull_request_target keeps both this workflow and its helper on trusted main. +# Its only write is a commit status; it receives no deployment credentials. +on: # zizmor: ignore[dangerous-triggers] + pull_request_target: + branches: [main] + types: [labeled] + +permissions: {} + +jobs: + approve: + if: >- + github.repository == 'voidzero-dev/vite-plus' && + github.event.label.name == 'docs-preview' && + github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: + contents: read + pull-requests: read + statuses: write + 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 + with: + script: | + const { approvePreview } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/docs-fork-preview.mjs`); + await approvePreview({ github, context, core }); diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml index a48d3d52e9..5faae48874 100644 --- a/.github/workflows/build-docs-fork-preview.yml +++ b/.github/workflows/build-docs-fork-preview.yml @@ -3,7 +3,7 @@ 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 checks the label and original run actor again. +# the trusted deploy workflow also requires approval for this exact PR commit. permissions: {} on: @@ -17,6 +17,7 @@ on: - 'packages/cli/install-legacy.sh' - 'packages/cli/install-legacy.ps1' - '.github/workflows/build-docs-fork-preview.yml' + - '.github/workflows/approve-docs-fork-preview.yml' - '.github/workflows/deploy-docs-fork-preview.yml' - '.github/scripts/docs-fork-preview.mjs' - '.github/scripts/__tests__/docs-fork-preview.mjs' @@ -71,9 +72,9 @@ jobs: github.event.label.name == 'docs-preview' runs-on: ubuntu-latest timeout-minutes: 15 - # Unrelated label events must not cancel a requested preview build. + # 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 }} + group: build-docs-fork-preview-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} cancel-in-progress: true permissions: contents: read diff --git a/.github/workflows/deploy-docs-fork-preview.yml b/.github/workflows/deploy-docs-fork-preview.yml index f890bd5cf8..e76ed61b98 100644 --- a/.github/workflows/deploy-docs-fork-preview.yml +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -13,7 +13,9 @@ name: Deploy Docs Fork Preview # reapply it after updates. The label is rechecked before upload and commenting; # removing it cannot undo an upload that already started. # The build workflow is PR-controlled, so authorization also requires its -# original run actor to have write permission in this repository. +# original run actor to have write permission in this repository. The trusted +# approve-docs-fork-preview.yml workflow must approve the exact PR commit. +# Both trusted workflows must be on main before fork previews can deploy. on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ['Build Docs Fork Preview'] @@ -33,10 +35,12 @@ jobs: 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 + statuses: read outputs: pr: ${{ steps.preview.outputs.pr }} artifact-id: ${{ steps.preview.outputs.artifact-id }} @@ -71,6 +75,7 @@ jobs: contents: read actions: read pull-requests: write + statuses: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From 7b0d2bb703af77be98b37d7dfc99ac9c52d4fc59 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:29:41 +0800 Subject: [PATCH 09/12] ci: preserve pending fork docs preview deployments --- .../scripts/__tests__/docs-fork-preview.mjs | 85 ++++++++++++++++++- .github/scripts/docs-fork-preview.mjs | 6 ++ .../workflows/deploy-docs-fork-preview.yml | 5 +- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs index ed7934370d..09b1445619 100644 --- a/.github/scripts/__tests__/docs-fork-preview.mjs +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -97,7 +97,13 @@ function fixture() { return { data: { permission: state.permission } }; }, }, - pulls: { list() {}, get: async () => ({ data: pr }) }, + pulls: { + list() {}, + get: async (params) => { + state.requests.push(params); + return { data: structuredClone(pr) }; + }, + }, actions: { listWorkflowRunArtifacts() {}, getWorkflowRun: async (params) => { @@ -118,7 +124,7 @@ function fixture() { paginate: async (method, params) => { state.requests.push(params); if (method === github.rest.pulls.list) { - return state.pulls; + return structuredClone(state.pulls); } if (method === github.rest.actions.listWorkflowRunArtifacts) { return state.artifacts; @@ -297,6 +303,17 @@ await test('isolates build concurrency by PR and SHA, including delayed old runs assert.equal(group(2684, 'b'.repeat(40)), newer); }); +await test('queues pending deployments without replacing them when an old run arrives late', 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+queue: max\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(); await authorizePreview(f); @@ -312,6 +329,10 @@ await test('authorizes a fork with an empty workflow_run PR list and pins its ar { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 123 }, { owner: 'voidzero-dev', repo: 'vite-plus', ref: 'a'.repeat(40), per_page: 100 }, { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 987 }, + { owner: 'voidzero-dev', repo: 'vite-plus', pull_number: 2684 }, + { owner: 'voidzero-dev', repo: 'vite-plus', username: 'maintainer' }, + { owner: 'voidzero-dev', repo: 'vite-plus', ref: 'a'.repeat(40), per_page: 100 }, + { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 987 }, ]); assert.deepEqual(f.state.outputs, { pr: 2684, @@ -452,6 +473,66 @@ for (const pending of ['missing status', 'running workflow']) { }); } +await test('does not enqueue an older authorization that finishes after a newer preview', async () => { + const older = fixture(); + older.approval.status = 'in_progress'; + older.approval.conclusion = null; + const newerOutputs = {}; + const newer = { + ...older, + context: structuredClone(older.context), + core: { + info() {}, + setOutput: (key, value) => (newerOutputs[key] = value), + }, + }; + newer.context.payload.workflow_run.id = 124; + newer.context.payload.workflow_run.head_sha = 'b'.repeat(40); + older.sleep = async (ms) => { + older.state.sleeps.push(ms); + older.pr.head.sha = newer.context.payload.workflow_run.head_sha; + grantApproval(older.state, older.pr.head.sha, older.pr.number, 988); + await authorizePreview(newer); + older.approval.status = 'completed'; + older.approval.conclusion = 'success'; + }; + + await authorizePreview(older); + + assert.equal(newerOutputs.pr, 2684); + assert.deepEqual(older.state.sleeps, [5000]); + assert.deepEqual(older.state.outputs, {}); + assert.equal(await isCurrentPreview(newer, 2684), true); +}); + +for (const { name, mutate } of [ + { name: 'label removal', mutate: (f) => (f.pr.labels = []) }, + { name: 'PR closure', mutate: (f) => (f.pr.state = 'closed') }, + { name: 'a base change', mutate: (f) => (f.pr.base.ref = 'release') }, + { name: 'requester permission removal', mutate: (f) => (f.state.permission = 'read') }, +]) { + await test(`rechecks ${name} after approval polling and before queueing`, async () => { + const f = fixture(); + f.approval.status = 'in_progress'; + f.approval.conclusion = null; + f.sleep = async () => { + grantApproval(f.state); + mutate(f); + }; + 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 api of ['statuses', 'workflow proof']) { await test(`fails closed when the ${api} lookup fails`, async () => { const f = fixture(); diff --git a/.github/scripts/docs-fork-preview.mjs b/.github/scripts/docs-fork-preview.mjs index 7e12b6b06c..46b5ba2b42 100644 --- a/.github/scripts/docs-fork-preview.mjs +++ b/.github/scripts/docs-fork-preview.mjs @@ -209,6 +209,12 @@ export async function authorizePreview({ github, context, core, sleep = setTimeo ); return; } + // Approval polling can outlive the PR state checked above. Do not send a + // stale or revoked request to the deployment queue. + if (!(await isCurrentPreview({ github, context }, candidates[0].number))) { + core.info('The PR changed or preview permission was revoked; skipping the deployment queue.'); + return; + } core.setOutput('pr', candidates[0].number); core.setOutput('artifact-id', matches[0].id); core.setOutput('preview-url', previewUrl(candidates[0].number)); diff --git a/.github/workflows/deploy-docs-fork-preview.yml b/.github/workflows/deploy-docs-fork-preview.yml index e76ed61b98..05c50ec7c2 100644 --- a/.github/workflows/deploy-docs-fork-preview.yml +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -63,10 +63,11 @@ jobs: if: needs.authorize.outputs.pr != '' runs-on: ubuntu-latest timeout-minutes: 10 - # Serialize uploads for a PR. An older run rechecks the head after waiting, - # so it cannot overwrite a preview that a newer run has already uploaded. + # Serialize uploads without replacing pending jobs when an old run arrives + # late. Recheck the head after waiting so stale jobs do not upload. concurrency: group: deploy-docs-fork-preview-${{ needs.authorize.outputs.pr }} + queue: max cancel-in-progress: false environment: name: docs-preview From 6f3182af0975d719818ae39d28a54f293f8d6770 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:44:41 +0800 Subject: [PATCH 10/12] ci: use environment approval for fork docs previews --- .../scripts/__tests__/docs-fork-preview.mjs | 533 ++++++------------ .github/scripts/docs-fork-preview.mjs | 161 ++---- .../workflows/approve-docs-fork-preview.yml | 39 -- .github/workflows/build-docs-fork-preview.yml | 4 +- .../workflows/deploy-docs-fork-preview.yml | 37 +- 5 files changed, 261 insertions(+), 513 deletions(-) delete mode 100644 .github/workflows/approve-docs-fork-preview.yml diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs index 09b1445619..5af20f2d50 100644 --- a/.github/scripts/__tests__/docs-fork-preview.mjs +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -6,36 +6,25 @@ import { join } from 'node:path'; import { test } from 'node:test'; import { - approvePreview, authorizePreview, commentPreview, isCurrentPreview, 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, sha = 'a'.repeat(40), number = 2684, runId = 987) { - const status = { - context: `docs-preview/pr-${number}`, - state: 'success', - creator: { login: 'github-actions[bot]' }, - target_url: `https://github.com/voidzero-dev/vite-plus/actions/runs/${runId}`, - }; +function grantApproval(state, runId = 987) { const approval = { - id: runId, - repository: { full_name: 'voidzero-dev/vite-plus' }, - path: '.github/workflows/approve-docs-fork-preview.yml', - event: 'pull_request_target', - display_title: `Approve docs preview for PR #${number} at ${sha} (docs-preview)`, - status: 'completed', - conclusion: 'success', + environments: [{ id: 10, name: 'docs-preview' }], + state: 'approved', + user: { login: 'reviewer' }, }; - state.statuses.set(sha, [status]); - state.approvals.set(runId, approval); - return { status, approval }; + state.approvals.set(runId, [approval]); + return { approval }; } function uploadOutput(overrides = {}) { @@ -53,6 +42,8 @@ function uploadOutput(overrides = {}) { 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: { @@ -83,18 +74,20 @@ function fixture() { writes: [], requests: [], permission: 'write', - statuses: new Map(), + reviewerPermission: 'write', approvals: new Map(), - sleeps: [], }; const github = { rest: { repos: { - listCommitStatusesForRef() {}, - createCommitStatus: async (params) => state.writes.push({ method: 'status', ...params }), getCollaboratorPermissionLevel: async (params) => { state.requests.push(params); - return { data: { permission: state.permission } }; + return { + data: { + permission: + params.username === 'reviewer' ? state.reviewerPermission : state.permission, + }, + }; }, }, pulls: { @@ -106,13 +99,9 @@ function fixture() { }, actions: { listWorkflowRunArtifacts() {}, - getWorkflowRun: async (params) => { + getReviewsForRun: async (params) => { state.requests.push(params); - const approval = state.approvals.get(params.run_id); - if (!approval) { - throw new Error('Approval workflow run not found'); - } - return { data: approval }; + return { data: state.approvals.get(params.run_id) ?? [] }; }, }, issues: { @@ -132,9 +121,6 @@ function fixture() { if (method === github.rest.issues.listComments) { return state.comments; } - if (method === github.rest.repos.listCommitStatusesForRef) { - return state.statuses.get(params.ref) ?? []; - } throw new Error('Unexpected GitHub request'); }, }; @@ -144,145 +130,50 @@ function fixture() { state.outputs[key] = value; }, }; - const sleep = async (ms) => state.sleeps.push(ms); - return { github, context, core, pr, state, sleep, ...grantApproval(state) }; -} - -function approvalFixture() { - const f = fixture(); - f.context.eventName = 'pull_request_target'; - f.context.actor = 'maintainer'; - f.context.runId = 987; - f.context.payload = { - action: 'labeled', - label: { name: 'docs-preview' }, - pull_request: structuredClone(f.pr), - }; - return f; -} - -await test('records approval for the immutable label event commit', async () => { - const f = approvalFixture(); - await approvePreview(f); - assert.deepEqual(f.state.writes, [ - { - method: 'status', - owner: 'voidzero-dev', - repo: 'vite-plus', - sha: 'a'.repeat(40), - context: 'docs-preview/pr-2684', - state: 'success', - description: 'Maintainer approved this commit for a docs preview', - target_url: 'https://github.com/voidzero-dev/vite-plus/actions/runs/987', - }, - ]); -}); - -for (const { name, mutate } of [ - { name: 'another repository', mutate: (c) => (c.repo.owner = 'contributor') }, - { name: 'an untrusted trigger', mutate: (c) => (c.eventName = 'pull_request') }, - { name: 'a push', mutate: (c) => (c.payload.action = 'synchronize') }, - { name: 'label removal', mutate: (c) => (c.payload.action = 'unlabeled') }, - { name: 'an unrelated label', mutate: (c) => (c.payload.label.name = 'bug') }, - { name: 'a missing label', mutate: (c) => (c.payload.label = undefined) }, - { name: 'a missing PR', mutate: (c) => (c.payload.pull_request = undefined) }, - { name: 'an invalid SHA', mutate: (c) => (c.payload.pull_request.head.sha = 'invalid') }, - { name: 'a deleted fork', mutate: (c) => (c.payload.pull_request.head.repo = null) }, - { name: 'a missing fork ID', mutate: (c) => (c.payload.pull_request.head.repo.id = undefined) }, - { name: 'a missing fork name', mutate: (c) => (c.payload.pull_request.head.repo.full_name = '') }, - { name: 'a missing branch', mutate: (c) => (c.payload.pull_request.head.ref = '') }, - { name: 'an invalid run ID', mutate: (c) => (c.runId = '987') }, - { name: 'a zero run ID', mutate: (c) => (c.runId = 0) }, - { name: 'an unsafe run ID', mutate: (c) => (c.runId = Number.MAX_SAFE_INTEGER + 1) }, - { name: 'an invalid PR number', mutate: (c) => (c.payload.pull_request.number = '2684') }, -]) { - await test(`does not record approval for ${name}`, async () => { - const f = approvalFixture(); - mutate(f.context); - await assert.rejects(approvePreview(f), /Invalid/); - assert.deepEqual(f.state.writes, []); - }); + return { github, context, core, pr, state, ...grantApproval(state) }; } -for (const { name, mutate } of [ - { name: 'a closed PR', mutate: (f) => (f.context.payload.pull_request.state = 'closed') }, - { name: 'a missing preview label', mutate: (f) => (f.context.payload.pull_request.labels = []) }, - { - name: 'a same-repository PR', - mutate: (f) => (f.context.payload.pull_request.head.repo.full_name = 'voidzero-dev/vite-plus'), - }, - { - name: 'another base branch', - mutate: (f) => (f.context.payload.pull_request.base.ref = 'release'), - }, - { name: 'a missing actor', mutate: (f) => (f.context.actor = undefined) }, - ...['read', 'triage', 'none', undefined].map((permission) => ({ - name: `${permission} permission`, - mutate: (f) => (f.state.permission = permission), - })), -]) { - await test(`does not record approval with ${name}`, async () => { - const f = approvalFixture(); - mutate(f); - await assert.rejects(approvePreview(f), /A maintainer must apply/); - assert.deepEqual(f.state.writes, []); - }); -} - -for (const { name, mutate } of [ - { name: 'a new commit', mutate: (pr) => (pr.head.sha = 'b'.repeat(40)) }, - { name: 'label removal', mutate: (pr) => (pr.labels = []) }, - { name: 'PR closure', mutate: (pr) => (pr.state = 'closed') }, - { name: 'a base change', mutate: (pr) => (pr.base.ref = 'release') }, -]) { - await test(`does not record approval after ${name} during label handling`, async () => { - const f = approvalFixture(); - mutate(f.pr); - await assert.rejects(approvePreview(f), /The PR changed after the label event/); - assert.deepEqual(f.state.writes, []); - }); -} - -await test('never transfers approval to a commit pushed during the status write', async () => { - const f = approvalFixture(); - const create = f.github.rest.repos.createCommitStatus; - f.github.rest.repos.createCommitStatus = async (params) => { - f.pr.head.sha = 'b'.repeat(40); - await create(params); - }; - await approvePreview(f); - assert.equal(f.state.writes[0].sha, 'a'.repeat(40)); -}); - -for (const [area, method] of [ - ['repos', 'getCollaboratorPermissionLevel'], - ['pulls', 'get'], - ['repos', 'createCommitStatus'], -]) { - await test(`fails closed when approval ${method} fails`, async () => { - const f = approvalFixture(); - f.github.rest[area][method] = async () => { - throw new Error('GitHub API failed'); - }; - await assert.rejects(approvePreview(f), /GitHub API failed/); - assert.deepEqual(f.state.writes, []); - }); -} - -await test('keeps the trusted workflow run name aligned with the approval proof', async () => { - const yaml = await readFile( - new URL('../../workflows/approve-docs-fork-preview.yml', import.meta.url), +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 template = yaml.match(/^run-name: '(.+)'$/m)?.[1]; - assert.ok(template); - const f = fixture(); - f.approval.display_title = template - .replaceAll('${{ github.event.pull_request.number }}', '2684') - .replaceAll('${{ github.event.pull_request.head.sha }}', f.pr.head.sha) - .replaceAll('${{ github.event.label.name }}', 'docs-preview'); - await authorizePreview(f); - assert.equal(f.state.outputs.pr, 2684); + 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 () => { @@ -316,6 +207,8 @@ await test('queues pending deployments without replacing them when an old run ar 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, [ { @@ -327,197 +220,164 @@ await test('authorizes a fork with an empty workflow_run PR list and pins its ar }, { owner: 'voidzero-dev', repo: 'vite-plus', username: 'maintainer' }, { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 123 }, - { owner: 'voidzero-dev', repo: 'vite-plus', ref: 'a'.repeat(40), per_page: 100 }, - { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 987 }, { owner: 'voidzero-dev', repo: 'vite-plus', pull_number: 2684 }, { owner: 'voidzero-dev', repo: 'vite-plus', username: 'maintainer' }, - { owner: 'voidzero-dev', repo: 'vite-plus', ref: 'a'.repeat(40), per_page: 100 }, - { owner: 'voidzero-dev', repo: 'vite-plus', run_id: 987 }, ]); assert.deepEqual(f.state.outputs, { pr: 2684, 'artifact-id': 456, '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('does not approve a new commit when a maintainer applies an unrelated label', async () => { +await test('checks approval for the current deployment run, not the build run', async () => { const f = fixture(); - await authorizePreview(f); - assert.equal(f.state.outputs.pr, 2684); + 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' }, + ]); +}); - // The fork changes its workflow to build on any label. A maintainer applies - // bug to B while docs-preview remains from A: actor and label checks pass. +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.state.outputs = {}; + f.context.payload.workflow_run.id = 124; + f.context.runId = 988; await authorizePreview(f); - assert.deepEqual(f.state.outputs, {}); + 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, []); - - // Only a new trusted approval for B permits deployment. - grantApproval(f.state, f.pr.head.sha, f.pr.number, 988); - await authorizePreview(f); - assert.equal(f.state.outputs.pr, 2684); + grantApproval(f.state, f.context.runId); + await requireDeploymentApproval(f); + assert.equal(await isCurrentPreview(f, 2684), true); }); -for (const { name, mutate } of [ - { name: 'missing status', mutate: (f) => f.state.statuses.clear() }, - { name: 'another PR status', mutate: (f) => (f.status.context = 'docs-preview/pr-2685') }, - { name: 'failed status', mutate: (f) => (f.status.state = 'failure') }, - { name: 'pending status', mutate: (f) => (f.status.state = 'pending') }, - { name: 'manual status', mutate: (f) => (f.status.creator.login = 'contributor') }, - { name: 'missing creator', mutate: (f) => (f.status.creator = null) }, - { name: 'missing URL', mutate: (f) => (f.status.target_url = null) }, - { name: 'external URL', mutate: (f) => (f.status.target_url = 'https://example.com/987') }, - { - name: 'fork run URL', - mutate: (f) => - (f.status.target_url = 'https://github.com/contributor/vite-plus/actions/runs/987'), - }, - ...['0', '-987', '0987', '987/attempts/1', '987?other=true', '9007199254740992'].map((id) => ({ - name: `invalid run URL ${id}`, - mutate: (f) => - (f.status.target_url = `https://github.com/voidzero-dev/vite-plus/actions/runs/${id}`), - })), - { name: 'mismatched proof ID', mutate: (f) => (f.approval.id = 988) }, - { - name: 'fork proof', - mutate: (f) => (f.approval.repository.full_name = 'contributor/vite-plus'), - }, - { name: 'missing proof repository', mutate: (f) => (f.approval.repository = null) }, - { name: 'untrusted workflow', mutate: (f) => (f.approval.path = '.github/workflows/spoof.yml') }, - { name: 'PR-controlled proof', mutate: (f) => (f.approval.event = 'pull_request') }, - { name: 'manual proof', mutate: (f) => (f.approval.event = 'workflow_dispatch') }, - { - name: 'proof for another PR', - mutate: (f) => (f.approval.display_title = f.approval.display_title.replace('#2684', '#2685')), - }, - { - name: 'proof for another SHA', - mutate: (f) => - (f.approval.display_title = f.approval.display_title.replace('a'.repeat(40), 'b'.repeat(40))), - }, - { - name: 'proof for another label', - mutate: (f) => - (f.approval.display_title = f.approval.display_title.replace('(docs-preview)', '(bug)')), - }, - { name: 'missing proof title', mutate: (f) => (f.approval.display_title = undefined) }, - ...['failure', 'cancelled', 'skipped', null].map((conclusion) => ({ - name: `${conclusion} proof`, - mutate: (f) => (f.approval.conclusion = conclusion), - })), +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(`rejects ${name} during authorization and rechecks`, async () => { + await test('refuses deployment and commenting with ' + name, async () => { const f = fixture(); mutate(f); - await authorizePreview(f); - assert.deepEqual(f.state.outputs, {}); + 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, []); }); } -await test('does not accept an old proof URL copied into a new commit status', async () => { +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.pr.head.sha = 'b'.repeat(40); - f.context.payload.workflow_run.head_sha = f.pr.head.sha; - f.state.statuses.set(f.pr.head.sha, [structuredClone(f.status)]); - await authorizePreview(f); - assert.deepEqual(f.state.outputs, {}); - assert.equal(await isCurrentPreview(f, 2684), false); + f.state.approvals.get(987).unshift({ state: 'rejected', environments: [{ name: 'release' }] }); + await requireDeploymentApproval(f); }); -await test('uses the latest status instead of an older successful approval', async () => { +await test('rechecks approval revocation before upload and commenting', async () => { const f = fixture(); - f.state.statuses.get(f.pr.head.sha).unshift({ ...f.status, state: 'failure' }); - await authorizePreview(f); - assert.deepEqual(f.state.outputs, {}); - assert.deepEqual(f.state.sleeps, []); + 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 pending of ['missing status', 'running workflow']) { - await test(`waits for the trusted approval when there is a ${pending}`, async () => { +for (const [area, method] of [ + ['actions', 'getReviewsForRun'], + ['repos', 'getCollaboratorPermissionLevel'], +]) { + await test('fails closed when the deployment approval ' + method + ' lookup fails', async () => { const f = fixture(); - if (pending === 'missing status') { - f.state.statuses.clear(); - } else { - f.approval.status = 'in_progress'; - f.approval.conclusion = null; - } - f.sleep = async (ms) => { - f.state.sleeps.push(ms); - grantApproval(f.state); + f.github.rest[area][method] = async () => { + throw new Error('GitHub API failed'); }; - await authorizePreview(f); - assert.equal(f.state.outputs.pr, 2684); - assert.deepEqual(f.state.sleeps, [5000]); + 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, []); }); +} - await test(`stops waiting after a bounded interval for a ${pending}`, async () => { +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(); - if (pending === 'missing status') { - f.state.statuses.clear(); - } else { - f.approval.status = 'in_progress'; - f.approval.conclusion = null; - } - await authorizePreview(f); - assert.deepEqual(f.state.outputs, {}); - assert.deepEqual(f.state.sleeps, Array(23).fill(5000)); - assert.equal(await isCurrentPreview(f, 2684), false); + f.context.runId = runId; + await assert.rejects(requireDeploymentApproval(f), /Invalid docs preview workflow run/); + assert.deepEqual(f.state.requests, []); }); } -await test('does not enqueue an older authorization that finishes after a newer preview', async () => { - const older = fixture(); - older.approval.status = 'in_progress'; - older.approval.conclusion = null; - const newerOutputs = {}; - const newer = { - ...older, - context: structuredClone(older.context), - core: { - info() {}, - setOutput: (key, value) => (newerOutputs[key] = value), - }, - }; - newer.context.payload.workflow_run.id = 124; - newer.context.payload.workflow_run.head_sha = 'b'.repeat(40); - older.sleep = async (ms) => { - older.state.sleeps.push(ms); - older.pr.head.sha = newer.context.payload.workflow_run.head_sha; - grantApproval(older.state, older.pr.head.sha, older.pr.number, 988); - await authorizePreview(newer); - older.approval.status = 'completed'; - older.approval.conclusion = 'success'; - }; - - await authorizePreview(older); - - assert.equal(newerOutputs.pr, 2684); - assert.deepEqual(older.state.sleeps, [5000]); - assert.deepEqual(older.state.outputs, {}); - assert.equal(await isCurrentPreview(newer, 2684), true); -}); - -for (const { name, mutate } of [ - { name: 'label removal', mutate: (f) => (f.pr.labels = []) }, - { name: 'PR closure', mutate: (f) => (f.pr.state = 'closed') }, - { name: 'a base change', mutate: (f) => (f.pr.base.ref = 'release') }, - { name: 'requester permission removal', mutate: (f) => (f.state.permission = 'read') }, +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} after approval polling and before queueing`, async () => { + await test('rechecks ' + name + ' before requesting environment review', async () => { const f = fixture(); - f.approval.status = 'in_progress'; - f.approval.conclusion = null; - f.sleep = async () => { - grantApproval(f.state); - mutate(f); + 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, {}); @@ -533,38 +393,6 @@ await test('fails closed when the PR recheck before queueing fails', async () => assert.deepEqual(f.state.outputs, {}); }); -for (const api of ['statuses', 'workflow proof']) { - await test(`fails closed when the ${api} lookup fails`, async () => { - const f = fixture(); - if (api === 'statuses') { - const paginate = f.github.paginate; - f.github.paginate = async (method, params) => { - if (method === f.github.rest.repos.listCommitStatusesForRef) { - throw new Error('GitHub API failed'); - } - return paginate(method, params); - }; - } else { - f.github.rest.actions.getWorkflowRun = async () => { - throw new Error('GitHub API failed'); - }; - } - await assert.rejects(authorizePreview(f), /GitHub API failed/); - assert.deepEqual(f.state.outputs, {}); - await assert.rejects(isCurrentPreview(f, 2684), /GitHub API failed/); - }); -} - -await test('rechecks approval revocation after authorization', async () => { - const f = fixture(); - await authorizePreview(f); - assert.equal(f.state.outputs.pr, 2684); - f.status.state = 'failure'; - assert.equal(await isCurrentPreview(f, 2684), false); - await commentPreview(f, 2684, uploadOutput()); - assert.deepEqual(f.state.writes, []); -}); - for (const [field, value] of [ ['path', '.github/workflows/spoof.yml'], ['event', 'push'], @@ -587,6 +415,18 @@ await test('rejects a workflow running in another repository', async () => { 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'; @@ -782,7 +622,8 @@ await test('keeps the previous comment tied to its version when the PR changes d // B passes the pre-upload check, then C arrives while B moves the PR alias. f.context.payload.workflow_run.head_sha = 'b'.repeat(40); f.pr.head.sha = 'b'.repeat(40); - grantApproval(f.state, f.pr.head.sha, f.pr.number, 988); + f.context.runId = 988; + grantApproval(f.state, f.context.runId); assert.equal(await isCurrentPreview(f, 2684), true); f.pr.head.sha = 'c'.repeat(40); await commentPreview( diff --git a/.github/scripts/docs-fork-preview.mjs b/.github/scripts/docs-fork-preview.mjs index 46b5ba2b42..607a60d82f 100644 --- a/.github/scripts/docs-fork-preview.mjs +++ b/.github/scripts/docs-fork-preview.mjs @@ -1,14 +1,11 @@ import { lstat, readdir } from 'node:fs/promises'; import { join, resolve } from 'node:path'; -import { setTimeout } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; const repository = 'voidzero-dev/vite-plus'; const buildWorkflow = '.github/workflows/build-docs-fork-preview.yml'; -const approvalWorkflow = '.github/workflows/approve-docs-fork-preview.yml'; const marker = ''; const previewLabel = 'docs-preview'; -const approvalRunPrefix = `https://github.com/${repository}/actions/runs/`; export function previewUrl(number) { if (!Number.isSafeInteger(number) || number <= 0) { @@ -21,6 +18,9 @@ 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' || @@ -49,109 +49,51 @@ function matchesPreview(pr, run) { ); } -async function requestedByMaintainer({ github, context }, run) { - // Use the original actor, not triggering_actor: a maintainer re-running an - // outsider's workflow must not grant that run deployment permission. - if (!run.actor?.login) { +async function hasWritePermission({ github, context }, login) { + if (!login) { return false; } const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...context.repo, - username: run.actor.login, + username: login, }); return ['admin', 'maintain', 'write'].includes(data.permission); } -export async function approvePreview({ github, context, core }) { - const snapshot = context.payload.pull_request; - if ( - `${context.repo.owner}/${context.repo.repo}` !== repository || - context.eventName !== 'pull_request_target' || - context.payload.action !== 'labeled' || - context.payload.label?.name !== previewLabel || - !/^[a-f0-9]{40}$/.test(snapshot?.head?.sha) || - !snapshot.head.repo?.id || - !snapshot.head.repo.full_name || - !snapshot.head.ref || - !Number.isSafeInteger(context.runId) || - context.runId <= 0 - ) { - throw new Error('Invalid docs preview approval event'); - } - previewUrl(snapshot.number); - const run = { - head_sha: snapshot.head.sha, - head_branch: snapshot.head.ref, - head_repository: snapshot.head.repo, - actor: { login: context.actor }, - }; - if (!matchesPreview(snapshot, run) || !(await requestedByMaintainer({ github, context }, run))) { - throw new Error('A maintainer must apply docs-preview to an open fork PR'); - } - const { data: current } = await github.rest.pulls.get({ +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, - pull_number: snapshot.number, + run_id: context.runId, }); - if (!matchesPreview(current, run)) { - throw new Error('The PR changed after the label event; review it and reapply docs-preview'); + 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; } - await github.rest.repos.createCommitStatus({ - ...context.repo, - sha: snapshot.head.sha, - context: `docs-preview/pr-${snapshot.number}`, - state: 'success', - description: 'Maintainer approved this commit for a docs preview', - target_url: `${approvalRunPrefix}${context.runId}`, - }); - core.info(`Recorded docs preview approval for PR #${snapshot.number} at ${snapshot.head.sha}.`); + for (const review of reviews) { + if (await hasWritePermission({ github, context }, review.user?.login)) { + return true; + } + } + return false; } -async function previewApprovalState({ github, context }, number, sha) { - const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { - ...context.repo, - ref: sha, - per_page: 100, - }); - // GitHub returns statuses newest first. A label on an older commit is not - // permission to publish this one, even when a maintainer triggers its run. - const status = statuses.find((entry) => entry.context === `docs-preview/pr-${number}`); - if (!status) { - return 'pending'; - } - const target = status.target_url; - if ( - status.state !== 'success' || - status.creator?.login !== 'github-actions[bot]' || - !target?.startsWith(approvalRunPrefix) - ) { - return 'denied'; - } - const id = target.slice(approvalRunPrefix.length); - if (!/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(Number(id))) { - return 'denied'; - } - const { data: approval } = await github.rest.actions.getWorkflowRun({ - ...context.repo, - run_id: Number(id), - }); - // The status is only an index. Verify its proof against a trusted workflow - // run so copying a status or a run URL cannot approve a different PR/SHA. - if ( - approval.id !== Number(id) || - approval.repository?.full_name !== repository || - approval.path !== approvalWorkflow || - approval.event !== 'pull_request_target' || - approval.display_title !== `Approve docs preview for PR #${number} at ${sha} (${previewLabel})` - ) { - return 'denied'; - } - if (approval.status !== 'completed') { - return 'pending'; +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.', + ); } - return approval.conclusion === 'success' ? 'approved' : 'denied'; } -export async function authorizePreview({ github, context, core, sleep = setTimeout }) { +export async function authorizePreview({ github, context, core }) { const run = previewRun(context); if (run.head_repository.full_name === repository) { return; @@ -173,7 +115,9 @@ export async function authorizePreview({ github, context, core, sleep = setTimeo if (candidates.length !== 1) { throw new Error('More than one PR matches the docs preview run'); } - if (!(await requestedByMaintainer({ github, context }, 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; } @@ -191,27 +135,16 @@ export async function authorizePreview({ github, context, core, sleep = setTimeo if (matches.length !== 1) { throw new Error('Expected one active docs-fork-preview artifact from the triggering run'); } - // The build and trusted label handler start independently. Allow the small - // metadata-only approval job to finish, but never deploy without its proof. - let approval; - for (let attempt = 0; attempt < 24; attempt++) { - approval = await previewApprovalState({ github, context }, candidates[0].number, run.head_sha); - if (approval !== 'pending') { - break; - } - if (attempt < 23) { - await sleep(5000); - } - } - if (approval !== 'approved') { - core.info( - 'No successful trusted approval for this PR commit; remove and reapply docs-preview.', - ); - return; - } - // Approval polling can outlive the PR state checked above. Do not send a - // stale or revoked request to the deployment queue. - if (!(await isCurrentPreview({ github, context }, candidates[0].number))) { + // 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; } @@ -226,8 +159,8 @@ export async function isCurrentPreview({ github, context }, number) { const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); return ( matchesPreview(pr, run) && - (await requestedByMaintainer({ github, context }, run)) && - (await previewApprovalState({ github, context }, number, run.head_sha)) === 'approved' + (await hasWritePermission({ github, context }, run.actor?.login)) && + (await hasDeploymentApproval({ github, context })) ); } diff --git a/.github/workflows/approve-docs-fork-preview.yml b/.github/workflows/approve-docs-fork-preview.yml deleted file mode 100644 index 72df55e36a..0000000000 --- a/.github/workflows/approve-docs-fork-preview.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Approve Docs Fork Preview - -# This name binds the approval run to the label event's PR and exact head SHA. -# Keep it aligned with previewApprovalState in docs-fork-preview.mjs. -run-name: 'Approve docs preview for PR #${{ github.event.pull_request.number }} at ${{ github.event.pull_request.head.sha }} (${{ github.event.label.name }})' - -# Metadata only: never check out fork code, install dependencies, or build here. -# pull_request_target keeps both this workflow and its helper on trusted main. -# Its only write is a commit status; it receives no deployment credentials. -on: # zizmor: ignore[dangerous-triggers] - pull_request_target: - branches: [main] - types: [labeled] - -permissions: {} - -jobs: - approve: - if: >- - github.repository == 'voidzero-dev/vite-plus' && - github.event.label.name == 'docs-preview' && - github.event.pull_request.head.repo.full_name != github.repository - runs-on: ubuntu-latest - timeout-minutes: 3 - permissions: - contents: read - pull-requests: read - statuses: write - 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 - with: - script: | - const { approvePreview } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/docs-fork-preview.mjs`); - await approvePreview({ github, context, core }); diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml index 5faae48874..1ee7f6ace6 100644 --- a/.github/workflows/build-docs-fork-preview.yml +++ b/.github/workflows/build-docs-fork-preview.yml @@ -3,7 +3,8 @@ 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 also requires approval for this exact PR commit. +# the trusted deploy workflow requires environment approval for each build. +# This follows publish-preview.yml; only static artifacts cross to deployment. permissions: {} on: @@ -17,7 +18,6 @@ on: - 'packages/cli/install-legacy.sh' - 'packages/cli/install-legacy.ps1' - '.github/workflows/build-docs-fork-preview.yml' - - '.github/workflows/approve-docs-fork-preview.yml' - '.github/workflows/deploy-docs-fork-preview.yml' - '.github/scripts/docs-fork-preview.mjs' - '.github/scripts/__tests__/docs-fork-preview.mjs' diff --git a/.github/workflows/deploy-docs-fork-preview.yml b/.github/workflows/deploy-docs-fork-preview.yml index 05c50ec7c2..6bc7259aa6 100644 --- a/.github/workflows/deploy-docs-fork-preview.yml +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -1,21 +1,27 @@ 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. # -# Maintainer setup: restrict the docs-preview environment to main. Add the -# CLOUDFLARE_ACCOUNT_ID variable and CLOUDFLARE_API_TOKEN secret there. Scope -# the token to the Worker account with Workers Scripts: Edit permission. +# 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. The label is rechecked before upload and commenting; -# removing it cannot undo an upload that already started. -# The build workflow is PR-controlled, so authorization also requires its -# original run actor to have write permission in this repository. The trusted -# approve-docs-fork-preview.yml workflow must approve the exact PR commit. -# Both trusted workflows must be on main before fork previews can deploy. +# 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. on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ['Build Docs Fork Preview'] @@ -30,6 +36,7 @@ defaults: 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' && @@ -40,7 +47,6 @@ jobs: contents: read actions: read pull-requests: read - statuses: read outputs: pr: ${{ steps.preview.outputs.pr }} artifact-id: ${{ steps.preview.outputs.artifact-id }} @@ -59,8 +65,9 @@ jobs: await authorizePreview({ github, context, core }); deploy: + name: 'Deploy PR #${{ needs.authorize.outputs.pr }} at ${{ github.event.workflow_run.head_sha }}' needs: authorize - if: needs.authorize.outputs.pr != '' + if: github.event_name == 'workflow_run' && needs.authorize.outputs.pr != '' runs-on: ubuntu-latest timeout-minutes: 10 # Serialize uploads without replacing pending jobs when an old run arrives @@ -76,7 +83,6 @@ jobs: contents: read actions: read pull-requests: write - statuses: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -86,6 +92,13 @@ jobs: .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' From 16351284ed9015fa29e86b97c20e48764e692ac9 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Tue, 15 Sep 2026 02:57:11 +0800 Subject: [PATCH 11/12] ci: pin docs preview installer URLs to build attempts --- .../scripts/__tests__/docs-fork-preview.mjs | 153 ++++++++++++++++-- .github/scripts/docs-fork-preview.mjs | 40 +++-- .github/workflows/build-docs-fork-preview.yml | 6 +- .../workflows/deploy-docs-fork-preview.yml | 6 +- 4 files changed, 180 insertions(+), 25 deletions(-) diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs index 5af20f2d50..b34e39e050 100644 --- a/.github/scripts/__tests__/docs-fork-preview.mjs +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -1,6 +1,7 @@ // Run with node --test; these workflow helpers need no workspace dependencies. import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +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'; @@ -9,6 +10,7 @@ import { authorizePreview, commentPreview, isCurrentPreview, + previewAlias, previewUrl, requireDeploymentApproval, validateAssets, @@ -34,7 +36,7 @@ function uploadOutput(overrides = {}) { worker_name: 'viteplus-dev', version_id: versionId, preview_url: versionUrl, - preview_alias_url: previewUrl(2684), + preview_alias_url: previewUrl(123, 1), ...overrides, })}\n`; } @@ -48,6 +50,7 @@ function fixture() { payload: { workflow_run: { id: 123, + run_attempt: 1, path: '.github/workflows/build-docs-fork-preview.yml', event: 'pull_request', conclusion: 'success', @@ -68,7 +71,7 @@ function fixture() { }; const state = { pulls: [pr], - artifacts: [{ id: 456, name: 'docs-fork-preview', expired: false }], + artifacts: [{ id: 456, name: 'docs-fork-preview-1', expired: false }], comments: [], outputs: {}, writes: [], @@ -194,6 +197,126 @@ await test('isolates build concurrency by PR and SHA, including delayed old runs assert.equal(group(2684, 'b'.repeat(40)), newer); }); +await test('keeps build origins, artifact names, and deployment aliases aligned', 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 [runId, attempt] of [ + [123, 1], + [124, 1], + [123, 2], + ]) { + const f = fixture(); + f.context.payload.workflow_run.id = runId; + f.context.payload.workflow_run.run_attempt = attempt; + f.state.artifacts[0].name = artifactTemplate.replace( + '${{ github.run_attempt }}', + String(attempt), + ); + await authorizePreview(f); + const origin = originTemplate + .replace('${{ github.run_id }}', String(runId)) + .replace('${{ github.run_attempt }}', String(attempt)); + assert.equal(origin, f.state.outputs['preview-url']); + assert.equal(f.state.outputs['preview-alias'], previewAlias(runId, attempt)); + } + 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"/); + assert.doesNotMatch(deploy, /--preview-alias "pr-\$PR_NUMBER"/); +}); + +await test('keeps installer origins isolated after a newer build and a rerun', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'docs-preview-installers-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const snapshots = []; + for (const [runId, attempt] of [ + [123, 1], + [124, 1], + [123, 2], + ]) { + const root = join(directory, `${runId}-${attempt}`); + 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(runId, attempt); + 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, output, shell, powershell }); + } + assert.equal(new Set(snapshots.map((snapshot) => snapshot.origin)).size, 3); + for (const snapshot of snapshots) { + assert.equal(await readFile(join(snapshot.output, 'install.sh'), 'utf8'), snapshot.shell); + assert.equal(await readFile(join(snapshot.output, 'install.ps1'), 'utf8'), snapshot.powershell); + 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'], 'build-123-1'); + 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'], 'build-123-2'); + 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 and keeps aliases within DNS limits', 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], + ]) { + assert.throws(() => previewAlias(runId, attempt), /Invalid docs preview build identity/); + assert.throws(() => previewUrl(runId, attempt), /Invalid docs preview build identity/); + 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, []); + } + } + const alias = previewAlias(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); + assert.match(alias, /^[a-z][a-z0-9-]*$/); + assert.ok(`${alias}-viteplus-dev`.length <= 63); +}); + await test('queues pending deployments without replacing them when an old run arrives late', async () => { const yaml = await readFile( new URL('../../workflows/deploy-docs-fork-preview.yml', import.meta.url), @@ -226,7 +349,8 @@ await test('authorizes a fork with an empty workflow_run PR list and pins its ar assert.deepEqual(f.state.outputs, { pr: 2684, 'artifact-id': 456, - 'preview-url': 'https://pr-2684-viteplus-dev.voidzero-docs.workers.dev', + 'preview-alias': 'build-123-1', + 'preview-url': 'https://build-123-1-viteplus-dev.voidzero-docs.workers.dev', }); await assert.rejects(requireDeploymentApproval(f), /needs maintainer approval/); assert.equal(await isCurrentPreview(f, 2684), false); @@ -557,11 +681,11 @@ await test('skips successful helper-only or unrelated label runs without artifac }); for (const artifacts of [ - [{ id: 456, name: 'docs-fork-preview', expired: true }], + [{ id: 456, name: 'docs-fork-preview-1', expired: true }], [{ id: 456, name: 'other', expired: false }], [ - { id: 456, name: 'docs-fork-preview', expired: false }, - { id: 789, name: 'docs-fork-preview', 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 () => { @@ -584,7 +708,7 @@ await test('ignores a contributor comment that copies the bot marker', async () assert.equal(f.state.writes[0].issue_number, 2684); assert.equal( f.state.writes[0].body, - `\nCloudflare documentation preview: ${versionUrl}\n\nCommit: ${'a'.repeat(40)}\n\nLatest uploaded preview (may show another commit): ${previewUrl(2684)}`, + `\nCloudflare documentation preview: ${versionUrl}\n\nCommit: ${'a'.repeat(40)}`, ); }); @@ -619,7 +743,8 @@ await test('keeps the previous comment tied to its version when the PR changes d }); f.state.writes = []; - // B passes the pre-upload check, then C arrives while B moves the PR alias. + // B passes the pre-upload check, then C arrives while B uploads its version. + f.context.payload.workflow_run.id = 124; f.context.payload.workflow_run.head_sha = 'b'.repeat(40); f.pr.head.sha = 'b'.repeat(40); f.context.runId = 988; @@ -655,7 +780,14 @@ for (const [name, output] of [ ['another Worker', uploadOutput({ worker_name: 'other' })], ['invalid version ID', uploadOutput({ version_id: 'invalid' })], ['disabled preview URLs', uploadOutput({ preview_url: undefined })], - ['moving alias', uploadOutput({ preview_url: previewUrl(2684) })], + ['alias instead of version URL', uploadOutput({ preview_url: previewUrl(123, 1) })], + ['missing build alias', uploadOutput({ preview_alias_url: undefined })], + ['another build alias', uploadOutput({ preview_alias_url: previewUrl(124, 1) })], + ['another attempt alias', uploadOutput({ preview_alias_url: previewUrl(123, 2) })], + [ + 'moving PR alias', + uploadOutput({ preview_alias_url: 'https://pr-2684-viteplus-dev.voidzero-docs.workers.dev' }), + ], [ 'another version URL', uploadOutput({ preview_url: versionUrl.replace('11111111', '22222222') }), @@ -671,7 +803,6 @@ for (const [name, output] of [ await test('rejects invalid PR numbers before using them in URLs or requests', async () => { for (const number of [0, -1, 1.5, NaN, '2684', '2684\nother-output=true']) { - assert.throws(() => previewUrl(number), /Invalid pull request number/); await assert.rejects(isCurrentPreview(fixture(), number), /Invalid pull request number/); } }); diff --git a/.github/scripts/docs-fork-preview.mjs b/.github/scripts/docs-fork-preview.mjs index 607a60d82f..f3c2fc53b5 100644 --- a/.github/scripts/docs-fork-preview.mjs +++ b/.github/scripts/docs-fork-preview.mjs @@ -7,11 +7,21 @@ const buildWorkflow = '.github/workflows/build-docs-fork-preview.yml'; const marker = ''; const previewLabel = 'docs-preview'; -export function previewUrl(number) { +function validatePrNumber(number) { if (!Number.isSafeInteger(number) || number <= 0) { throw new Error('Invalid pull request number'); } - return `https://pr-${number}-viteplus-dev.voidzero-docs.workers.dev`; +} + +export function previewAlias(runId, attempt) { + if (![runId, attempt].every((value) => Number.isSafeInteger(value) && value > 0)) { + throw new Error('Invalid docs preview build identity'); + } + return `build-${runId}-${attempt}`; +} + +export function previewUrl(runId, attempt) { + return `https://${previewAlias(runId, attempt)}-viteplus-dev.voidzero-docs.workers.dev`; } function previewRun(context) { @@ -32,6 +42,7 @@ function previewRun(context) { ) { throw new Error('Invalid docs preview workflow run'); } + previewAlias(run.id, run.run_attempt); return run; } @@ -131,9 +142,12 @@ export async function authorizePreview({ github, context, core }) { core.info('The workflow produced no artifacts; skipping the preview.'); return; } - const matches = artifacts.filter((a) => a.name === 'docs-fork-preview' && !a.expired); + // A rerun has its own origin. Do not pair one attempt's origin with another + // attempt's artifact when a delayed deployment lists the run's artifacts. + 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 docs-fork-preview artifact from the triggering run'); + 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. @@ -148,13 +162,15 @@ export async function authorizePreview({ github, context, core }) { 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-url', previewUrl(candidates[0].number)); + core.setOutput('preview-alias', previewAlias(run.id, run.run_attempt)); + core.setOutput('preview-url', previewUrl(run.id, run.run_attempt)); } export async function isCurrentPreview({ github, context }, number) { - previewUrl(number); + validatePrNumber(number); const run = previewRun(context); const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); return ( @@ -164,9 +180,9 @@ export async function isCurrentPreview({ github, context }, number) { ); } -function uploadedPreviewUrl(output) { +function uploadedPreviewUrl(output, expectedAliasUrl) { // WRANGLER_OUTPUT_FILE_PATH contains JSONL, not console output. Require one - // upload from this job and its version URL; never substitute the moving alias. + // upload from this job, its version URL, and the alias used by its installers. const uploads = output .split('\n') .filter((line) => line.trim() !== '') @@ -181,10 +197,11 @@ function uploadedPreviewUrl(output) { 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` + `https://${upload.version_id.slice(0, 8)}-viteplus-dev.voidzero-docs.workers.dev` || + upload.preview_alias_url !== expectedAliasUrl ) { throw new Error( - 'Invalid version preview URL from Wrangler; check that Preview URLs are enabled', + 'Invalid preview URLs from Wrangler; check that Preview URLs and the build alias are configured', ); } return upload.preview_url; @@ -195,7 +212,8 @@ export async function commentPreview({ github, context, core }, number, output) core.info('The PR changed or preview permission was revoked; skipping the preview comment.'); return; } - const body = `${marker}\nCloudflare documentation preview: ${uploadedPreviewUrl(output)}\n\nCommit: ${context.payload.workflow_run.head_sha}\n\nLatest uploaded preview (may show another commit): ${previewUrl(number)}`; + const run = previewRun(context); + const body = `${marker}\nCloudflare documentation preview: ${uploadedPreviewUrl(output, previewUrl(run.id, run.run_attempt))}\n\nCommit: ${run.head_sha}`; const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: number, diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml index 1ee7f6ace6..37470c07f6 100644 --- a/.github/workflows/build-docs-fork-preview.yml +++ b/.github/workflows/build-docs-fork-preview.yml @@ -94,13 +94,15 @@ jobs: run: vp run build:cloudflare working-directory: docs env: - DOCS_SITE_ORIGIN: https://pr-${{ github.event.pull_request.number }}-viteplus-dev.voidzero-docs.workers.dev + # Pin absolute links and piped installers to this build attempt. + # Keep this origin aligned with previewUrl in docs-fork-preview.mjs. + DOCS_SITE_ORIGIN: https://build-${{ github.run_id }}-${{ github.run_attempt }}-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 + 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 index 6bc7259aa6..7f33edc22c 100644 --- a/.github/workflows/deploy-docs-fork-preview.yml +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -22,6 +22,8 @@ run-name: 'Deploy docs preview at ${{ github.event.workflow_run.head_sha }} from # 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 build attempt gets its own alias, so its installer URLs cannot move to +# another build. These aliases remain subject to Cloudflare's retention limits. on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ['Build Docs Fork Preview'] @@ -50,6 +52,7 @@ jobs: 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 @@ -137,6 +140,7 @@ jobs: 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: | @@ -147,7 +151,7 @@ jobs: "$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 "pr-$PR_NUMBER" \ + --preview-alias "$PREVIEW_ALIAS" \ --message "Docs preview for PR #$PR_NUMBER ($HEAD_SHA)" - name: Comment on the fork PR From 3119b04ffcba9aad36465172edbe28da4ecfa9df Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 15 Sep 2026 03:19:22 +0800 Subject: [PATCH 12/12] ci: reuse one docs preview alias per PR --- .../scripts/__tests__/docs-fork-preview.mjs | 147 ++++++++++-------- .github/scripts/docs-fork-preview.mjs | 30 ++-- .github/workflows/build-docs-fork-preview.yml | 4 +- .../workflows/deploy-docs-fork-preview.yml | 9 +- 4 files changed, 103 insertions(+), 87 deletions(-) diff --git a/.github/scripts/__tests__/docs-fork-preview.mjs b/.github/scripts/__tests__/docs-fork-preview.mjs index b34e39e050..51711c6d86 100644 --- a/.github/scripts/__tests__/docs-fork-preview.mjs +++ b/.github/scripts/__tests__/docs-fork-preview.mjs @@ -36,7 +36,7 @@ function uploadOutput(overrides = {}) { worker_name: 'viteplus-dev', version_id: versionId, preview_url: versionUrl, - preview_alias_url: previewUrl(123, 1), + preview_alias_url: previewUrl(2684), ...overrides, })}\n`; } @@ -197,7 +197,7 @@ await test('isolates build concurrency by PR and SHA, including delayed old runs assert.equal(group(2684, 'b'.repeat(40)), newer); }); -await test('keeps build origins, artifact names, and deployment aliases aligned', async () => { +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', @@ -210,41 +210,43 @@ await test('keeps build origins, artifact names, and deployment aliases aligned' const artifactTemplate = build.match(/name: (docs-fork-preview-.+)/)?.[1]; assert.ok(originTemplate); assert.ok(artifactTemplate); - for (const [runId, attempt] of [ - [123, 1], - [124, 1], - [123, 2], + 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.run_id }}', String(runId)) - .replace('${{ github.run_attempt }}', String(attempt)); + 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'], previewAlias(runId, attempt)); + 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"/); - assert.doesNotMatch(deploy, /--preview-alias "pr-\$PR_NUMBER"/); }); -await test('keeps installer origins isolated after a newer build and a rerun', async (t) => { +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 [runId, attempt] of [ - [123, 1], - [124, 1], - [123, 2], - ]) { - const root = join(directory, `${runId}-${attempt}`); + 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'); @@ -262,18 +264,16 @@ await test('keeps installer origins isolated after a newer build and a rerun', a join(installers, name), ); } - const origin = previewUrl(runId, attempt); + 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, output, shell, powershell }); + snapshots.push({ origin, shell, powershell }); } - assert.equal(new Set(snapshots.map((snapshot) => snapshot.origin)).size, 3); + assert.equal(new Set(snapshots.map((snapshot) => snapshot.origin)).size, 2); for (const snapshot of snapshots) { - assert.equal(await readFile(join(snapshot.output, 'install.sh'), 'utf8'), snapshot.shell); - assert.equal(await readFile(join(snapshot.output, 'install.ps1'), 'utf8'), snapshot.powershell); for (const other of snapshots) { if (other.origin !== snapshot.origin) { assert.ok(!snapshot.shell.includes(other.origin)); @@ -288,23 +288,21 @@ await test('pins an artifact to the triggering build attempt', async () => { 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'], 'build-123-1'); + 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'], 'build-123-2'); + 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 and keeps aliases within DNS limits', async () => { +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], ]) { - assert.throws(() => previewAlias(runId, attempt), /Invalid docs preview build identity/); - assert.throws(() => previewUrl(runId, attempt), /Invalid docs preview build identity/); const f = fixture(); f.context.payload.workflow_run.id = runId; f.context.payload.workflow_run.run_attempt = attempt; @@ -312,19 +310,22 @@ await test('validates build identities and keeps aliases within DNS limits', asy assert.deepEqual(f.state.requests, []); } } - const alias = previewAlias(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); +}); + +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('queues pending deployments without replacing them when an old run arrives late', async () => { +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+queue: max\n\s+cancel-in-progress: false/, + /concurrency:\n\s+group: deploy-docs-fork-preview-\$\{\{ needs\.authorize\.outputs\.pr \}\}\n\s+cancel-in-progress: false/, ); }); @@ -349,8 +350,8 @@ await test('authorizes a fork with an empty workflow_run PR list and pins its ar assert.deepEqual(f.state.outputs, { pr: 2684, 'artifact-id': 456, - 'preview-alias': 'build-123-1', - 'preview-url': 'https://build-123-1-viteplus-dev.voidzero-docs.workers.dev', + '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); @@ -708,7 +709,7 @@ await test('ignores a contributor comment that copies the bot marker', async () assert.equal(f.state.writes[0].issue_number, 2684); assert.equal( f.state.writes[0].body, - `\nCloudflare documentation preview: ${versionUrl}\n\nCommit: ${'a'.repeat(40)}`, + `\nCloudflare documentation preview: ${previewUrl(2684)}\n\nCommit: ${'a'.repeat(40)}`, ); }); @@ -722,7 +723,7 @@ await test('updates the existing bot comment', async () => { 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(versionUrl)); + assert.ok(f.state.writes[0].body.includes(previewUrl(2684))); }); await test('does not comment if the PR changes during upload', async () => { @@ -732,7 +733,7 @@ await test('does not comment if the PR changes during upload', async () => { assert.deepEqual(f.state.writes, []); }); -await test('keeps the previous comment tied to its version when the PR changes during upload', async () => { +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; @@ -743,33 +744,37 @@ await test('keeps the previous comment tied to its version when the PR changes d }); f.state.writes = []; - // B passes the pre-upload check, then C arrives while B uploads its version. - f.context.payload.workflow_run.id = 124; - f.context.payload.workflow_run.head_sha = 'b'.repeat(40); - f.pr.head.sha = 'b'.repeat(40); - f.context.runId = 988; - grantApproval(f.state, f.context.runId); - assert.equal(await isCurrentPreview(f, 2684), true); - f.pr.head.sha = 'c'.repeat(40); - await commentPreview( - f, - 2684, - uploadOutput({ - version_id: '22222222-2222-4222-8222-222222222222', - preview_url: 'https://22222222-viteplus-dev.voidzero-docs.workers.dev', - }), - ); + 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.deepEqual(f.state.writes, []); - assert.equal(f.state.comments[0].body, previousBody); - assert.ok(previousBody.includes(`Cloudflare documentation preview: ${versionUrl}`)); - assert.ok(previousBody.includes(`Commit: ${'a'.repeat(40)}`)); + 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 version URL from Wrangler JSONL with other records and blank lines', async () => { +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(versionUrl)); + assert.ok(f.state.writes[0].body.includes(previewUrl(2684))); + assert.ok(!f.state.writes[0].body.includes(versionUrl)); }); for (const [name, output] of [ @@ -780,13 +785,14 @@ for (const [name, output] of [ ['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(123, 1) })], - ['missing build alias', uploadOutput({ preview_alias_url: undefined })], - ['another build alias', uploadOutput({ preview_alias_url: previewUrl(124, 1) })], - ['another attempt alias', uploadOutput({ preview_alias_url: previewUrl(123, 2) })], + ['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) })], [ - 'moving PR alias', - uploadOutput({ preview_alias_url: 'https://pr-2684-viteplus-dev.voidzero-docs.workers.dev' }), + 'build attempt alias', + uploadOutput({ + preview_alias_url: 'https://build-123-1-viteplus-dev.voidzero-docs.workers.dev', + }), ], [ 'another version URL', @@ -802,7 +808,18 @@ for (const [name, output] of [ } await test('rejects invalid PR numbers before using them in URLs or requests', async () => { - for (const number of [0, -1, 1.5, NaN, '2684', '2684\nother-output=true']) { + 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/); } }); diff --git a/.github/scripts/docs-fork-preview.mjs b/.github/scripts/docs-fork-preview.mjs index f3c2fc53b5..d3f8fa99c5 100644 --- a/.github/scripts/docs-fork-preview.mjs +++ b/.github/scripts/docs-fork-preview.mjs @@ -13,15 +13,13 @@ function validatePrNumber(number) { } } -export function previewAlias(runId, attempt) { - if (![runId, attempt].every((value) => Number.isSafeInteger(value) && value > 0)) { - throw new Error('Invalid docs preview build identity'); - } - return `build-${runId}-${attempt}`; +export function previewAlias(number) { + validatePrNumber(number); + return `pr-${number}`; } -export function previewUrl(runId, attempt) { - return `https://${previewAlias(runId, attempt)}-viteplus-dev.voidzero-docs.workers.dev`; +export function previewUrl(number) { + return `https://${previewAlias(number)}-viteplus-dev.voidzero-docs.workers.dev`; } function previewRun(context) { @@ -42,7 +40,9 @@ function previewRun(context) { ) { throw new Error('Invalid docs preview workflow run'); } - previewAlias(run.id, run.run_attempt); + if (![run.id, run.run_attempt].every((value) => Number.isSafeInteger(value) && value > 0)) { + throw new Error('Invalid docs preview build identity'); + } return run; } @@ -142,8 +142,8 @@ export async function authorizePreview({ github, context, core }) { core.info('The workflow produced no artifacts; skipping the preview.'); return; } - // A rerun has its own origin. Do not pair one attempt's origin with another - // attempt's artifact when a delayed deployment lists the run's artifacts. + // 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) { @@ -165,8 +165,8 @@ export async function authorizePreview({ github, context, core }) { validatePrNumber(candidates[0].number); core.setOutput('pr', candidates[0].number); core.setOutput('artifact-id', matches[0].id); - core.setOutput('preview-alias', previewAlias(run.id, run.run_attempt)); - core.setOutput('preview-url', previewUrl(run.id, run.run_attempt)); + core.setOutput('preview-alias', previewAlias(candidates[0].number)); + core.setOutput('preview-url', previewUrl(candidates[0].number)); } export async function isCurrentPreview({ github, context }, number) { @@ -201,10 +201,10 @@ function uploadedPreviewUrl(output, expectedAliasUrl) { upload.preview_alias_url !== expectedAliasUrl ) { throw new Error( - 'Invalid preview URLs from Wrangler; check that Preview URLs and the build alias are configured', + 'Invalid preview URLs from Wrangler; check that Preview URLs and the PR alias are configured', ); } - return upload.preview_url; + return upload.preview_alias_url; } export async function commentPreview({ github, context, core }, number, output) { @@ -213,7 +213,7 @@ export async function commentPreview({ github, context, core }, number, output) return; } const run = previewRun(context); - const body = `${marker}\nCloudflare documentation preview: ${uploadedPreviewUrl(output, previewUrl(run.id, run.run_attempt))}\n\nCommit: ${run.head_sha}`; + 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, diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml index 37470c07f6..ca14f774bd 100644 --- a/.github/workflows/build-docs-fork-preview.yml +++ b/.github/workflows/build-docs-fork-preview.yml @@ -94,9 +94,9 @@ jobs: run: vp run build:cloudflare working-directory: docs env: - # Pin absolute links and piped installers to this build attempt. + # 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://build-${{ github.run_id }}-${{ github.run_attempt }}-viteplus-dev.voidzero-docs.workers.dev + 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. diff --git a/.github/workflows/deploy-docs-fork-preview.yml b/.github/workflows/deploy-docs-fork-preview.yml index 7f33edc22c..d7c5c0206a 100644 --- a/.github/workflows/deploy-docs-fork-preview.yml +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -22,8 +22,8 @@ run-name: 'Deploy docs preview at ${{ github.event.workflow_run.head_sha }} from # 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 build attempt gets its own alias, so its installer URLs cannot move to -# another build. These aliases remain subject to Cloudflare's retention limits. +# 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'] @@ -73,11 +73,10 @@ jobs: if: github.event_name == 'workflow_run' && needs.authorize.outputs.pr != '' runs-on: ubuntu-latest timeout-minutes: 10 - # Serialize uploads without replacing pending jobs when an old run arrives - # late. Recheck the head after waiting so stale jobs do not upload. + # 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 }} - queue: max cancel-in-progress: false environment: name: docs-preview