cloudflare/kumo

Public

mirrored from https://github.com/cloudflare/kumoAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
main

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

.github/workflows/preview-deploy.yml

236lines · modepreview

# 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: 7