name: Deploy Site

on:
  workflow_run:
    workflows: [Build Site]
    types: [completed]

permissions:
  contents: read
  actions: read
  deployments: write
  pull-requests: write

concurrency:
  group: site-deploy-${{ github.event.workflow_run.event }}-${{ github.event.workflow_run.head_repository.owner.login }}-${{ github.event.workflow_run.head_branch }}
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-24.04
    if: >-
      github.event.workflow_run.conclusion == 'success' &&
      github.event.workflow_run.repository.full_name == github.repository &&
      contains(fromJSON('["pull_request","push"]'), github.event.workflow_run.event)
    env:
      HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
      HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
    steps:
      # Trusted tree only: wrangler.toml must not come from PR checkout (no PR-controlled [build] on the deploy runner).
      # PR/fork Worker + static files ship in the Build Site artifact under _bundle/; we copy only public/ and worker/ (never extract TOML from the zip into cloudflare_site/).
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "22"
          cache: npm
          cache-dependency-path: package-lock.json

      - name: Install JS dependencies
        run: npm ci

      - name: Download build artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: site
          path: _bundle
          github-token: ${{ secrets.GITHUB_TOKEN }}
          run-id: ${{ github.event.workflow_run.id }}

      - name: Apply artifact to Cloudflare project
        run: |
          set -euo pipefail
          if [[ ! -d _bundle ]]; then
            echo "::error::Missing _bundle after artifact download"
            exit 1
          fi
          while IFS= read -r -d '' entry; do
            b=$(basename "$entry")
            if [[ "$b" != "public" && "$b" != "worker" ]]; then
              echo "::error::Disallowed path in site artifact: $b (only public/ and worker/ may exist at the top level of _bundle/)"
              exit 1
            fi
            if [[ ! -d "$entry" ]]; then
              echo "::error::_bundle/$b must be a directory"
              exit 1
            fi
          done < <(find _bundle -mindepth 1 -maxdepth 1 -print0)
          if [[ ! -d _bundle/public ]] || [[ ! -d _bundle/worker ]]; then
            echo "::error::Artifact must contain _bundle/public/ and _bundle/worker/"
            exit 1
          fi
          mkdir -p cloudflare_site/public cloudflare_site/worker
          cp -a _bundle/public/. cloudflare_site/public/
          cp -a _bundle/worker/. cloudflare_site/worker/

      - name: Patch wrangler.toml for CI (worker name + rate limit namespaces)
        env:
          CLOUDFLARE_PROJECT_NAME: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
        run: node cloudflare_site/scripts/patch-wrangler-rate-limit-namespace-ids.mjs

      - name: Resolve preview context (PR number + preview alias)
        id: preview-context
        if: success()
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        with:
          script: |
            const run = context.payload.workflow_run;
            if (run.event !== 'pull_request') {
              core.setOutput('preview_alias', '');
              core.setOutput('pr_number', '');
              return;
            }

            const owner = context.repo.owner;
            const repo = context.repo.repo;
            let prNumber = run.pull_requests?.[0]?.number;

            if (!prNumber) {
              const head = `${run.head_repository.owner.login}:${run.head_branch}`;
              const { data: prs } = await github.rest.pulls.list({
                owner,
                repo,
                state: 'open',
                head,
                per_page: 100,
              });
              if (prs.length === 1) {
                prNumber = prs[0].number;
              } else if (prs.length === 0) {
                core.setFailed(`Cannot resolve PR for preview: no open PR for head=${head}`);
                return;
              } else {
                core.warning(
                  `Multiple open PRs (${prs.length}) for head=${head}; preview alias falls back to workflow_run.id; PR comment upsert skipped until head is unique`,
                );
                prNumber = null;
              }
            }

            const workflowRunId = run.id;
            const previewAlias = prNumber != null ? `pr-${prNumber}` : `pr-${workflowRunId}`;
            core.setOutput('preview_alias', previewAlias);
            core.setOutput('pr_number', prNumber != null ? String(prNumber) : '');

      # Same 10214 issue as previews: wrangler-action runs script-level `wrangler secret bulk`
      # before `deploy` when `secrets:` is set. Use `wrangler versions secret bulk` in preCommands
      # instead (cloudflare/wrangler-action#374).
      # preCommands run under `/bin/sh` (dash on Ubuntu): no `pipefail` (bash-only).
      # Each newline in preCommands is a separate shell invocation — no line continuations or
      # multi-line printf; keep the secret bulk prep on one line so mktemp and the file path match.
      - name: Deploy to production (Workers + static assets)
        id: cf-prod
        if: github.event.workflow_run.event == 'push'
        uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
        with:
          wranglerVersion: "4.36.0"
          workingDirectory: cloudflare_site
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          preCommands: set -eu; secrets_file="$(mktemp)"; printf '%s\n' "GITHUB_APP_CLIENT_SECRET=${GITHUB_APP_CLIENT_SECRET}" "TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}" >"$secrets_file"; npx wrangler@4.36.0 versions secret bulk "$secrets_file" --message "Production secrets (workflow-run ${{ github.event.workflow_run.id }})"; rm -f "$secrets_file"
          command: deploy --name="${{ vars.CLOUDFLARE_PROJECT_NAME }}"
          vars: |
            GITHUB_APP_CLIENT_ID
            TURNSTILE_SITE_KEY
        env:
          GITHUB_APP_CLIENT_ID: ${{ vars.FULLSEND_GITHUB_APP_CLIENT_ID }}
          GITHUB_APP_CLIENT_SECRET: ${{ secrets.FULLSEND_GITHUB_APP_CLIENT_SECRET }}
          TURNSTILE_SITE_KEY: ${{ vars.FULLSEND_TURNSTILE_SITE_KEY }}
          TURNSTILE_SECRET_KEY: ${{ secrets.FULLSEND_TURNSTILE_SECRET_KEY }}

      # PR previews: wrangler-action runs script-level `wrangler secret bulk` *before* the main command.
      # That conflicts with `versions upload` (API 10214: latest version isn't deployed). Apply secrets
      # with `wrangler versions secret bulk` in preCommands instead (cloudflare/wrangler-action#374).
      # Plain `vars` are only auto-injected for deploy/publish, so pass `--var` on `versions upload`.
      # preCommands run under `/bin/sh` (dash on Ubuntu): no `pipefail` (bash-only).
      # Each newline in preCommands is a separate shell invocation — keep secret bulk prep on one line.
      - name: Upload preview version (Workers + static assets)
        id: cf-preview
        if: github.event.workflow_run.event == 'pull_request'
        uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
        with:
          wranglerVersion: "4.36.0"
          workingDirectory: cloudflare_site
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          preCommands: set -eu; secrets_file="$(mktemp)"; printf '%s\n' "GITHUB_APP_CLIENT_SECRET=${GITHUB_APP_CLIENT_SECRET}" "TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}" >"$secrets_file"; npx wrangler@4.36.0 versions secret bulk "$secrets_file" --message "PR preview secrets (workflow-run ${{ github.event.workflow_run.id }})"; rm -f "$secrets_file"
          command: >-
            versions upload
            --name="${{ vars.CLOUDFLARE_PROJECT_NAME }}"
            --preview-alias ${{ steps.preview-context.outputs.preview_alias }}
            --var GITHUB_APP_CLIENT_ID:${{ vars.FULLSEND_GITHUB_APP_CLIENT_ID }}
            --var TURNSTILE_SITE_KEY:${{ vars.FULLSEND_TURNSTILE_SITE_KEY }}
        env:
          GITHUB_APP_CLIENT_SECRET: ${{ secrets.FULLSEND_GITHUB_APP_CLIENT_SECRET }}
          TURNSTILE_SECRET_KEY: ${{ secrets.FULLSEND_TURNSTILE_SECRET_KEY }}

      - name: Resolve deployment URL
        id: meta
        if: >-
          (steps.cf-prod.outcome == 'success' || steps.cf-preview.outcome == 'success')
        env:
          URL_PROD: ${{ steps.cf-prod.outputs.deployment-url }}
          URL_PREVIEW: ${{ steps.cf-preview.outputs.deployment-url }}
          OUT_PROD: ${{ steps.cf-prod.outputs.command-output }}
          ERR_PROD: ${{ steps.cf-prod.outputs.command-stderr }}
          OUT_PR: ${{ steps.cf-preview.outputs.command-output }}
          ERR_PR: ${{ steps.cf-preview.outputs.command-stderr }}
        run: |
          set -euo pipefail
          url="${URL_PROD:-}"
          if [ -z "$url" ]; then
            url="${URL_PREVIEW:-}"
          fi
          if [ -z "$url" ]; then
            comb="${OUT_PROD:-}${ERR_PROD:-}${OUT_PR:-}${ERR_PR:-}"
            url=$(printf '%s' "$comb" | grep -oE 'https://[a-zA-Z0-9._/?#&=%_-]+' | grep -E '\.workers\.dev(/|$)' | head -1 || true)
          fi
          if [ -z "$url" ]; then
            echo "::error::Could not determine Workers deployment URL from Wrangler output"
            exit 1
          fi
          echo "deployment_url=$url" >> "$GITHUB_OUTPUT"

      - name: Validate preview deployment URL (alias vs versioned URL)
        if: >-
          github.event.workflow_run.event == 'pull_request' &&
          steps.cf-preview.outcome == 'success' &&
          steps.meta.outcome == 'success'
        env:
          URL: ${{ steps.meta.outputs.deployment_url }}
          ALIAS_TOKEN: ${{ steps.preview-context.outputs.preview_alias }}
        run: |
          set -euo pipefail
          if [[ -z "${ALIAS_TOKEN:-}" ]]; then
            exit 0
          fi
          if [[ "$URL" != *"$ALIAS_TOKEN"* ]]; then
            echo "::warning::Preview deployment URL does not include alias token '${ALIAS_TOKEN}' (got: ${URL}). wrangler-action may be returning a versioned URL instead of the preview-alias hostname; confirm in Cloudflare or Wrangler structured output."
          fi

      - name: GitHub Deployment + preview comment
        if: steps.meta.outcome == 'success'
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          DEPLOYMENT_URL: ${{ steps.meta.outputs.deployment_url }}
          PREVIEW_PR_NUMBER: ${{ steps.preview-context.outputs.pr_number }}
        with:
          script: |
            const run = context.payload.workflow_run;
            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const sha = run.head_sha;
            const isPR = run.event === 'pull_request';
            const environment = isPR ? 'site-preview' : 'site-production';
            const url = process.env.DEPLOYMENT_URL;
            if (!url) {
              core.setFailed('Missing deployment URL after Workers deploy/upload');
              return;
            }

            const deployment = await github.rest.repos.createDeployment({
              owner,
              repo,
              ref: sha,
              environment,
              auto_merge: false,
              required_contexts: [],
              transient_environment: isPR,
              production_environment: !isPR,
            });
            const deploymentId = deployment.data.id;

            await github.rest.repos.createDeploymentStatus({
              owner,
              repo,
              deployment_id: deploymentId,
              state: 'success',
              environment_url: url,
              description: 'Cloudflare Workers (static assets)',
              auto_inactive: isPR,
            });

            if (!isPR) return;

            const raw = process.env.PREVIEW_PR_NUMBER || '';
            const prNumber = raw ? Number.parseInt(raw, 10) : NaN;
            if (!Number.isFinite(prNumber)) {
              core.warning(
                'Skipping PR preview comment upsert: no unique PR number (preview deployment and GitHub Deployment still recorded)',
              );
              return;
            }

            const marker = '<!-- site-preview -->';
            const body = [
              marker,
              '### Site preview',
              '',
              `**Preview:** ${url}`,
              '',
              `Commit: \`${sha}\``,
            ].join('\n');

            let existing = null;
            for (let page = 1; page <= 20; page++) {
              const { data: comments } = await github.rest.issues.listComments({
                owner,
                repo,
                issue_number: prNumber,
                per_page: 100,
                page,
              });
              existing = comments.find((c) => c.body?.includes(marker));
              if (existing || comments.length < 100) break;
            }
            if (existing) {
              await github.rest.issues.updateComment({
                owner,
                repo,
                comment_id: existing.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner,
                repo,
                issue_number: prNumber,
                body,
              });
            }
