name: Build PR preview

# Every push to a pull request publishes that pull request's preview to
# Cloudflare Pages under its own branch alias, behind Cloudflare Access. The
# upload carries build products only: the workflow never grants the deployment
# platform access to this repository's sources.

on:
  pull_request:
    types: [opened, synchronize, reopened]

# Within one pull request the newest build wins. Across pull requests there is
# nothing to serialize: each uploads to its own branch alias, so two deployments
# never contend for the same URL.
concurrency:
  group: build-preview-cloudflare-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read
  pull-requests: write

env:
  PRIMARY_NODE_VERSION: '24'
  # Cloudflare Pages project receiving the upload. Its preview deployments are
  # the surface the Access application protects; the project's production branch
  # is deliberately a name no deployment uses, so no unprotected URL exists.
  CF_PROJECT: dsh-build-preview
  # CI runs must never report to the production telemetry endpoint baked into
  # apps/cli/cordis.yml (AppCLIEntry disables the row when set).
  DSH_TELEMETRY_DISABLED: '1'

jobs:
  preview:
    runs-on: dsh-ubuntu-24-04-16core
    name: cloudflare pages preview
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false

      - uses: pnpm/action-setup@v4
        with:
          dest: ${{ runner.temp }}/setup-pnpm

      - uses: actions/setup-node@v6
        with:
          node-version: ${{ env.PRIMARY_NODE_VERSION }}

      - name: Configure pnpm store path
        id: pnpm-store
        run: |
          store_root="$HOME/.local/share/pnpm/store"
          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
          echo "path=$store_path" >> "$GITHUB_OUTPUT"

      # Read-only: the preview lane consumes the default-branch cache without
      # putting cache upload on its own path.
      - uses: actions/cache/restore@v4
        with:
          path: ${{ steps.pnpm-store.outputs.path }}
          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-

      - name: Install (immutable)
        run: pnpm install --frozen-lockfile

      # apps/web consumes workspace packages as built lib products, and
      # build:preview packs the image through the packer's installed bin
      # (lib/bin.js), so neither half exists before the full build runs.
      - name: Build workspace
        run: pnpm run build

      - name: Build the preview page and pack the VFS image
        env:
          DSH_CLIENT_TITLE: DSH preview pr-${{ github.event.pull_request.number }}
        run: pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview

      # Sourcemaps carry complete sources and stay off the deployment platform.
      # index.html is the served page, which cannot boot without a host
      # injecting window.__DSH_BOOT__; replacing it with the worker page makes
      # the deployment root the usable entry instead of a page that never boots.
      - name: Shape the upload
        run: |
          find apps/web/dist -name '*.map' -delete
          cp apps/web/dist/preview.html apps/web/dist/index.html

      - name: Upload to Cloudflare Pages
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: |
          npx --yes wrangler@4 pages deploy apps/web/dist \
            --project-name "$CF_PROJECT" \
            --branch "pr-${{ github.event.pull_request.number }}" \
            --commit-dirty=true

      # The image is what a worker boot fails on first and least visibly, so the
      # run only passes once the protected URL serves it as gzip bytes. Three
      # facts are asserted, each with its own failure meaning:
      #   200                     Access admitted the request; a 302 means the
      #                           Access policy is missing its Service Auth rule
      #                           for this token
      #   no content-encoding     the platform did not claim transport
      #                           compression, which would make the browser
      #                           decode the body and leave the worker's
      #                           DecompressionStream inflating a plain tar
      #   gzip magic 1f 8b        the bytes really are the gzip member the
      #                           packer wrote
      # Accept-Encoding is sent because a browser sends it; the assertion is
      # about what the platform does with a body that is already compressed.
      - name: Verify the protected deployment serves the image
        env:
          CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
          CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
        run: |
          url="https://pr-${{ github.event.pull_request.number }}.${CF_PROJECT}.pages.dev"
          image="$url/preview/vfs-image.tar.gz"
          code=000
          for attempt in 1 2 3 4 5; do
            code=$(curl -sS -o image.bin -D headers.txt -w '%{http_code}' \
              -H 'Accept-Encoding: gzip' \
              -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
              -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
              "$image" || echo 000)
            echo "attempt $attempt: HTTP $code"
            if [ "$code" = "200" ]; then break; fi
            sleep 10
          done
          if [ "$code" != "200" ]; then
            echo "the protected image URL answered $code, not 200"
            head -20 headers.txt
            exit 1
          fi
          if grep -qi '^content-encoding:' headers.txt; then
            echo "the platform declared transport compression on an already-compressed image:"
            grep -i '^content-encoding:' headers.txt
            exit 1
          fi
          magic=$(head -c 2 image.bin | od -An -tx1 | tr -d ' \n')
          if [ "$magic" != "1f8b" ]; then
            echo "image does not start with the gzip magic number: $magic"
            exit 1
          fi
          echo "image served as $(wc -c < image.bin) gzip bytes"

      # The alias URL follows from the pull request number, so it is stable
      # across redeploys and worth stating once. The marker makes the comment
      # idempotent: a pull request opened before this workflow existed never
      # sees an `opened` event, and every later push must not restate the URL.
      - name: Comment the preview URL
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR: ${{ github.event.pull_request.number }}
        run: |
          marker='<!-- dsh-preview-url -->'
          existing=$(gh pr view "$PR" --json comments \
            --jq "[.comments[] | select(.body | contains(\"$marker\")) | .url] | first // empty")
          if [ -n "$existing" ]; then
            echo "preview URL already commented: $existing"
            exit 0
          fi
          # The marker sits on its own line: markdown renders no link on a
          # line that opens with a raw HTML comment.
          printf '%s\n\n%s\n' \
            "$marker" \
            "[Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" \
            | gh pr comment "$PR" --body-file -
