Skip to content

Deploy Site

Deploy Site #4801

Workflow file for this run

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,
});
}