# Deploys docs preview for fork PRs via workflow_run. # # Security model: fork code runs in the untrusted pull_request context # (preview.yml docs-build job) and produces a static HTML artifact. # This workflow runs in the base repo context with secrets, but NEVER # checks out or executes fork code — it only deploys the built artifact. # # IMPORTANT: ALL checkouts in this workflow use ref: main (or no ref, which # also defaults to main via the workflow_run base context). The wrangler.jsonc # was previously checked out from the fork's SHA, but wrangler supports a # build.command field that executes arbitrary shell commands — meaning a # malicious wrangler.jsonc from a fork could exfiltrate CLOUDFLARE_API_TOKEN. # We always deploy using the wrangler config from main. # # The visual-regression job always checks out ci/visual-regression from main # (never the fork's SHA) before running it with secrets. The fork's component # changes are captured via the deployed preview URL (AFTER_URL), not via # executing the fork's copy of the script. # # For internal PRs, deployment happens directly in preview.yml (docs-deploy job). name: Preview Deploy (Forks) on: workflow_run: workflows: ["Preview"] types: [completed] permissions: contents: write pull-requests: write concurrency: group: preview-deploy-${{ github.event.workflow_run.id }} cancel-in-progress: true jobs: deploy: name: Docs Deploy (Fork) # Only run for: # 1. Successful workflow runs # 2. Pull request events (not push) # 3. Fork PRs only (internal PRs deploy in preview.yml directly) if: >- github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.head_repository.full_name != github.repository runs-on: ubuntu-latest timeout-minutes: 10 outputs: preview_url: ${{ steps.deploy.outputs.preview_url }} pr_number: ${{ steps.metadata.outputs.pr_number }} steps: # Always check out wrangler config from main — never from the fork's SHA. # wrangler.jsonc supports a build.command field that executes arbitrary # shell commands; checking out the fork's version with CLOUDFLARE_API_TOKEN # in env would allow secret exfiltration via a malicious config. - name: Checkout wrangler config from main uses: actions/checkout@v4 with: sparse-checkout: packages/kumo-docs-astro/wrangler.jsonc ref: main - name: Resolve PR metadata id: metadata uses: actions/github-script@v7 with: script: | // workflow_run.pull_requests can be empty for fork PRs. // Fall back to the Pulls API when that happens. const prs = context.payload.workflow_run.pull_requests; if (prs && prs.length > 0) { core.setOutput('pr_number', prs[0].number.toString()); core.setOutput('head_sha', prs[0].head.sha); return; } // Fallback: find the PR matching the head SHA via API const headSha = context.payload.workflow_run.head_sha; const headBranch = context.payload.workflow_run.head_branch; const { data: pulls } = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', head: `${context.payload.workflow_run.head_repository.full_name.split('/')[0]}:${headBranch}`, }); const match = pulls.find(pr => pr.head.sha === headSha); if (!match) { core.setFailed(`No open PR found for SHA ${headSha} on branch ${headBranch}`); return; } core.setOutput('pr_number', match.number.toString()); core.setOutput('head_sha', headSha); - name: Download docs artifact uses: actions/download-artifact@v4 with: name: docs-preview-dist path: packages/kumo-docs-astro/dist/ run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Install wrangler run: npm install -g wrangler - name: Deploy docs preview id: deploy working-directory: packages/kumo-docs-astro env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} HEAD_SHA: ${{ steps.metadata.outputs.head_sha }} run: | COMMIT_SHORT="${HEAD_SHA:0:7}" VERSION_OUTPUT=$(wrangler versions upload --env="" --message "PR #${PR_NUMBER} (${COMMIT_SHORT})" 2>&1) || true echo "$VERSION_OUTPUT" if ! echo "$VERSION_OUTPUT" | grep -q "Worker Version ID:"; then echo "::error::wrangler versions upload failed -- no Worker Version ID in output" exit 1 fi PREVIEW_URL=$(echo "$VERSION_OUTPUT" | grep -oE 'Version Preview URL: https://[^ ]+' | sed 's/Version Preview URL: //') if [ -z "$PREVIEW_URL" ]; then VERSION_ID=$(echo "$VERSION_OUTPUT" | grep -oE 'Worker Version ID: [a-f0-9-]+' | sed 's/Worker Version ID: //') if [ -n "$VERSION_ID" ]; then VERSION_PREFIX=$(echo "$VERSION_ID" | cut -c1-8) PREVIEW_URL="https://${VERSION_PREFIX}-kumo-docs.design-engineering.workers.dev" fi fi if [ -z "$PREVIEW_URL" ]; then echo "::error::Failed to determine docs preview URL" exit 1 fi echo "preview_url=$PREVIEW_URL" >> "$GITHUB_OUTPUT" - name: Comment on PR uses: actions/github-script@v7 env: PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} HEAD_SHA: ${{ steps.metadata.outputs.head_sha }} PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} with: script: | const marker = ''; const previewUrl = process.env.PREVIEW_URL; const headSha = process.env.HEAD_SHA; const prNumber = parseInt(process.env.PR_NUMBER, 10); const body = [ marker, '### Docs Preview', '', `[View docs preview](${previewUrl})`, '', `Commit: \`${headSha.substring(0, 7)}\``, ].join('\n'); const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, }); const existing = comments.find(c => c.body?.startsWith(marker)); if (existing) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body, }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body, }); } visual-regression: name: Visual Regression (Fork) needs: deploy if: ${{ needs.deploy.outputs.preview_url }} runs-on: ubuntu-latest timeout-minutes: 15 steps: # Always check out the VR script from main — never from the fork's SHA. # The fork's component changes are captured via AFTER_URL (the deployed # preview), not by running the fork's copy of run-visual-regression.ts. # Running untrusted code from a fork with secrets in env is the exact # attack vector demonstrated in PR #279. - name: Checkout VR script from main uses: actions/checkout@v4 with: ref: main sparse-checkout: | ci/visual-regression .github/actions/install-dependencies # Fetch the fork's HEAD so git diff works for changed-file detection. - name: Fetch fork HEAD for git diff run: git fetch --depth=1 origin ${{ github.event.workflow_run.head_sha }} - name: Install Dependencies uses: ./.github/actions/install-dependencies with: filter: "@cloudflare/kumo..." - name: Run Visual Regression env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_PR_NUMBER: ${{ needs.deploy.outputs.pr_number }} GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_BASE_REF: main PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} BEFORE_URL: "https://kumo-ui.com" AFTER_URL: ${{ needs.deploy.outputs.preview_url }} SCREENSHOT_API_KEY: ${{ secrets.SCREENSHOT_API_KEY }} run: pnpm tsx ci/visual-regression/run-visual-regression.ts - name: Upload Screenshots uses: actions/upload-artifact@v4 if: always() with: name: visual-regression-screenshots-fork path: ci/visual-regression/screenshots/ retention-days: 7