# 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 = '<!-- kumo-docs-preview -->';
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: 7cloudflare/kumo
Publicmirrored from https://github.com/cloudflare/kumoAvailable
.github/workflows/preview-deploy.yml
236lines · modepreview