← back to Open Seo
.github/workflows/pr-preview.yml
146 lines
name: PR Preview
# Alchemy preview stage per private-repo PR: deploy on open/update, destroy on
# close (stage pr-<n>). Full model in docs/PREVIEW_DEPLOYMENTS.md.
#
# Public-mirror (every-app/open-seo) PRs NEVER deploy from CI — fork code must
# not run with deploy secrets. Preview those locally from a worktree instead
# (docs/PREVIEW_DEPLOYMENTS.md, "Public-mirror PRs"). The repository gate
# below keeps the synced copy of this file inert on the mirror.
#
# Required repo secrets: CLOUDFLARE_API_TOKEN (Workers Scripts/KV/D1/R2/
# Workflows write + Secrets Store read + Account Settings read — the last two
# are how alchemy fetches its state-store token via an edge-preview worker),
# CLOUDFLARE_ACCOUNT_ID, ENV_PREVIEW (.env.preview contents).
on:
pull_request:
types: [opened, synchronize, reopened, closed]
# Skip PRs that can't change the deployed worker. Filters see the PR's
# full file list, so deploy and close-time destroy stay consistent.
paths-ignore:
- "**/*.md"
- docs/**
- runbooks/**
- .agents/**
- web/**
- badseo/**
# A newer push supersedes any in-flight deploy for the same PR (alchemy's
# per-resource state reconciles cleanly on the next run); a close-triggered
# destroy never cancels — it queues behind whatever is running.
concurrency:
group: pr-preview-${{ github.event.pull_request.number }}
cancel-in-progress: ${{ github.event.action != 'closed' }}
permissions:
contents: read
pull-requests: write
env:
STAGE: pr-${{ github.event.pull_request.number }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
jobs:
preview:
if: >-
github.repository == 'bensenescu/open-seo' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Write .env.preview
run: printf '%s' "$ENV_PREVIEW" > .env.preview
env:
ENV_PREVIEW: ${{ secrets.ENV_PREVIEW }}
# Derive the preview URL once, before spending a deploy on a broken
# secret. Destroy runs don't need it.
- name: Derive preview URL
if: github.event.action != 'closed'
run: |
subdomain=$(grep '^WORKERS_SUBDOMAIN=' .env.preview | cut -d= -f2-)
case "$subdomain" in
*.workers.dev) ;;
*) echo "::error::ENV_PREVIEW secret is missing a valid WORKERS_SUBDOMAIN (needed to derive and verify the preview URL)"; exit 1 ;;
esac
echo "PREVIEW_URL=https://open-seo-${STAGE}.${subdomain}" >> "$GITHUB_ENV"
# Same command as a local preview deploy: vite build, alchemy deploy.
# State lives in the account's Cloudflare state store; CI resolves its
# auth token from the Secrets Store each run. The Access gate protecting
# previews is one-time local setup (pnpm preview:access) — the verify
# step below fails the job if it's ever missing.
- name: Deploy preview stage
if: github.event.action != 'closed'
run: pnpm deploy:preview --stage "$STAGE" --yes
env:
NODE_OPTIONS: --max-old-space-size=4096
# Fail the job (and skip the URL comment) if the deployed preview
# answers without a Cloudflare Access login redirect. A definitive app
# response (2xx/3xx, no Access redirect) fails immediately — the
# preview is public; retries are only for propagation-era errors.
# (Cloudflare version preview URLs sit outside this wildcard, but alchemy
# uploads versions with no preview provisioned, so none are served — see
# docs/PREVIEW_DEPLOYMENTS.md.)
- name: Verify Access protection
if: github.event.action != 'closed'
run: |
for attempt in 1 2 3 4 5 6 7 8; do
response=$(curl -sS -o /dev/null -m 10 -w '%{http_code} %{redirect_url}' "$PREVIEW_URL") || response=""
code="${response%% *}"
location="${response#* }"
case "$location" in
https://*.cloudflareaccess.com/cdn-cgi/access/login*)
exit 0 ;;
esac
if [ -n "$code" ] && [ "$code" -ge 200 ] && [ "$code" -lt 500 ]; then
echo "::error::$PREVIEW_URL responded (HTTP $code) WITHOUT a Cloudflare Access challenge — the preview is public; destroy the stage (pnpm destroy:preview --stage $STAGE --yes)"
exit 1
fi
echo "attempt $attempt: not up yet (${response:-no response})"
sleep 5
done
echo "::error::$PREVIEW_URL is unreachable (likely workers.dev propagation) — it still sits behind the wildcard Access app; re-run this job or verify manually"
exit 1
- name: Destroy preview stage
if: github.event.action == 'closed'
run: pnpm destroy:preview --stage "$STAGE" --yes
# PREVIEW_URL comes from the derive step's $GITHUB_ENV write; this step
# only runs after verify succeeds (no `if:`, so default success() gating).
- name: Comment on PR
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
if [ "${{ github.event.action }}" = "closed" ]; then
body=$(printf '**Preview destroyed** (stage `%s`).' "$STAGE")
else
body=$(printf '**Preview deployed** (stage `%s`): %s\n_Updated for %s. Torn down automatically when this PR closes._' \
"$STAGE" "$PREVIEW_URL" "${{ github.event.pull_request.head.sha }}")
fi
gh pr comment "$PR" --repo "${{ github.repository }}" \
--body "$body" --edit-last --create-if-none