Deploy Site #2346
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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-latest | |
| 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@v6.0.2 | |
| - uses: actions/setup-node@v6 | |
| 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@v4 | |
| 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@v8 | |
| 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@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@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@v8 | |
| 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, | |
| }); | |
| } |