Skip to content

PR Preview Deploy

PR Preview Deploy #959

# Deploys the example preview built by "PR Preview Build" to Cloudflare Pages and
# posts (or updates) a sticky comment on the PR with a browsable URL.
#
# This runs on `workflow_run` — i.e. in the TRUSTED context of the base repo's
# default branch — which is the only context where fork-PR previews can safely
# use secrets. It deploys the PRE-BUILT artifact bytes and NEVER checks out or
# runs the PR's code.
#
# Requires two repository secrets (one-time setup — see .github/PR_PREVIEW.md):
# CLOUDFLARE_API_TOKEN scope: Account > Cloudflare Pages > Edit
# CLOUDFLARE_ACCOUNT_ID
# Until they are set, the job logs a warning and exits without deploying, so it
# is safe to merge before the secrets exist.
#
# The preview is served at a stable per-PR alias:
# https://pr-<number>.maidr-preview.pages.dev
name: PR Preview Deploy
on:
workflow_run:
workflows: ["PR Preview Build"]
types: [completed]
# One in-flight deploy per source branch; newer commits supersede older deploys.
concurrency:
group: pr-preview-deploy-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
permissions:
actions: read # download the triggering run's artifacts (cross-run)
pull-requests: write # create/update the sticky preview comment
jobs:
deploy:
name: Deploy to Cloudflare Pages
runs-on: ubuntu-latest
timeout-minutes: 15
# Only act on preview builds that actually succeeded.
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
# Detect whether the Cloudflare secrets are configured. Secrets can't be
# used directly in `if:`, so we surface a boolean output instead. When
# unset (e.g. before first-time setup) every later step no-ops.
- name: Check Cloudflare configuration
id: cfg
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
if [ -n "$CF_API_TOKEN" ] && [ -n "$CF_ACCOUNT_ID" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::warning::Cloudflare secrets (CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID) are not set — skipping PR preview deploy. See .github/PR_PREVIEW.md for setup."
fi
- name: Download preview site
if: success() && steps.cfg.outputs.ready == 'true'
uses: actions/download-artifact@v4
with:
name: preview-site
path: _site
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Download PR metadata
if: success() && steps.cfg.outputs.ready == 'true'
uses: actions/download-artifact@v4
with:
name: pr-meta
path: pr-meta
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
# The pr-meta artifact came from an UNTRUSTED PR build — and a fork PR can
# even rewrite pr-preview-build.yml, because `pull_request` runs the
# workflow from the PR head. So the PR number it claims is only a
# candidate: validate it is strictly numeric AND verify it belongs to the
# commit/repo that produced this run, so a forged number can't redirect
# the deploy or the sticky comment at a different (victim) PR.
- name: Resolve and verify PR number
if: success() && steps.cfg.outputs.ready == 'true'
id: pr
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const raw = fs.readFileSync('pr-meta/pr-number', 'utf8').trim();
if (!/^[0-9]+$/.test(raw)) {
throw new Error(`Non-numeric PR number in artifact: ${JSON.stringify(raw)}`);
}
const prNumber = Number(raw);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error(`Invalid PR number: ${raw}`);
}
const { owner, repo } = context.repo;
const run = context.payload.workflow_run;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
// head_sha is set by GitHub for the triggering run and cannot be
// forged to match an unrelated PR's head commit.
if (pr.head.sha !== run.head_sha) {
throw new Error(`PR #${prNumber} head (${pr.head.sha}) does not match the run head (${run.head_sha}); refusing to deploy.`);
}
const claimedRepo = pr.head.repo && pr.head.repo.full_name;
const runRepo = run.head_repository && run.head_repository.full_name;
if (claimedRepo && runRepo && claimedRepo !== runRepo) {
throw new Error(`PR #${prNumber} head repo (${claimedRepo}) does not match the run head repo (${runRepo}); refusing to deploy.`);
}
core.setOutput('number', String(prNumber));
# Previews must be purely static. The built _site is untrusted (a fork PR
# produced it), so strip the files Cloudflare Pages treats specially —
# otherwise a malicious build could run server-side code (_worker.js /
# functions/) or set arbitrary response headers (_headers / _redirects)
# on the preview origin.
- name: Enforce static-only preview
if: success() && steps.cfg.outputs.ready == 'true'
run: |
rm -rf _site/_worker.js _site/_worker.js.map _site/functions _site/_headers _site/_redirects
# Idempotent: pins production-branch=main so every pr-* deploy is a preview.
# No-ops (errors) once the project exists, hence continue-on-error.
- name: Ensure Cloudflare Pages project exists
if: success() && steps.cfg.outputs.ready == 'true'
continue-on-error: true
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages project create maidr-preview --production-branch=main
- name: Deploy to Cloudflare Pages
if: success() && steps.cfg.outputs.ready == 'true'
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy _site --project-name=maidr-preview --branch=pr-${{ steps.pr.outputs.number }} --commit-dirty=true
- name: Post preview URL to the PR
if: success() && steps.cfg.outputs.ready == 'true'
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.pr.outputs.number }}
ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }}
DEPLOY_URL: ${{ steps.deploy.outputs.deployment-url }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
with:
script: |
const prNumber = Number(process.env.PR_NUMBER);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error(`Invalid PR number: ${process.env.PR_NUMBER}`);
}
// Prefer the stable per-PR alias; fall back to the immutable deploy URL.
const url = (process.env.ALIAS_URL || process.env.DEPLOY_URL || '').trim();
if (!url) {
throw new Error('No deployment URL was returned by wrangler-action');
}
const sha = (process.env.HEAD_SHA || '').slice(0, 7);
const marker = '<!-- maidr-pr-preview -->';
const body = [
marker,
'### 🔎 MAIDR example preview is ready',
'',
`**Open the preview:** ${url}`,
'',
`- Example gallery: ${url}/examples.html`,
`- Or open any example directly, e.g. ${url}/examples/heatmap.html`,
'',
`Built from \`${sha}\`. This preview URL is stable and updates automatically on every new commit to the PR.`,
].join('\n');
const { owner, repo } = context.repo;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
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 });
}
# Best-effort: if the deploy failed after we knew the PR number, flip the
# sticky comment to a failure notice so reviewers aren't left waiting.
- name: Note preview failure on the PR
if: failure() && steps.pr.outputs.number != ''
continue-on-error: true
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.pr.outputs.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const prNumber = Number(process.env.PR_NUMBER);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
return;
}
const marker = '<!-- maidr-pr-preview -->';
const body = [
marker,
'### ⚠️ MAIDR example preview failed to deploy',
'',
`The preview build succeeded but publishing to Cloudflare Pages failed. See the [workflow logs](${process.env.RUN_URL}) for details.`,
].join('\n');
const { owner, repo } = context.repo;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
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 });
}