chore(deploy/site): retire the migration scaffolding, keep the health check

The Vercel -> Cloudflare Pages move is done and the Vercel project is
deleted, so the one-shot scripts have no remaining purpose. Nothing in CI
ever called them — deploy-site.yml runs npm ci / test / build / pages
deploy inline — so this removes 10 files and orphans nothing.

Deleted: 00-baseline (refused to run without x-vercel-id, so permanently
unrunnable), 01-preflight (proved Vercel state and API-token scopes),
07-dns-cutover (the phased cutover; in the end the records were attached
through the dashboard, and the rules/apex phases went unused once we chose
APEX_MODE=serve), 09-detach-vercel (its target project is gone), plus
03-ensure-project, 04-set-secrets, 05-deploy, 06-verify-deploy,
02-verify-local and 99-rollback, all either spent or duplicating CI. Their
lib/cf-api.sh went with them: the survivors use wrangler, so the whole
remaining path needs only `wrangler login` and no zone scopes.

What is kept is the part with ongoing value: lib/parity.sh, the assertion
set that caught five real defects during the migration — the live COOP/COEP
bug on the post's canonical URL, the soft-404 Pages would have introduced,
the cross-site form-POST guard Vercel had been providing for free, the
missing immutable header, and HSTS max-age=0. "Does the page return 200"
catches none of those.

08-verify-prod.sh becomes verify.sh, since the numbered sequence it
belonged to no longer exists. It drops the stamp machinery, the dry-run
plumbing and the Vercel-fallback messaging (there is no fallback now:
recovery is promoting a previous Pages deployment), and gains --skip-dns /
--skip-domains so it can be pointed at a single deployment via PROD_BASE
before promoting it.

The README is rewritten around the four invariants that fail SILENTLY —
never widen _headers to /*, keep both URL forms of the Gerber post, never
delete 404.astro, keep the cross-site form-POST guard — each with the
reason, since the reason is the only thing that stops someone simplifying
them back out.

Verified after: 21 probes, 20 pass, 1 warn (HSTS max-age is 6 months vs
Vercel's 2 years — on, just shorter), 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
This commit is contained in:
Viktor Vaczi 2026-07-27 15:08:56 +02:00
commit fdc4583288
20 changed files with 228 additions and 1631 deletions

View file

@ -19,7 +19,8 @@ name: deploy-site (marketing site)
# from site/functions/. Its secrets (RESEND_API_KEY, RESEND_SEGMENT_ID,
# WAITLIST_FROM_EMAIL) are NOT deploy inputs: set once with
# `wrangler pages secret put <NAME> --project-name pcbjam-site`.
# See deploy/site/README.md for the full runbook.
# deploy/site/README.md documents the setup, the invariants that fail silently,
# and deploy/site/verify.sh (a fuller health check than step 5 below).
#
# Secrets (Settings → Secrets → Actions) — already present for demo/editor:
# CLOUDFLARE_API_TOKEN Cloudflare Pages:Edit

View file

@ -39,4 +39,5 @@ from; because it pins the kicad + wxwidgets submodule revisions implicitly, it i
our GPLv3 corresponding-source pointer (see /licenses). It resolves automatically
at build time in site/src/components/Footer.astro (CF_PAGES_COMMIT_SHA / GITHUB_SHA
in CI, `git rev-parse` locally) — no manual bump needed.
The Cloudflare setup + cutover runbook is in pcbjam/deploy/site/README.md.
Cloudflare setup, the invariants that fail silently, and the health check
(deploy/site/verify.sh) are documented in pcbjam/deploy/site/README.md.

View file

@ -1,77 +0,0 @@
#!/usr/bin/env bash
# Freeze the LIVE Vercel behaviour as the reference every later step is compared
# against. Read-only; needs no Cloudflare credentials. Run this while Vercel is
# still serving — you cannot re-create it afterwards.
#
# deploy/site/00-baseline.sh [--force]
#
# Two probes are EXPECTED to fail here: post_coi (the blog post's COOP/COEP) is
# genuinely broken in production today — the page's canonical is the
# trailing-slash URL and that URL serves 200 with no isolation headers. Recording
# it is the point: 08-verify-prod.sh asserts those same probes PASS afterwards,
# so the migration proves it fixed the bug rather than porting it.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/parity.sh"
require_cmd curl dig awk sed jq
OUT="$STATE_DIR/baseline/vercel"
if [ -d "$OUT" ] && [ "${1:-}" != "--force" ]; then
die "baseline already exists at $OUT — refusing to overwrite (use --force).
Re-baselining after cutover would silently replace the reference."
fi
mkdir -p "$OUT"
section "confirming $PROD_BASE is still served by Vercel"
hdrs="$(_headers "$PROD_BASE/")"
if [ -z "$(_hdr "$hdrs" x-vercel-id)" ]; then
die "no x-vercel-id header on $PROD_BASE — this host is not on Vercel any more.
Baselining a Cloudflare response as 'the Vercel reference' would be useless."
fi
echo "ok: x-vercel-id present"
section "DNS + SOA snapshot"
{
echo "# captured $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "apex_cname=$(dig +short CNAME "$ZONE_NAME" || true)"
echo "www_cname=$(dig +short CNAME "www.$ZONE_NAME" || true)"
echo "ns=$(dig +short NS "$ZONE_NAME" | sort | tr '\n' ' ')"
# The SOA minimum is the NEGATIVE cache TTL. It is the number that makes
# delete-then-create dangerous: a resolver that asks while the record is gone
# caches NODATA for this long, and you cannot flush it.
echo "soa=$(dig +short SOA "$ZONE_NAME" || true)"
echo "soa_minimum_ttl=$(dig +short SOA "$ZONE_NAME" | awk '{print $NF}')"
} | tee "$OUT/dns.txt"
section "page titles (used to detect a soft-404 later)"
printf 'home_title=%s\n' "$(_title "$PROD_BASE/")" | tee "$OUT/titles.txt"
section "header snapshots"
for p in / /pricing /blog /privacy /blog/porting-kicad-graphics-to-webgl-in-2026 /gerber-demo/boot.js; do
f="$(printf '%s' "$p" | sed 's|/|_|g')"; [ "$f" = "_" ] && f="_home"
t="$(_trace "$PROD_BASE$p")"; eff="$(printf '%s' "$t" | cut -f1)"
{
echo "# requested: $PROD_BASE$p"
echo "# effective: $eff (hops $(printf '%s' "$t" | cut -f3))"
# Drop volatile headers so a later diff shows real changes, not timestamps.
_headers "$eff" | grep -vE '^(date|age|etag|last-modified|content-length|server|cf-ray|cf-cache-status|nel|report-to|alt-svc|set-cookie|x-vercel-id|x-vercel-cache|x-matched-path|expires|via):' | sort
} > "$OUT/$f.headers"
echo " $p -> $OUT/$f.headers"
done
rc=0
assert_parity "$PROD_BASE" --scope prod || rc=$?
assert_apex_redirect "$APEX_BASE" || rc=$?
{
echo "# baseline captured $(date -u +%Y-%m-%dT%H:%M:%SZ) against $PROD_BASE (Vercel)"
echo "# parity exit code: $rc (non-zero is EXPECTED — see the header of this script)"
} > "$OUT/parity-exit.txt"
section "done"
echo "Baseline written to $OUT"
echo
echo "Read the FAIL rows above and keep them: they are the 'before' half of the"
echo "COOP/COEP fix. 08-verify-prod.sh requires those same probes to pass."
echo "done: file://$OUT"

View file

@ -1,120 +0,0 @@
#!/usr/bin/env bash
# Prove every credential and token scope the later steps need, and snapshot DNS
# for rollback. Read-only — makes no changes anywhere.
#
# export CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=...
# deploy/site/01-preflight.sh
#
# Re-run this freely; it is the script to come back to after fixing a scope or
# logging wrangler in.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl dig jq awk node npm npx shasum
ok=0; bad=0
chk() { # chk "<label>" <0|1> ["remediation"]
if [ "$2" = 0 ]; then printf ' ok %s\n' "$1"; ok=$((ok+1))
else printf ' FAIL %s\n' "$1"; [ -n "${3:-}" ] && printf ' -> %s\n' "$3"; bad=$((bad+1)); fi
}
section "toolchain"
nv="$(node -v | sed 's/^v//')"
node_ok=1
# Astro 6 needs >= 22.12.0
maj="${nv%%.*}"; rest="${nv#*.}"; min="${rest%%.*}"
if [ "$maj" -gt 22 ] 2>/dev/null || { [ "$maj" = 22 ] && [ "$min" -ge 12 ]; } 2>/dev/null; then node_ok=0; fi
chk "node $nv >= 22.12 (Astro 6)" $node_ok "install Node 22.12+ (nvm use 22)"
chk "wrangler reachable ($($WRANGLER --version 2>/dev/null | head -1))" \
"$($WRANGLER --version >/dev/null 2>&1; echo $?)"
section "cloudflare auth"
if [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then
chk "CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID exported" 0
tv="$(cf_api GET /user/tokens/verify 2>/dev/null || true)"
st="$(printf '%s' "$tv" | jq -r '.result.status // "unknown"' 2>/dev/null || echo unknown)"
chk "token status = active (got '$st')" "$([ "$st" = active ] && echo 0 || echo 1)"
else
chk "CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID exported" 1 \
"the API steps (03,07,09) need both. Create a token with the scopes listed in lib/cf-api.sh"
wo="$($WRANGLER whoami 2>&1 || true)"
case "$wo" in
*"Not logged in"*|*"could not be refreshed"*)
chk "wrangler logged in" 1 "run: $WRANGLER login (the local token is expired)" ;;
*) chk "wrangler logged in" 0 ;;
esac
fi
if [ -n "${CLOUDFLARE_API_TOKEN:-}" ]; then
section "zone"
zid="$(cf_zone_id 2>/dev/null || true)"
chk "zone $ZONE_NAME resolved (${zid:-none})" "$([ -n "$zid" ] && echo 0 || echo 1)"
if [ -n "$zid" ]; then
section "token scopes (each probe maps to one permission)"
cf_api GET "/zones/$zid/dns_records?per_page=5" >/dev/null 2>&1
chk "Zone -> DNS -> Read/Edit" $?
# 404 here is fine and expected — it means the redirect phase has no ruleset
# yet. 403 is the failure we are probing for.
rs="$($CURL_BIN -sS "$CF_API/zones/$zid/rulesets/phases/http_request_dynamic_redirect/entrypoint" \
-H "Authorization: Bearer $(cf_token)" || true)"
code="$(printf '%s' "$rs" | jq -r '.errors[0].code // "ok"' 2>/dev/null)"
chk "Zone -> Dynamic Redirect -> Edit (probe code=$code)" \
"$([ "$code" = "ok" ] || [ "$code" = "10000" ] || printf '%s' "$rs" | jq -e '.success==false and (.errors[0].code==1002 or .errors[0].code==10000)' >/dev/null 2>&1; echo $?)" \
"add 'Dynamic Redirect: Edit' (sometimes shown as Config/Transform Rules) to the token"
cf_api GET "/zones/$zid/settings/security_header" >/dev/null 2>&1
chk "Zone -> Zone Settings -> Edit (HSTS)" $?
cf_api GET "/accounts/$(cf_account)/pages/projects" >/dev/null 2>&1
chk "Account -> Cloudflare Pages -> Edit" $?
section "free-plan redirect headroom"
nrules="$(printf '%s' "$rs" | jq '.result.rules | length' 2>/dev/null || echo 0)"
[ "$nrules" = "null" ] && nrules=0
chk "existing dynamic redirects: $nrules (< 10 on the free plan)" \
"$([ "$nrules" -lt 10 ] && echo 0 || echo 1)"
section "DNS snapshot -> state/dns-before.json (rollback source of truth)"
cf_dns_list > "$STATE_DIR/state/dns-before.json"
for spec in "$ZONE_NAME:CNAME" "www.$ZONE_NAME:CNAME"; do
n="${spec%%:*}"; t="${spec##*:}"
c="$(jq -r --arg n "$n" --arg t "$t" '.result[]|select(.name==$n and .type==$t)|.content' \
"$STATE_DIR/state/dns-before.json" | head -1)"
chk "$n $t -> ${c:-MISSING} (expect $VERCEL_DNS_TARGET)" \
"$([ "$c" = "$VERCEL_DNS_TARGET" ] && echo 0 || echo 1)" \
"the record moved since this migration was planned — re-read the plan before continuing"
done
mx="$(jq -r '.result[]|select(.type=="MX")|.content' "$STATE_DIR/state/dns-before.json" | head -1)"
chk "Google MX still present ($mx) — must survive untouched" \
"$([ -n "$mx" ] && echo 0 || echo 1)"
fi
fi
section "nameservers"
ns="$(dig +short NS "$ZONE_NAME" | tr '\n' ' ')"
case "$ns" in *becky*|*ernest*) chk "on Cloudflare NS ($ns)" 0 ;;
*) chk "on Cloudflare NS ($ns)" 1 "the zone must be on Cloudflare nameservers" ;;
esac
section "vercel (env-var names for 04-set-secrets; detach/rollback for 09/99)"
# Deliberately via npx rather than a global `vercel` binary — that is how this
# repo invokes it everywhere else, and a global install is not a prerequisite.
if [ "${SKIP_VERCEL_CHECK:-0}" != 1 ]; then
names="$(npx --yes vercel@latest env ls production --project "$VERCEL_PROJECT" --scope "$VERCEL_TEAM" 2>/dev/null \
| awk '/^ [A-Z]/{print $1}' | sort | tr '\n' ' ' || true)"
echo " env var NAMES on Vercel (values never read): ${names:-<unavailable>}"
[ -n "$names" ] && printf '%s\n' "$names" > "$STATE_DIR/state/vercel-env-names.txt"
for want in RESEND_API_KEY RESEND_SEGMENT_ID WAITLIST_FROM_EMAIL; do
case " $names " in *" $want "*) chk "$want present on Vercel" 0 ;;
*) chk "$want present on Vercel" 1 "expected it there; 04-set-secrets.sh needs its value" ;;
esac
done
else
warn "SKIP_VERCEL_CHECK=1 — skipping the Vercel probe"
fi
section "summary"
echo " $ok ok, $bad failed"
[ "$bad" -eq 0 ] || die "preflight failed — fix the FAIL rows above before continuing."
stamp_write 01-preflight "zone=${zid:-unknown}"
echo "done: preflight ok (zone=${zid:-unknown})"

View file

@ -1,121 +0,0 @@
#!/usr/bin/env bash
# Build the site and run it the way Cloudflare Pages will, then sweep it. This is
# the gate: 05-deploy.sh refuses to deploy a tree that has not passed here.
#
# deploy/site/02-verify-local.sh [--port 8788] [--skip-build] [--keep-running]
#
# Writes a TEMPORARY .dev.vars containing a deliberately BOGUS RESEND_API_KEY.
# That is on purpose: a valid-email POST must then come back 502 send_failed,
# which proves the `resend` SDK actually resolved and issued a request under the
# Pages runtime. A module-resolution error instead means nodejs_compat isn't
# taking effect (it is declared in site/wrangler.toml).
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/parity.sh"
require_cmd curl jq awk sed node npm npx
PORT=8788; SKIP_BUILD=0; KEEP=0
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
--keep-running) KEEP=1; shift ;;
*) die "unknown arg: $1" ;;
esac
done
cd "$SITE_DIR"
section "structural checks (before spending time on a build)"
[ -f functions/api/waitlist.ts ] || die "functions/api/waitlist.ts is missing — the waitlist endpoint would 404"
grep -q 'adapter:' astro.config.mjs && die "astro.config.mjs still configures an adapter; this deploy is pure static"
grep -q 'astro:env/server' functions/api/waitlist.ts && \
die "functions/api/waitlist.ts imports astro:env/server, which does not exist in a Pages Function"
for n in RESEND_API_KEY RESEND_SEGMENT_ID WAITLIST_FROM_EMAIL WAITLIST_ALLOWED_ORIGINS; do
grep -q "$n" functions/api/waitlist.ts || die "functions/api/waitlist.ts never reads $n"
done
echo "ok: no adapter, Function present, reads all four env names"
if [ "$SKIP_BUILD" = 0 ]; then
section "npm ci + test + build"
npm ci
npm test
npm run build
fi
section "build output"
for f in dist/404.html dist/_headers dist/_routes.json dist/index.html; do
[ -e "$f" ] || die "$f missing after build"
echo " ok $f"
done
[ -d dist/server ] && die "dist/server exists — an adapter crept back in"
[ -d .vercel ] && die ".vercel/ was produced — the Vercel adapter is still wired up"
# A /* rule would isolate the landing page and break the YouTube hero embed.
if grep -qE '^/\*[[:space:]]*$' dist/_headers; then
die "_headers contains a bare /* rule — that would apply COOP/COEP site-wide and break the YouTube embed on /"
fi
grep -q 'porting-kicad-graphics-to-webgl-in-2026/\*' dist/_headers \
|| die "_headers has no trailing-slash rule for the blog post; COOP/COEP would land on the redirect, not the document"
echo "ok: _headers scoped correctly, 404.html present"
section "starting wrangler pages dev on :$PORT"
if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
die "port $PORT is already in use — stop the other process or pass --port"
fi
DEV_VARS_CREATED=0
if [ -f .dev.vars ]; then
warn ".dev.vars already exists; leaving it alone (the 502 probe may not apply)"
else
printf 'RESEND_API_KEY=re_bogus_key_for_local_verification_only\nRESEND_SEGMENT_ID=seg_local\nWAITLIST_FROM_EMAIL=PCBJam <hello@pcbjam.com>\n' > .dev.vars
DEV_VARS_CREATED=1
fi
LOG="$STATE_DIR/logs/pages-dev-$(date +%s).log"
$WRANGLER pages dev --port "$PORT" --ip 127.0.0.1 > "$LOG" 2>&1 &
DEV_PID=$!
cleanup() {
[ "$KEEP" = 1 ] && { echo; echo "left running: http://127.0.0.1:$PORT (pid $DEV_PID)"; return; }
kill "$DEV_PID" 2>/dev/null || true
wait "$DEV_PID" 2>/dev/null || true
[ "$DEV_VARS_CREATED" = 1 ] && rm -f "$SITE_DIR/.dev.vars"
return 0
}
trap cleanup EXIT INT TERM
for _ in $(seq 1 40); do
curl -sf "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break
sleep 0.5
done
curl -sf "http://127.0.0.1:$PORT/" >/dev/null 2>&1 \
|| die "pages dev never became ready — see $LOG"
grep -q 'valid header rules' "$LOG" && echo "ok: $(grep -o '[0-9]* valid header rules' "$LOG" | head -1) parsed"
rc=0
assert_parity "http://127.0.0.1:$PORT" --scope local --live-post || rc=$?
section "resend SDK loads under the Pages runtime"
# With the bogus key in .dev.vars this MUST be 502 send_failed. 200 would mean the
# key never reached the Function; a 500 would mean the module failed to resolve.
code="$(curl -sS -o "$STATE_DIR/state/live-post.json" -w '%{http_code}' \
-X POST "http://127.0.0.1:$PORT/api/waitlist" \
-H 'content-type: application/json' \
--data '{"email":"sdk-probe@example.com","source":"cfm-verify"}' 2>/dev/null || true)"
body="$(cat "$STATE_DIR/state/live-post.json" 2>/dev/null || true)"
if [ "$DEV_VARS_CREATED" = 1 ]; then
case "$code:$body" in
502:*send_failed*) echo "ok: 502 send_failed — SDK resolved and called Resend" ;;
200:*) echo "FAIL: 200 — the bogus RESEND_API_KEY never reached the Function"; rc=1 ;;
*) echo "FAIL: status=$code body=$body (expected 502 send_failed; a module error means nodejs_compat isn't applying)"; rc=1 ;;
esac
else
echo "skipped (pre-existing .dev.vars): status=$code"
fi
[ "$rc" -eq 0 ] || die "local verification FAILED — do not deploy. Logs: $LOG"
stamp_write 02-local-parity "port=$PORT"
section "done"
echo "done: local parity ok (ctx=$(ctx_hash)) — 05-deploy.sh will accept this tree"

View file

@ -1,75 +0,0 @@
#!/usr/bin/env bash
# Idempotently ensure the Cloudflare Pages project exists with the right
# production branch and compatibility settings.
#
# deploy/site/03-ensure-project.sh # dry run
# deploy/site/03-ensure-project.sh --apply
#
# Naming this "ensure" rather than "create" is the contract: running it twice is
# normal and must not look like an error.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl jq npx
parse_common_flags "$@"
dry_banner
section "project $PAGES_PROJECT"
row="$($WRANGLER pages project list --json 2>/dev/null \
| jq -r --arg n "$PAGES_PROJECT" '.[] | select(."Project Name"==$n)')"
if [ -n "$row" ]; then
echo "exists already"
else
dry "create Pages project $PAGES_PROJECT (production branch: $PAGES_PROD_BRANCH)" -- \
$WRANGLER pages project create "$PAGES_PROJECT" \
--production-branch "$PAGES_PROD_BRANCH" \
--compatibility-date 2026-06-01 --compatibility-flags nodejs_compat
[ "$DRY_RUN" = 1 ] && { echo; echo "done: (dry run) would create $PAGES_PROJECT"; exit 0; }
row="$($WRANGLER pages project list --json 2>/dev/null \
| jq -r --arg n "$PAGES_PROJECT" '.[] | select(."Project Name"==$n)')"
[ -n "$row" ] || die "project still not listed after create"
fi
section "asserting settings"
# `wrangler pages project list` reports the Git provider but not the production
# branch, and the REST endpoint that would needs an API token. So prove the branch
# EMPIRICALLY instead, from the deployments: if a deployment on $PAGES_PROD_BRANCH
# is labelled Production, the project's production branch is that branch. That is
# a stronger check than reading the setting.
src="$(printf '%s' "$row" | jq -r '."Git Provider" // "?"')"
dom="$(printf '%s' "$row" | jq -r '."Project Domains" // "?"')"
if [ "$src" != "No" ]; then
die "project appears Git-connected (Git Provider: $src). Cloudflare's own builds
would race the uploads from deploy-site.yml. Disconnect it in the dashboard."
fi
echo " ok Direct Upload (not Git-connected)"
prod="$($WRANGLER pages deployment list --project-name "$PAGES_PROJECT" \
--environment production --json 2>/dev/null || true)"
nprod="$(printf '%s' "$prod" | jq 'length' 2>/dev/null || echo 0)"
if [ "${nprod:-0}" -gt 0 ]; then
br="$(printf '%s' "$prod" | jq -r '.[0].Branch // "?"')"
env="$(printf '%s' "$prod" | jq -r '.[0].Environment // "?"')"
# The most expensive mistake available here: if the project's production branch
# is not what deploy-site.yml passes to --branch, every deploy lands as a
# PREVIEW and the live site silently never updates (see deploy-demo.yml).
if [ "$br" = "$PAGES_PROD_BRANCH" ] && [ "$env" = "Production" ]; then
echo " ok branch '$br' deploys land as $env"
else
die "latest production-environment deployment is branch='$br' env='$env',
but deploys use --branch '$PAGES_PROD_BRANCH'. If those disagree, every deploy
becomes a preview and www.pcbjam.com never updates. Fix it in
Pages -> $PAGES_PROJECT -> Settings -> Builds & deployments."
fi
else
warn "no production deployment yet — the branch check runs after 05-deploy.sh"
fi
sub="$(printf '%s' "$dom" | sed 's/,.*//')"
section "done"
echo "note: the custom domain is NOT set here — there is no 'wrangler pages domain'"
echo " subcommand. 07-dns-cutover.sh attaches www.pcbjam.com via the API,"
echo " which needs a real API token (wrangler's OAuth login is zone:read only)."
echo "done: https://${sub}"

View file

@ -1,104 +0,0 @@
#!/usr/bin/env bash
# Put the three waitlist secrets into the Pages project, for BOTH the production
# and preview environments, without ever printing a value.
#
# deploy/site/04-set-secrets.sh --prompt --apply
# deploy/site/04-set-secrets.sh --from-env-file site/.migration-secrets.env --apply
#
# Getting the values: pull them yourself, e.g.
# cd site && npx vercel env pull .migration-secrets.env \
# --environment=production --project pcbjam --scope pcbj-am
# .migration-secrets.env is gitignored. Delete it when you're done.
#
# Pages applies environment-variable changes to NEW deployments only, so this must
# run BEFORE 05-deploy.sh (or you must redeploy afterwards).
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
no_xtrace # never run with tracing: values would be echoed
require_cmd npx shasum awk
NAMES="RESEND_API_KEY RESEND_SEGMENT_ID WAITLIST_FROM_EMAIL"
MODE=prompt; ENV_FILE=""
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--prompt) MODE=prompt; shift ;;
--from-env-file) MODE=file; ENV_FILE="$2"; shift 2 ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
dry_banner
# WAITLIST_ALLOWED_ORIGINS is deliberately NOT settable here. It must stay unset
# so the Function keeps its in-code default (https://demo.pcbjam.com), matching
# what Vercel had. Setting it as a Pages secret would work but would hide the
# allowlist from code review.
guard_name() {
case "$1" in
WAITLIST_ALLOWED_ORIGINS)
die "refusing to set WAITLIST_ALLOWED_ORIGINS: it is intentionally unset so
the allowlist lives in functions/api/waitlist.ts where it is reviewable." ;;
esac
grep -q "$1" "$SITE_DIR/functions/api/waitlist.ts" \
|| die "refusing to set $1: functions/api/waitlist.ts never reads it"
}
read_value() { # read_value NAME -> echoes the value (never logged)
_n="$1"
if [ "$MODE" = file ]; then
[ -f "$ENV_FILE" ] || die "env file not found: $ENV_FILE"
( cd "$(dirname "$ENV_FILE")" && git check-ignore -q "$(basename "$ENV_FILE")" ) \
|| die "$ENV_FILE is NOT gitignored — refusing to read secrets from a trackable file"
awk -F= -v k="$_n" '$1==k { sub(/^[^=]*=/,""); gsub(/^"|"$/,""); print; exit }' "$ENV_FILE"
else
printf 'value for %s (input hidden, empty to skip): ' "$_n" >&2
read -r -s _v; echo >&2
printf '%s' "$_v"
fi
}
section "setting secrets on $PAGES_PROJECT"
set_count=0
for n in $NAMES; do
guard_name "$n"
v="$(read_value "$n")"
if [ -z "$v" ]; then warn "$n: empty, skipped"; continue; fi
# Length + short hash only. Enough to compare against Vercel without ever
# revealing the value.
fp="$(printf '%s' "$v" | shasum -a 256 | cut -c1-8)"
echo " $n: len=$(printf '%s' "$v" | wc -c | tr -d ' ') sha256=$fp"
for envname in production preview; do
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: pages secret put $n --project-name $PAGES_PROJECT --env $envname (value via stdin)"
else
# stdin, never argv — an argv value would be visible in `ps`.
printf '%s' "$v" | $WRANGLER pages secret put "$n" \
--project-name "$PAGES_PROJECT" --env "$envname" >/dev/null \
|| die "failed to set $n ($envname)"
echo " set: $n ($envname)"
fi
done
set_count=$((set_count+1))
unset v
done
if [ "$DRY_RUN" = 0 ]; then
section "verifying (names only)"
for envname in production preview; do
have="$($WRANGLER pages secret list --project-name "$PAGES_PROJECT" --env "$envname" 2>/dev/null || true)"
for n in $NAMES; do
case "$have" in *"$n"*) echo " ok $n ($envname)" ;;
*) die "$n missing from $envname after setting it" ;;
esac
done
done
stamp_write 04-secrets "count=$set_count"
fi
section "done"
echo "Pages applies env changes to NEW deployments only — run 05-deploy.sh next."
echo "If you used --from-env-file, delete it now: rm -f $ENV_FILE"
echo "done: $set_count secrets set (production + preview)"

View file

@ -1,71 +0,0 @@
#!/usr/bin/env bash
# Build and upload to Cloudflare Pages. Touches no DNS — after this the site is
# live only on *.pages.dev, which is what makes the whole migration safe to
# rehearse.
#
# deploy/site/05-deploy.sh --preview # dry run
# deploy/site/05-deploy.sh --preview --apply
# deploy/site/05-deploy.sh --production --apply
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
require_cmd npx jq
TARGET=""; SKIP_BUILD=0
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--preview) TARGET=preview; shift ;;
--production) TARGET=production; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
[ -n "$TARGET" ] || die "pass --preview or --production"
dry_banner
# The gate. The stamp is keyed on a hash of src/, public/, functions/ and the
# configs, so it cannot vouch for a tree that has been edited since.
stamp_require 02-local-parity
BRANCH="$PAGES_PROD_BRANCH"
[ "$TARGET" = preview ] && BRANCH="cf-migrate-preview"
cd "$SITE_DIR"
if [ "$SKIP_BUILD" = 0 ]; then
section "build"
npm run build
fi
[ -e dist/404.html ] || die "dist/404.html missing — deploying would create a soft-404 (homepage at 200 on every unknown URL)"
[ -e dist/_headers ] || die "dist/_headers missing — the Gerber viewer would lose cross-origin isolation"
[ -f functions/api/waitlist.ts ] || die "functions/api/waitlist.ts missing"
section "deploy ($TARGET, branch=$BRANCH)"
# Run from $SITE_DIR so wrangler reads site/wrangler.toml (pages_build_output_dir
# + nodejs_compat) AND discovers site/functions/. Deploying from the repo root
# would upload the static files and silently omit the Function.
LOG="$STATE_DIR/logs/deploy-$(date +%s).log"
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: (cd $SITE_DIR && $WRANGLER pages deploy --project-name $PAGES_PROJECT --branch $BRANCH --commit-dirty=true)"
echo; echo "done: (dry run) nothing deployed"
exit 0
fi
$WRANGLER pages deploy \
--project-name "$PAGES_PROJECT" \
--branch "$BRANCH" \
--commit-dirty=true 2>&1 | tee "$LOG"
URL="$(grep -Eo 'https://[a-z0-9-]+\.'"$PAGES_PROJECT"'\.pages\.dev' "$LOG" | tail -1 || true)"
[ -n "$URL" ] || URL="$(grep -Eo 'https://[^ ]*\.pages\.dev' "$LOG" | tail -1 || true)"
[ -n "$URL" ] || die "could not determine the deployment URL — see $LOG"
printf '{"url":"%s","branch":"%s","target":"%s","ctx":"%s"}\n' \
"$URL" "$BRANCH" "$TARGET" "$(ctx_hash)" > "$STATE_DIR/state/last-deploy.json"
section "done"
echo "Verify it before any DNS moves:"
echo " deploy/site/06-verify-deploy.sh --url $URL$([ "$TARGET" = production ] && echo ' --scope prod-deploy')"
echo "done: $URL"

View file

@ -1,99 +0,0 @@
#!/usr/bin/env bash
# Sweep a Pages deployment on its *.pages.dev URL — the real Cloudflare runtime,
# before any DNS is touched. One script for both the preview and the production
# deployment; only the stamp it writes differs.
#
# deploy/site/06-verify-deploy.sh --latest
# deploy/site/06-verify-deploy.sh --url https://abc123.pcbjam-site.pages.dev --scope prod-deploy
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/parity.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl jq awk sed
URL=""; SCOPE=preview
while [ $# -gt 0 ]; do
case "$1" in
--url) URL="$2"; shift 2 ;;
--latest) URL=""; shift ;;
--scope) SCOPE="$2"; shift 2 ;;
*) die "unknown arg: $1" ;;
esac
done
if [ -z "$URL" ] && [ -s "$STATE_DIR/state/last-deploy.json" ]; then
URL="$(jq -r .url "$STATE_DIR/state/last-deploy.json")"
fi
[ -n "$URL" ] || die "no deployment URL — pass --url, or run 05-deploy.sh first"
section "target"
echo " $URL (scope=$SCOPE)"
rc=0
assert_parity "$URL" --scope preview || rc=$?
section "Pages-specific checks"
# If the Function were not bundled (e.g. deployed from the wrong directory) the
# whole endpoint would simply be a static 404. The parity sweep would catch it,
# but say so explicitly — it is the most likely deploy-time mistake.
st="$(curl -sS -o /dev/null -w '%{http_code}' -X OPTIONS "$URL/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null || true)"
if [ "$st" = "404" ]; then
echo "FAIL /api/waitlist is 404 — the Function was not bundled."
echo " wrangler discovers functions/ relative to \$PWD; deploy from site/."
rc=1
else
echo "PASS /api/waitlist is routed (status $st)"
fi
# _routes.json restricts the Function to /api/*. Confirm that did not also break
# static 404 handling for everything else.
nf="$(curl -sS -L -o /dev/null -w '%{http_code}' "$URL/__cfm-parity-404__/" 2>/dev/null || true)"
[ "$nf" = "404" ] && echo "PASS static 404 handling intact under _routes.json" \
|| { echo "FAIL unknown path returned $nf, expected 404"; rc=1; }
if [ "$SCOPE" = prod-deploy ]; then
section "confirming this is the PRODUCTION deployment"
# Via wrangler, not the REST API: `wrangler login` is enough for this, whereas
# the REST call would need an API token that the Pages steps otherwise don't.
latest="$($WRANGLER pages deployment list --project-name "$PAGES_PROJECT" \
--environment production --json 2>/dev/null || true)"
envname="$(printf '%s' "$latest" | jq -r '.[0].Environment // "?"' 2>/dev/null || echo '?')"
did="$(printf '%s' "$latest" | jq -r '.[0].Id // "?"' 2>/dev/null || echo '?')"
brn="$(printf '%s' "$latest" | jq -r '.[0].Branch // "?"' 2>/dev/null || echo '?')"
if [ "$envname" != "Production" ]; then
echo "FAIL latest deployment environment is '$envname', not 'Production'"
echo " (a branch name other than $PAGES_PROD_BRANCH makes it a preview)"
rc=1
else
echo "PASS latest deployment is Production (branch=$brn id=$did)"
fi
fi
section "diff vs the Vercel baseline (informational)"
BASE="$STATE_DIR/baseline/vercel"
if [ -d "$BASE" ]; then
OUT="$STATE_DIR/baseline/pages-$(date +%s)"; mkdir -p "$OUT"
for p in / /pricing /blog /privacy /blog/porting-kicad-graphics-to-webgl-in-2026 /gerber-demo/boot.js; do
f="$(printf '%s' "$p" | sed 's|/|_|g')"; [ "$f" = "_" ] && f="_home"
eff="$(_trace "$URL$p" | cut -f1)"
_headers "$eff" | grep -vE '^(date|age|etag|last-modified|content-length|server|cf-ray|cf-cache-status|nel|report-to|alt-svc|set-cookie|x-vercel-id|x-vercel-cache|x-matched-path|expires|via):' | sort > "$OUT/$f.headers"
if [ -f "$BASE/$f.headers" ]; then
d="$(diff -u "$BASE/$f.headers" "$OUT/$f.headers" || true)"
[ -n "$d" ] && { echo "--- $p"; printf '%s\n' "$d" | sed -n '3,$p'; }
fi
done
echo "(header differences above are for human review, not assertions)"
else
warn "no baseline at $BASE — run 00-baseline.sh while Vercel is still live"
fi
[ "$rc" -eq 0 ] || die "deployment verification FAILED — do not cut DNS."
case "$SCOPE" in
prod-deploy) stamp_write 06-prod-deploy-parity "url=$URL" "deployment_id=${did:-unknown}" ;;
*) stamp_write 06-preview-parity "url=$URL" ;;
esac
section "done"
echo "done: verified $URL"

View file

@ -1,347 +0,0 @@
#!/usr/bin/env bash
# Move www.pcbjam.com from Vercel to the Pages project, and point the apex at a
# 308 redirect to www. THE ONLY SCRIPT HERE THAT AFFECTS LIVE TRAFFIC.
#
# deploy/site/07-dns-cutover.sh --phase probe # dry run (default)
# deploy/site/07-dns-cutover.sh --phase probe --apply
# ...
# deploy/site/07-dns-cutover.sh --phase swap --apply
# deploy/site/07-dns-cutover.sh --rollback --apply
#
# Phases, in the order they should be run. Only `swap` and `apex` are live:
#
# prelower T-24h drop the www TTL to 60 so the cut AND any rollback
# propagate in ~1 min instead of ~5.
# probe T-days create a throwaway hostname, attach it as a Pages custom
# domain, delete both. Settles — at zero risk to www —
# whether Pages will attach over an existing CNAME, and
# measures how long validation takes.
# rules T-1d create the apex->www redirect rule. INERT until the apex is
# proxied, so it is safe to create early and verify later.
# hsts T-1d enable zone HSTS at Vercel's existing max-age. Additive.
# swap T+0 www -> Pages. See "the window" below.
# apex T+2m apex -> proxied placeholder, which activates the rule.
#
# THE WINDOW. The swap is an in-place PATCH, not a delete-then-create, and that
# distinction is the single most important safety property here. A PATCH changes
# content and proxied status atomically, so there is NO DNS gap. Delete-then-
# create leaves the name with no record for a second or two, and any resolver
# that asks in that instant caches NODATA for the zone's SOA minimum (typically
# 1800s) — an un-flushable ~30-minute partial outage. 00-baseline.sh records the
# actual SOA minimum so you can see the exposure. The residual risk with PATCH is
# HTTP-only and self-healing: for a second or two the edge has no route for the
# host and serves the Pages not-found page. Universal SSL already covers
# *.pcbjam.com, so TLS is never in question.
#
# Vercel stays attached throughout, so resolvers still holding the old answer keep
# serving the identical site. This is a fade, not a switch.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/cf-api.sh"
. "$(dirname "$0")/lib/parity.sh"
require_cmd curl dig jq awk
PHASE=""; ROLLBACK=0; SWAP_MODE=auto
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--phase) PHASE="$2"; shift 2 ;;
--swap-mode) SWAP_MODE="$2"; shift 2 ;;
--rollback) ROLLBACK=1; shift ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
dry_banner
ZID="$(cf_zone_id)"
PROBE_HOST="cf-attach-probe.$ZONE_NAME"
SNAP="$STATE_DIR/state/dns-before.json"
[ -s "$SNAP" ] || die "no DNS snapshot at $SNAP — run 01-preflight.sh first (it is the rollback source of truth)"
# ---------------------------------------------------------------- rollback ----
if [ "$ROLLBACK" = 1 ]; then
section "rollback: restore the Vercel CNAMEs and disable the redirect rule"
for n in "www.$ZONE_NAME" "$ZONE_NAME"; do
for t in CNAME A; do
id="$(cf_dns_find "$n" "$t")"
[ -n "$id" ] || continue
cf_dns_guard "$id" "$n"
dry "restore $n -> $VERCEL_DNS_TARGET (CNAME, unproxied, ttl 60)" -- true
[ "$DRY_RUN" = 0 ] && cf_api PUT "/zones/$ZID/dns_records/$id" \
"$(jq -n --arg n "$n" --arg c "$VERCEL_DNS_TARGET" \
'{type:"CNAME",name:$n,content:$c,proxied:false,ttl:60}')" >/dev/null
break
done
done
if [ -s "$STATE_DIR/state/redirect-rule.json" ]; then
rsid="$(jq -r .ruleset_id "$STATE_DIR/state/redirect-rule.json")"
rid="$(jq -r .rule_id "$STATE_DIR/state/redirect-rule.json")"
dry "disable redirect rule $rid" -- true
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/rulesets/$rsid/rules/$rid" \
'{"enabled":false}' >/dev/null || true
fi
echo
echo "The Pages project, its deployments and secrets are left intact — re-cutting"
echo "over later is just: 07-dns-cutover.sh --phase swap --apply"
echo "done: rolled back (www ttl=60)"
exit 0
fi
[ -n "$PHASE" ] || die "pass --phase prelower|probe|rules|hsts|swap|apex (or --rollback)"
# ---------------------------------------------------------------- prelower ----
phase_prelower() {
section "prelower: www TTL -> 60"
id="$(cf_dns_find "www.$ZONE_NAME" CNAME)"
[ -n "$id" ] || die "no www CNAME found"
cf_dns_guard "$id" "www.$ZONE_NAME"
cur="$(cf_api GET "/zones/$ZID/dns_records/$id")"
content="$(printf '%s' "$cur" | jq -r .result.content)"
ttl="$(printf '%s' "$cur" | jq -r .result.ttl)"
proxied="$(printf '%s' "$cur" | jq -r .result.proxied)"
echo " current: $content ttl=$ttl proxied=$proxied"
[ "$proxied" = "false" ] || die "record is proxied; TTL is not settable while proxied (and it should still be Vercel's at this point)"
dry "PATCH www TTL 60" -- true
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/dns_records/$id" '{"ttl":60}' >/dev/null
echo "done: www ttl 60 (was $ttl)"
}
# ------------------------------------------------------------------- probe ----
phase_probe() {
section "probe: can Pages attach a custom domain over an existing CNAME?"
echo "Uses the throwaway host $PROBE_HOST. www is never touched."
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: POST /zones/$ZID/dns_records (CNAME $PROBE_HOST -> $PAGES_PROJECT.pages.dev, proxied)"
echo "WOULD: POST /accounts/<acct>/pages/projects/$PAGES_PROJECT/domains {\"name\":\"$PROBE_HOST\"}"
echo "WOULD: poll until status=active, curl it, then DELETE both"
exit 0
fi
rid="$(cf_api POST "/zones/$ZID/dns_records" \
"$(jq -n --arg n "$PROBE_HOST" --arg c "$PAGES_PROJECT.pages.dev" \
'{type:"CNAME",name:$n,content:$c,proxied:true,ttl:1,comment:"cf-migrate probe - safe to delete"}')" \
| jq -r .result.id)"
echo " created probe record $rid"
attach_rc=0
cf_api POST "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains" \
"$(jq -n --arg n "$PROBE_HOST" '{name:$n}')" >/dev/null 2>&1 || attach_rc=$?
t0=$(date +%s); status=unknown
if [ "$attach_rc" = 0 ]; then
for _ in $(seq 1 60); do
status="$(cf_pages_domains | jq -r --arg n "$PROBE_HOST" '.result[]|select(.name==$n)|.status' | head -1)"
[ "$status" = active ] && break
sleep 5
done
fi
secs=$(( $(date +%s) - t0 ))
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$PROBE_HOST/" 2>/dev/null || echo 000)"
echo " attach_rc=$attach_rc status=$status seconds_to_active=$secs http=$code"
if [ "$attach_rc" = 0 ] && [ "$status" = active ]; then
printf 'ok seconds_to_active=%s\n' "$secs" > "$STATE_DIR/state/attach-over-existing-record.ok"
rm -f "$STATE_DIR/state/attach-over-existing-record.unsupported"
echo " => in-place PATCH swap is supported (swap-mode auto will use it)"
else
printf 'unsupported attach_rc=%s status=%s\n' "$attach_rc" "$status" \
> "$STATE_DIR/state/attach-over-existing-record.unsupported"
rm -f "$STATE_DIR/state/attach-over-existing-record.ok"
warn "attach over an existing record did NOT work; swap-mode auto will fall back to delete-create"
warn "read the NODATA warning in this script's header before running the swap"
fi
cf_api DELETE "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains/$PROBE_HOST" >/dev/null 2>&1 || true
cf_dns_guard "$rid" "$PROBE_HOST"
cf_api DELETE "/zones/$ZID/dns_records/$rid" >/dev/null
echo "done: probe cleaned up"
}
# ------------------------------------------------------------------- rules ----
phase_rules() {
section "rules: apex -> www, 308, path + query preserved"
ep="$($CURL_BIN -sS "$CF_API/zones/$ZID/rulesets/phases/http_request_dynamic_redirect/entrypoint" \
-H "Authorization: Bearer $(cf_token)")"
have="$(printf '%s' "$ep" | jq -r '.success')"
rule="$(jq -n --arg h "$ZONE_NAME" --arg w "https://www.$ZONE_NAME" '{
ref: "cfm_apex_to_www",
description: "apex \($h) -> www (308, preserve query) [cf-migrate]",
expression: "(http.host eq \"\($h)\")",
action: "redirect",
action_parameters: { from_value: {
target_url: { expression: "concat(\"\($w)\", http.request.uri.path)" },
status_code: 308,
preserve_query_string: true } },
enabled: true }')"
# Exact host match only. A `contains` match would also catch app./demo./editor.
printf '%s' "$rule" | jq -e '.expression | contains("eq")' >/dev/null \
|| die "refusing a redirect expression that is not an exact host match"
if [ "$have" = "true" ]; then
rsid="$(printf '%s' "$ep" | jq -r '.result.id')"
n="$(printf '%s' "$ep" | jq '.result.rules | length // 0')"
echo " existing ruleset $rsid with $n rule(s) — APPENDING"
# POST /rules appends. A PUT on the entrypoint would replace the whole rule
# list and silently delete unrelated redirects.
[ "$n" -lt 10 ] || die "$n dynamic redirects already exist (free plan allows 10)"
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: POST /zones/$ZID/rulesets/$rsid/rules"; printf '%s\n' "$rule" | jq .
return 0
fi
out="$(cf_api POST "/zones/$ZID/rulesets/$rsid/rules" "$rule")"
else
echo " no dynamic-redirect ruleset yet — creating the phase entrypoint"
body="$(jq -n --argjson r "$rule" '{name:"Redirect rules ruleset",kind:"zone",phase:"http_request_dynamic_redirect",rules:[$r]}')"
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: POST /zones/$ZID/rulesets"; printf '%s\n' "$body" | jq .
return 0
fi
out="$(cf_api POST "/zones/$ZID/rulesets" "$body")"
rsid="$(printf '%s' "$out" | jq -r '.result.id')"
fi
rid="$(printf '%s' "$out" | jq -r '.result.rules[]? | select(.ref=="cfm_apex_to_www") | .id' | head -1)"
jq -n --arg rs "$rsid" --arg r "$rid" '{ruleset_id:$rs,rule_id:$r}' \
> "$STATE_DIR/state/redirect-rule.json"
echo " recorded ruleset=$rsid rule=$rid"
echo
echo "NOT verified yet, deliberately: the rule cannot fire until the apex record"
echo "is proxied. --phase apex asserts it."
echo "done: redirect rule created (inert)"
}
# -------------------------------------------------------------------- hsts ----
phase_hsts() {
section "hsts: max-age 63072000 (matching what Vercel sends today)"
cur="$(cf_api GET "/zones/$ZID/settings/security_header")"
printf '%s' "$cur" | jq -c '.result.value' 2>/dev/null || true
body='{"value":{"strict_transport_security":{"enabled":true,"max_age":63072000,"include_subdomains":false,"preload":false,"nosniff":true}}}'
# include_subdomains / preload are effectively irreversible (browsers cache the
# directive for the max-age, and preload lists are slow to leave). Refused.
echo " include_subdomains=false, preload=false — both deliberately off."
echo " Note this is ZONE-WIDE: cdn/app/demo/editor responses gain the header too."
echo " All are already HTTPS-only, so it is a no-op for them."
dry "PATCH zone HSTS" -- true
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/settings/security_header" "$body" >/dev/null
echo "done: HSTS enabled"
}
# -------------------------------------------------------------------- swap ----
phase_swap() {
section "swap: www.$ZONE_NAME -> Pages"
stamp_require 06-prod-deploy-parity
# Refuse to cut DNS onto a deployment other than the one that was verified.
want="$(jq -r '.deployment_id // "unknown"' "$STATE_DIR/stamps/06-prod-deploy-parity.$(ctx_hash)" 2>/dev/null \
|| grep -h '^deployment_id=' "$STATE_DIR/stamps/06-prod-deploy-parity.$(ctx_hash)" 2>/dev/null | cut -d= -f2)"
have="$(cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/deployments?per_page=1" \
| jq -r '.result[0].id // "none"')"
if [ -n "$want" ] && [ "$want" != unknown ] && [ "$want" != "$have" ]; then
die "verified deployment ($want) is not the current production deployment ($have).
Re-run 06-verify-deploy.sh --scope prod-deploy before cutting DNS."
fi
echo " verified deployment == current production deployment ($have)"
id="$(cf_dns_find "www.$ZONE_NAME" CNAME)"
[ -n "$id" ] || die "no www CNAME found"
cf_dns_guard "$id" "www.$ZONE_NAME"
cur="$(cf_api GET "/zones/$ZID/dns_records/$id" | jq -r .result.content)"
snap="$(jq -r --arg n "www.$ZONE_NAME" '.result[]|select(.name==$n and .type=="CNAME")|.content' "$SNAP" | head -1)"
[ "$cur" = "$snap" ] || die "www currently points at '$cur' but the snapshot says '$snap' — someone else changed it. Stop and re-check."
mode="$SWAP_MODE"
if [ "$mode" = auto ]; then
if [ -f "$STATE_DIR/state/attach-over-existing-record.ok" ]; then mode=update
else mode=delete-create
warn "no successful --phase probe result on record; falling back to delete-create"
warn "that risks NODATA negative-caching for the SOA minimum. Run --phase probe first."
fi
fi
echo " swap mode: $mode"
confirm_cutover
if [ "$mode" = update ]; then
dry "PATCH www -> $PAGES_PROJECT.pages.dev (proxied, atomic — no DNS gap)" -- true
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/dns_records/$id" \
"$(jq -n --arg n "www.$ZONE_NAME" --arg c "$PAGES_PROJECT.pages.dev" \
'{type:"CNAME",name:$n,content:$c,proxied:true,ttl:1,comment:"Pages custom domain [cf-migrate]"}')" >/dev/null
else
dry "DELETE www record, then immediately attach (leaves a brief NODATA window)" -- true
[ "$DRY_RUN" = 0 ] && cf_api DELETE "/zones/$ZID/dns_records/$id" >/dev/null
fi
dry "POST pages custom domain www.$ZONE_NAME" -- true
if [ "$DRY_RUN" = 0 ]; then
cf_api POST "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains" \
"$(jq -n --arg n "www.$ZONE_NAME" '{name:$n}')" >/dev/null 2>&1 \
|| warn "attach returned an error — it may already be attached; checking status"
for _ in $(seq 1 60); do
s="$(cf_pages_domains | jq -r --arg n "www.$ZONE_NAME" '.result[]|select(.name==$n)|.status' | head -1)"
echo " domain status: ${s:-<absent>}"
[ "$s" = active ] && break
sleep 5
done
# Host-header probe: proves the edge routes the hostname to Pages regardless
# of what any resolver currently caches.
hp="$(curl -sS -o /dev/null -w '%{http_code}' -H "Host: www.$ZONE_NAME" \
"https://$PAGES_PROJECT.pages.dev/" 2>/dev/null || echo 000)"
echo " host-header probe via pages.dev: $hp"
fi
echo
echo "done: www swapped. Next: --phase apex, then 08-verify-prod.sh"
}
# -------------------------------------------------------------------- apex ----
phase_apex() {
section "apex: $ZONE_NAME -> proxied placeholder (activates the redirect rule)"
# Order matters: a proxied apex pointing at TEST-NET with no rule serves 522s.
[ -s "$STATE_DIR/state/redirect-rule.json" ] \
|| die "no redirect rule recorded — run --phase rules first, or the apex will 522"
rsid="$(jq -r .ruleset_id "$STATE_DIR/state/redirect-rule.json")"
rid="$(jq -r .rule_id "$STATE_DIR/state/redirect-rule.json")"
en="$(cf_api GET "/zones/$ZID/rulesets/$rsid" | jq -r --arg r "$rid" '.result.rules[]|select(.id==$r)|.enabled')"
[ "$en" = "true" ] || die "redirect rule $rid is not enabled — refusing to proxy the apex"
echo " redirect rule $rid is enabled"
id="$(cf_dns_find "$ZONE_NAME" CNAME)"
[ -n "$id" ] || id="$(cf_dns_find "$ZONE_NAME" A)"
[ -n "$id" ] || die "no apex record found"
cf_dns_guard "$id" "$ZONE_NAME"
before="$(cf_api GET "/zones/$ZID/dns_records/$id" | jq -c '{type:.result.type,content:.result.content,proxied:.result.proxied}')"
echo " before: $before"
# A single PUT changes CNAME -> A atomically; no delete/create gap.
dry "PUT apex -> A 192.0.2.0 proxied (placeholder; requests never reach it)" -- true
if [ "$DRY_RUN" = 1 ]; then echo; echo "done: (dry run)"; return 0; fi
cf_api PUT "/zones/$ZID/dns_records/$id" \
"$(jq -n --arg n "$ZONE_NAME" \
'{type:"A",name:$n,content:"192.0.2.0",proxied:true,ttl:1,comment:"placeholder for apex->www redirect rule [cf-migrate]"}')" >/dev/null
section "verifying the 308 (auto-rollback armed)"
ok=1
for _ in $(seq 1 12); do
if assert_apex_redirect "$APEX_BASE" >/dev/null 2>&1; then ok=0; break; fi
sleep 5
done
if [ "$ok" != 0 ]; then
warn "apex redirect did not come up — rolling the apex record back"
cf_api PUT "/zones/$ZID/dns_records/$id" \
"$(jq -n --arg n "$ZONE_NAME" --arg c "$VERCEL_DNS_TARGET" \
'{type:"CNAME",name:$n,content:$c,proxied:false,ttl:60}')" >/dev/null
die "apex rolled back to Vercel. Investigate the redirect rule, then retry."
fi
assert_apex_redirect "$APEX_BASE"
echo "done: apex 308s to www"
}
case "$PHASE" in
prelower) phase_prelower ;;
probe) phase_probe ;;
rules) phase_rules ;;
hsts) phase_hsts ;;
swap) phase_swap ;;
apex) phase_apex ;;
*) die "unknown phase '$PHASE' (prelower|probe|rules|hsts|swap|apex)" ;;
esac

View file

@ -1,99 +0,0 @@
#!/usr/bin/env bash
# The go/no-go after cutover. Read-only.
#
# deploy/site/08-verify-prod.sh
#
# Also asserts the two probes that FAILED in 00-baseline.sh now pass, so the
# migration demonstrably fixed the COOP/COEP bug rather than carrying it over.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/parity.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl dig jq awk sed
rc=0
section "DNS now resolves to Cloudflare"
# A PROXIED record answers with Cloudflare anycast A records and exposes no CNAME,
# so an empty CNAME lookup here is the expected result once the custom domain is
# attached — not a problem. What would be wrong is failing to resolve at all, or
# still CNAMEing to Vercel. The authoritative "are we on Cloudflare" check is the
# cf-ray / x-vercel-id pair below.
for h in "$ZONE_NAME" "www.$ZONE_NAME"; do
a="$(dig +short A "$h" | tr '\n' ' ')"
c="$(dig +short CNAME "$h" | tr '\n' ' ')"
printf ' %-20s A=%s%s\n' "$h" "${a:-<none>}" "${c:+ CNAME=$c}"
if [ -z "$a" ]; then echo " FAIL $h does not resolve"; rc=1; fi
case "$c" in
*vercel-dns*) echo " FAIL $h still CNAMEs to Vercel — not propagated, or the attach did not happen"; rc=1 ;;
esac
done
section "we are actually being served by Cloudflare, not a stale Vercel cache"
h="$(_headers "$PROD_BASE/")"
if [ -n "$(_hdr "$h" x-vercel-id)" ]; then
echo " FAIL x-vercel-id still present — you are seeing cached DNS."
echo " Wait for the TTL and re-run; this is NOT a pass."
rc=1
else
echo " PASS no x-vercel-id"
fi
[ -n "$(_hdr "$h" cf-ray)" ] && echo " PASS cf-ray present" || echo " WARN no cf-ray header"
assert_parity "$PROD_BASE" --scope prod || rc=$?
assert_apex "$APEX_BASE" || rc=$?
section "the two baseline failures must now pass"
# This is the whole point of having captured a baseline: prove the fix.
for path in /blog/porting-kicad-graphics-to-webgl-in-2026 /blog/porting-kicad-graphics-to-webgl-in-2026/; do
eff="$(_trace "$PROD_BASE$path" | cut -f1)"
hh="$(_headers "$eff")"
coop="$(_hdr "$hh" cross-origin-opener-policy)"; coep="$(_hdr "$hh" cross-origin-embedder-policy)"
if [ "$coop" = same-origin ] && [ "$coep" = require-corp ]; then
echo " PASS $path -> isolated ($eff)"
else
echo " FAIL $path -> coop='${coop:-absent}' coep='${coep:-absent}' ($eff)"
echo " this is the bug the migration was supposed to fix"
rc=1
fi
done
section "the demo integration (load-bearing)"
# demo.pcbjam.com has no backend; it cross-posts here. A CORS preflight cannot
# follow a redirect, so this must be 204 on www with zero hops.
hops="$(curl -sS -o /dev/null -w '%{num_redirects}' -X OPTIONS "$PROD_BASE/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null || echo 9)"
st="$(curl -sS -o /dev/null -w '%{http_code}' -X OPTIONS "$PROD_BASE/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' -H 'Access-Control-Request-Method: POST' 2>/dev/null || true)"
if [ "$st" = "204" ] && [ "$hops" = "0" ]; then
echo " PASS preflight 204 with 0 redirects"
else
echo " FAIL status=$st hops=$hops (both 204 and 0 hops are required)"; rc=1
fi
section "custom domains attached to $PAGES_PROJECT"
# Via wrangler (works with `wrangler login`); the REST endpoint would need an API
# token that nothing else in the serve-mode path requires.
doms="$($WRANGLER pages project list --json 2>/dev/null \
| jq -r --arg n "$PAGES_PROJECT" '.[] | select(."Project Name"==$n) | ."Project Domains"')"
echo " $doms"
want="www.$ZONE_NAME"
[ "${APEX_MODE:-serve}" = serve ] && want="$want $ZONE_NAME"
for d in $want; do
case ",$(printf '%s' "$doms" | tr -d ' ')," in
*",$d,"*) echo " PASS $d attached" ;;
*) echo " FAIL $d NOT attached to $PAGES_PROJECT"; rc=1 ;;
esac
done
section "summary"
if [ "$rc" -ne 0 ]; then
echo "PROD VERIFICATION FAILED."
echo "Rollback is one command: deploy/site/99-rollback.sh --apply"
exit 1
fi
stamp_write 08-prod-parity
echo "All checks passed. Vercel still holds the domains as a fallback;"
echo "detach them after a soak: deploy/site/09-detach-vercel.sh --apply"
echo "done: $PROD_BASE"

View file

@ -1,96 +0,0 @@
#!/usr/bin/env bash
# Detach pcbjam.com + www.pcbjam.com from the Vercel project, AFTER a soak.
# Keeps the Vercel project, its env vars, and the _vercel TXT records — so
# re-attaching is instant and needs no re-verification.
#
# deploy/site/09-detach-vercel.sh # dry run
# deploy/site/09-detach-vercel.sh --apply
# deploy/site/09-detach-vercel.sh --rollback --apply # re-attach (incident use)
#
# Needs VERCEL_TOKEN (a team token with project write), or the vercel CLI logged in.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
require_cmd curl jq
SOAK_H="${SOAK_HOURS:-24}"; ROLLBACK=0
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--soak-hours) SOAK_H="$2"; shift 2 ;;
--rollback) ROLLBACK=1; shift ;;
--i-understand) CFM_I_UNDERSTAND=1; shift ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
dry_banner
VAPI="https://api.vercel.com"
vercel_api() { # vercel_api METHOD PATH
[ -n "${VERCEL_TOKEN:-}" ] || die "VERCEL_TOKEN is not set (a team token with project write)"
if [ "$1" != GET ] && [ "$DRY_RUN" = 1 ]; then
echo "WOULD: $1 $VAPI$2" >&2; echo '{"dry_run":true}'; return 0
fi
curl -sS -X "$1" "$VAPI$2" -H "Authorization: Bearer $VERCEL_TOKEN" -H 'Content-Type: application/json'
}
TEAM_ID="$(vercel_api GET "/v2/teams?slug=$VERCEL_TEAM" | jq -r '.teams[0].id // empty' 2>/dev/null || true)"
[ -n "$TEAM_ID" ] || warn "could not resolve team id for '$VERCEL_TEAM'; API calls may fail"
Q="teamId=$TEAM_ID"
if [ "$ROLLBACK" = 1 ]; then
section "re-attaching the domains to Vercel"
# Instant: the _vercel TXT verification records were never deleted.
for d in "$ZONE_NAME" "www.$ZONE_NAME"; do
dry "attach $d to Vercel project $VERCEL_PROJECT" -- true
[ "$DRY_RUN" = 0 ] && vercel_api POST "/v10/projects/$VERCEL_PROJECT/domains?$Q" >/dev/null 2>&1 || true
done
echo "done: domains re-attached (DNS still needs to point at Vercel — see 99-rollback.sh)"
exit 0
fi
section "soak gate"
STAMP="$STATE_DIR/stamps/08-prod-parity.$(ctx_hash)"
[ -f "$STAMP" ] || die "no 08-prod-parity stamp — run 08-verify-prod.sh first"
ts="$(awk -F= '/^ts=/{print $2}' "$STAMP")"
age_h=$(( ( $(date +%s) - $(stat -f %m "$STAMP") ) / 3600 ))
echo " verified at $ts (${age_h}h ago); soak requirement ${SOAK_H}h"
if [ "$age_h" -lt "$SOAK_H" ]; then
[ "${CFM_I_UNDERSTAND:-0}" = 1 ] \
|| die "only ${age_h}h since verification (need ${SOAK_H}h).
Vercel is your fallback — detaching early removes it. Pass --i-understand to override."
warn "soak window overridden"
fi
section "re-checking production before removing the fallback"
# Never detach while broken.
for u in "$PROD_BASE/" "$PROD_BASE/blog" "$PROD_BASE/pricing"; do
c="$(curl -sS -L -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000)"
[ "$c" = "200" ] || die "$u returned $c — refusing to detach Vercel while production is unhealthy"
echo " ok $u"
done
c="$(curl -sS -o /dev/null -w '%{http_code}' -X OPTIONS "$PROD_BASE/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' -H 'Access-Control-Request-Method: POST' 2>/dev/null || echo 000)"
[ "$c" = "204" ] || die "waitlist preflight returned $c — refusing to detach"
echo " ok waitlist preflight"
section "detaching"
for d in "www.$ZONE_NAME" "$ZONE_NAME"; do
dry "DELETE /v9/projects/$VERCEL_PROJECT/domains/$d" -- true
[ "$DRY_RUN" = 0 ] && vercel_api DELETE "/v9/projects/$VERCEL_PROJECT/domains/$d?$Q" >/dev/null
done
if [ "$DRY_RUN" = 0 ]; then
section "confirming"
left="$(vercel_api GET "/v9/projects/$VERCEL_PROJECT/domains?$Q" | jq -r '.domains[]?.name' | tr '\n' ' ')"
echo " domains still on the project: ${left:-<none>}"
case " $left " in
*" $ZONE_NAME "*|*" www.$ZONE_NAME "*) die "a target domain is still attached" ;;
esac
fi
section "done"
echo "Deliberately NOT done: the Vercel project, its env vars, and the two _vercel"
echo "TXT DNS records all remain. That is what keeps rollback cheap."
echo "done: vercel domains detached (project $VERCEL_PROJECT retained)"

View file

@ -1,72 +0,0 @@
#!/usr/bin/env bash
# One command, incident-grade: put www.pcbjam.com and the apex back on Vercel.
#
# deploy/site/99-rollback.sh # dry run — read it first
# deploy/site/99-rollback.sh --apply --yes
#
# Order matters: re-attach at Vercel FIRST so the target exists before DNS points
# at it, then restore DNS, then disable the redirect rule.
#
# Leaves the Pages project, its deployments and its secrets alone — re-cutting
# over later is just `07-dns-cutover.sh --phase swap --apply`.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl dig jq
SKIP_VERCEL=0
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--skip-vercel) SKIP_VERCEL=1; shift ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
dry_banner
HERE="$(dirname "$0")"
FLAGS=""; [ "$DRY_RUN" = 0 ] && FLAGS="--apply"
section "1/3 re-attach the domains at Vercel"
if [ "$SKIP_VERCEL" = 1 ]; then
echo " skipped (--skip-vercel)"
elif [ -z "${VERCEL_TOKEN:-}" ]; then
warn "VERCEL_TOKEN unset — skipping the re-attach step."
warn "Do it by hand NOW (Vercel -> project $VERCEL_PROJECT -> Domains -> add"
warn "$ZONE_NAME and www.$ZONE_NAME) before the DNS change below propagates."
else
# shellcheck disable=SC2086
"$HERE/09-detach-vercel.sh" --rollback $FLAGS || warn "re-attach reported an error; check the Vercel dashboard"
fi
section "2/3 restore DNS + disable the redirect rule"
# shellcheck disable=SC2086
"$HERE/07-dns-cutover.sh" --rollback $FLAGS --yes
if [ "$DRY_RUN" = 1 ]; then
echo; echo "done: (dry run) nothing changed. Re-run with --apply --yes to roll back."
exit 0
fi
section "3/3 waiting for Vercel to serve again (www ttl is 60)"
ok=1
for i in $(seq 1 60); do
h="$(curl -sS -o /dev/null -D - "$PROD_BASE/" 2>/dev/null | tr -d '\r' || true)"
st="$(printf '%s' "$h" | awk '/^HTTP/{c=$2} END{print c}')"
vid="$(printf '%s' "$h" | awk 'tolower($1)=="x-vercel-id:"{print $2}' | tail -1)"
echo " [${i}] status=$st x-vercel-id=${vid:-none} cname=$(dig +short CNAME "www.$ZONE_NAME" | head -1)"
if [ "$st" = "200" ] && [ -n "$vid" ]; then ok=0; break; fi
sleep 5
done
section "done"
if [ "$ok" = 0 ]; then
echo "www.$ZONE_NAME is served by Vercel again."
else
echo "www.$ZONE_NAME is NOT confirmably back on Vercel yet."
echo "Check: the domains are attached in Vercel, and dig www.$ZONE_NAME."
fi
echo "The Pages project is untouched; re-cutover is 07-dns-cutover.sh --phase swap --apply"
echo "done: rollback complete (re-baseline with 00-baseline.sh --force if you want a fresh reference)"

View file

@ -1,8 +1,10 @@
# www.pcbjam.com deploy runbook
# www.pcbjam.com
The Astro marketing site + blog (`../../site`) on **Cloudflare Pages** (project
`pcbjam-site`), with one **Pages Function** for `/api/waitlist`. The apex
`pcbjam.com` 308s to `www` via a zone Redirect Rule.
`pcbjam-site`), with one **Pages Function** for `/api/waitlist`. Both
`www.pcbjam.com` and the apex `pcbjam.com` are custom domains on that project and
serve identically; the pages emit `canonical=www`, which is what consolidates them
for search.
```
push to main (site/**) ──▶ .github/workflows/deploy-site.yml
@ -16,134 +18,82 @@ push to main (site/**) ──▶ .github/workflows/deploy-site.yml
Not tag-gated: content must not wait for a release. The WASM editor ships from
`release.yml`; the two are independent.
## Layout
## Files that carry load
```
site/functions/api/waitlist.ts the only server-side code (Pages Function)
site/public/_headers prod COOP/COEP, scoped to 2 routes
site/public/_headers prod response headers, scoped to 3 patterns
site/public/_routes.json only /api/* invokes the Function
site/wrangler.toml nodejs_compat + pages_build_output_dir
site/src/pages/404.astro required — see "soft-404" below
site/src/pages/404.astro required — see below
```
Two things you can get wrong here, both of which fail quietly:
Four things here fail **silently** if changed carelessly. Each is asserted by
`verify.sh`, and each is asserted because it was a real defect at some point:
- **Never widen `_headers` to `/*`.** `deploy/demo/_headers` does exactly that,
which is right for the demo and wrong here: a `require-corp` document cannot
load the no-COEP YouTube hero iframe, so the landing page must stay
un-isolated. The sweep asserts `/` is *not* isolated for this reason.
- **Never delete `404.astro`.** Without a `404.html` in the output, Pages answers
every unknown URL with the **homepage at HTTP 200** — a soft-404 that invites
search engines to index arbitrary URLs as the homepage.
- **Never widen `_headers` to `/*`.** `../demo/_headers` does exactly that, which
is right for the demo and wrong here: a `require-corp` document cannot load the
no-COEP YouTube hero iframe, so the landing page must stay un-isolated.
- **Keep both URL forms of the Gerber blog post in `_headers`.** Pages serves the
trailing-slash form and 308s the bare form to it, and the trailing-slash form is
the page's own canonical. Scoping the headers to one form only means search
arrivals get a page without `SharedArrayBuffer` and the embedded viewer quietly
degrades. That shipped in production for months.
- **Never delete `404.astro`.** With no `404.html` in the output, Pages answers
every unknown URL with the **homepage at HTTP 200** — a soft 404 that invites
indexing junk URLs as the homepage.
- **Keep the cross-site form-POST guard** in `waitlist.ts`. Vercel's edge refused
those for free; Cloudflare does not, and a cross-site `<form>` submit needs no
CORS permission to be *sent*, so the allowlist cannot stop it.
## One-time setup (Cloudflare — needs your account)
## Verifying
1. `pcbjam.com` zone on Cloudflare; note the **account id**.
2. API token with: Zone→Zone:Read, Zone→DNS:Edit, Zone→Zone Settings:Edit,
Zone→Dynamic Redirect:Edit, Account→Cloudflare Pages:Edit.
Export `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.
3. Pages project `pcbjam-site`, production branch `production`
`03-ensure-project.sh --apply`.
4. Secrets — `04-set-secrets.sh --apply`. `WAITLIST_ALLOWED_ORIGINS` stays
**unset** so the allowlist lives in code.
5. Custom domain `www.pcbjam.com` + the apex Redirect Rule —
`07-dns-cutover.sh`. There is no `wrangler pages domain` subcommand, so this
goes through the API (or the dashboard).
6. The repo's GitHub secrets `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`
already exist for the demo/editor deploys — nothing to add.
```sh
deploy/site/verify.sh # production
EXPECT_HSTS=1 deploy/site/verify.sh # once HSTS max-age is set, assert it
## Migration / cutover (one time, from Vercel)
# a specific deployment, before promoting it
PROD_BASE=https://abc123.pcbjam-site.pages.dev \
deploy/site/verify.sh --skip-dns --skip-domains
```
Two supported apex topologies, selected by `APEX_MODE` (see `lib/common.sh`):
`lib/parity.sh` holds the assertion set: pages resolve to themselves (which is how
a soft-404 gets caught), cross-origin isolation on the right paths *and its
absence on the landing page*, immutable asset caching, and the waitlist endpoint's
whole contract — preflight 204 with **zero** redirects for the demo origin, CORS
denied for others, 405 on GET, 400 on a bad address, 303 for the no-JS form, 403
for a cross-site form POST.
- **`serve` (default, and what we chose).** The apex is a *second* Pages custom
domain and answers 200 directly. No redirect rule, no placeholder record, no
zone-level scopes — the same two clicks as `app.`/`demo.`/`editor.`. The pages
still emit `canonical=www`, which is what consolidates the two hostnames for
search.
- **`redirect`.** The apex 308s to www via a zone Redirect Rule plus a proxied
placeholder record. This is what Vercel did. It needs Zone→DNS:Edit and
Zone→Dynamic Redirect:Edit, and the rule is the single riskiest artefact in the
whole migration: Redirect Rules are **zone-scoped**, so a `contains` match
instead of `eq` catches every subdomain and 308s `app.`, `editor.`, `demo.` and
`api.` to www. Dynamic redirects run *before* Workers/Pages routing, so that
breaks the product, not just a marketing page. `07 --phase rules` refuses any
expression that is not an exact `eq` match.
Header assertions are made on the **final** response after following redirects,
via two separate requests (`_trace`, then `_headers`). That split is deliberate:
the isolation bug above was invisible precisely because the headers were present
on a redirect hop and absent on the document.
Every mutating script is **dry-run by default**; add `--apply`. Read the dry-run
output before applying — that is the point of the split.
The POSTs are safe against production. The full-path probe uses the honeypot
branch, which returns before validation, before the rate limiter and before any
Resend call — so it exercises routing, Functions bundling, body parsing and CORS
while sending no mail. The one destructive probe (a *valid* email POST) is behind
`--live-post` and is not used here.
| when | action | live? |
|---|---|---|
| Tdays | `00-baseline.sh` | no — read-only |
| Tdays | `01-preflight.sh` | no — read-only |
| Tdays | `02-verify-local.sh` | no |
| Tdays | `03-ensure-project.sh --apply``04-set-secrets.sh --apply` | new project only |
| Tdays | `05-deploy.sh --preview --apply``06-verify-deploy.sh --latest` | no — pages.dev only |
| T1d | *(optional)* HSTS: `07-dns-cutover.sh --phase hsts --apply`, or the dashboard | no — additive |
| T24h | *(optional)* `07-dns-cutover.sh --phase prelower --apply` — www TTL → 60 | no — TTL only |
| T1h | `05-deploy.sh --production --apply``06-verify-deploy.sh --scope prod-deploy` | no — no DNS yet |
| **T+0** | attach **www.pcbjam.com** to `pcbjam-site` (dashboard → Custom domains) | **yes** |
| **T+0** | attach **pcbjam.com** the same way (`APEX_MODE=serve`) | **yes** |
| T+5m | `08-verify-prod.sh` | verify only |
| T+24h | `09-detach-vercel.sh --apply` | Vercel only |
## Secrets
For `APEX_MODE=redirect` instead, replace the two attach rows with
`07 --phase rules --apply` (early, inert) then `07 --phase swap --apply` and
`07 --phase apex --apply`, and export `APEX_MODE=redirect` so `08` asserts a 308.
`RESEND_API_KEY`, `RESEND_SEGMENT_ID`, `WAITLIST_FROM_EMAIL`, set on the Pages
project (dashboard → Settings → Variables and Secrets, or
`wrangler pages secret put <NAME> --project-name pcbjam-site`). Pages applies
changes to **new deployments only**, so redeploy after editing them.
Rollback, any time: **`99-rollback.sh --apply --yes`**, or by hand — point `www`
and the apex back to CNAME `dcfb2907091b7240.vercel-dns-016.com`, **DNS-only**.
Vercel keeps both domains attached until `09`, so it resumes serving as soon as
DNS propagates. Keep rollback ready in a second terminal during the attach.
`WAITLIST_ALLOWED_ORIGINS` is deliberately unset — it keeps its default in
`functions/api/waitlist.ts`, where the allowlist is reviewable in code. Preview is
deliberately keyless too: a preview holding the live key would email real people
and write real Resend contacts.
### Attach, don't delete-and-recreate
## Recovery
Cloudflare's Custom Domains flow *updates* the existing `www` CNAME in place. That
matters: deleting the record and creating a new one leaves the name with no answer
for a second or two, and any resolver that asks in that instant caches NODATA for
the zone's **SOA minimum** — measured at **1800s** on this zone by
`00-baseline.sh`. That is an un-flushable ~30-minute partial outage. The in-place
update has no DNS gap at all; the only residual window is HTTP-level and
self-healing (a second or two where the edge has no route for the host and serves
the Pages not-found page). Universal SSL already covers `*.pcbjam.com`, so TLS is
never in question.
`07 --phase swap` does the same thing via the API (`PATCH`, not `DELETE`+`POST`),
and `--phase probe` settles days in advance — on a throwaway hostname — whether
Pages will attach over an existing CNAME at all.
## The parity sweep
`lib/parity.sh` is one assertion set, run against four bases: live Vercel
(baseline), `localhost:8788`, the `*.pages.dev` deployment, then `www`. Same
question every time, so a regression has nowhere to hide.
It asserts header values on the **final** response after following redirects,
via two separate requests (`_trace` then `_headers`). That is deliberate: the
COOP/COEP bug this migration fixes was invisible precisely because the headers
were present on a redirect hop and absent on the document.
`00-baseline.sh` is **expected to report failures** — the blog post's COOP/COEP
is genuinely broken on Vercel today (its canonical is the trailing-slash URL,
which serves 200 with no isolation headers). `08-verify-prod.sh` requires those
same probes to pass, which is how the fix is proven rather than assumed.
The honeypot POST is safe against production: that branch returns before
validation, before the rate limiter and before any Resend call, so it exercises
routing, Functions bundling, body parsing and CORS while sending no mail. The
only destructive probe (a *valid* email POST) is gated behind `--live-post` and
never runs against `www`.
## Gates
`02-verify-local.sh` writes a stamp keyed to a hash of `src/`, `public/`,
`functions/` and the configs. `05-deploy.sh` refuses to deploy without a stamp
for the *current* tree, and `07 --phase swap` refuses to cut DNS unless the
verified deployment id is still the live production deployment. Override with
`CFM_FORCE=1 CFM_I_UNDERSTAND=1`, which logs the bypass.
Scratch state (stamps, snapshots, logs) lives in `site/.cf-migrate/`, gitignored.
There is no cross-vendor fallback — the Vercel project is gone. Recovery is a
Pages concern: every deployment stays addressable at its own `*.pages.dev` URL, so
verify a candidate first, then either roll back to it in the dashboard
(Deployments → Rollback to this deployment) or re-deploy a known-good tree so git
stays the source of truth.
## Local development
@ -153,3 +103,6 @@ npm run dev # Astro only — does NOT run functions/
cp .dev.vars.example .dev.vars # gitignored
npm run build && npm run pages:dev # http://localhost:8788, Function included
```
`astro dev` does not execute `functions/`, so any change to the waitlist endpoint
needs `pages:dev` to be exercised at all.

View file

@ -1,95 +0,0 @@
#!/usr/bin/env bash
# Cloudflare API v4 wrapper + the DNS allowlist guard. Sourced, never executed.
#
# Required token scopes (one token, CLOUDFLARE_API_TOKEN):
# Zone -> Zone -> Read (pcbjam.com)
# Zone -> DNS -> Edit (record CRUD)
# Zone -> Zone Settings -> Edit (HSTS / security_header)
# Zone -> Dynamic Redirect-> Edit (http_request_dynamic_redirect ruleset)
# Account-> Cloudflare Pages-> Edit (project, secrets, deploy, custom domains)
CF_API="https://api.cloudflare.com/client/v4"
# True when a REST token is available. `wrangler login` gives an OAuth token that
# wrangler itself uses but that cannot be replayed as a REST bearer token, and its
# zone scope is read-only — so the DNS/ruleset/HSTS steps need a real API token
# while the Pages steps are happy with either. Scripts branch on this.
cf_have_token() { [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]; }
cf_token() {
[ -n "${CLOUDFLARE_API_TOKEN:-}" ] || die "CLOUDFLARE_API_TOKEN is not set.
\`wrangler login\` is not enough for this step: its OAuth token cannot be used
as a REST bearer token, and it only carries zone:read. Create an API token with
the scopes at the top of this file and export it (plus CLOUDFLARE_ACCOUNT_ID)."
printf '%s' "$CLOUDFLARE_API_TOKEN"
}
cf_account() {
[ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || die "CLOUDFLARE_ACCOUNT_ID is not set"
printf '%s' "$CLOUDFLARE_ACCOUNT_ID"
}
# cf_api METHOD PATH [JSON_BODY]
# GETs always execute (read-only). Non-GET honours DRY_RUN and prints the exact
# call it would make, body included, so a reviewer sees it before it happens.
cf_api() {
_m="$1"; _p="$2"; _b="${3:-}"
if [ "$_m" != "GET" ] && [ "${DRY_RUN:-1}" = 1 ]; then
echo "WOULD: $_m $_p" >&2
[ -n "$_b" ] && printf '%s\n' "$_b" | jq . >&2
echo '{"success":true,"result":{},"dry_run":true}'
return 0
fi
_log="$STATE_DIR/logs/api-$(date +%s)-$$.json"
if [ -n "$_b" ]; then
_out="$($CURL_BIN -sS -X "$_m" "$CF_API$_p" \
-H "Authorization: Bearer $(cf_token)" \
-H 'Content-Type: application/json' --data "$_b")"
else
_out="$($CURL_BIN -sS -X "$_m" "$CF_API$_p" \
-H "Authorization: Bearer $(cf_token)" \
-H 'Content-Type: application/json')"
fi
printf '%s' "$_out" > "$_log"
printf '%s' "$_out" | jq -e '.success == true' >/dev/null 2>&1 \
|| die "API $_m $_p failed — see $_log
$(printf '%s' "$_out" | jq -r '.errors[]? | " [\(.code)] \(.message)"' 2>/dev/null)"
printf '%s' "$_out"
}
CURL_BIN="${CURL_BIN:-curl}"
cf_zone_id() {
if [ -s "$STATE_DIR/state/zone-id" ]; then cat "$STATE_DIR/state/zone-id"; return; fi
_r="$(cf_api GET "/zones?name=$ZONE_NAME")"
_n="$(printf '%s' "$_r" | jq '.result | length')"
[ "$_n" = "1" ] || die "expected exactly 1 zone named $ZONE_NAME, found $_n"
printf '%s' "$_r" | jq -r '.result[0].id' | tee "$STATE_DIR/state/zone-id"
}
cf_dns_list() { cf_api GET "/zones/$(cf_zone_id)/dns_records?per_page=500"; }
# cf_dns_find <name> <type> -> record id ("" if none)
cf_dns_find() {
cf_dns_list | jq -r --arg n "$1" --arg t "$2" \
'.result[] | select(.name==$n and .type==$t) | .id' | head -1
}
# THE guard. Every DNS mutation passes through this first. The zone also holds
# app/demo/editor/cdn/assets records, the Google MX and the two _vercel TXT
# verification records — none of which this migration may touch.
cf_dns_guard() { # cf_dns_guard <record_id> <expected_name>
_r="$(cf_api GET "/zones/$(cf_zone_id)/dns_records/$1")"
_n="$(printf '%s' "$_r" | jq -r .result.name)"
_t="$(printf '%s' "$_r" | jq -r .result.type)"
case "$_n" in
"$ZONE_NAME"|"www.$ZONE_NAME"|"cf-attach-probe.$ZONE_NAME") : ;;
*) die "refusing to touch DNS record '$_n' (allowlist: apex, www, cf-attach-probe)" ;;
esac
[ "$_n" = "$2" ] || die "record $1 is '$_n', expected '$2' (stale id? re-run 01-preflight.sh)"
case "$_t" in
A|CNAME) : ;;
*) die "refusing to touch a $_t record ($_n) — only A/CNAME are in scope" ;;
esac
}
cf_pages_project() { cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT"; }
cf_pages_domains() { cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains"; }

View file

@ -1,143 +1,38 @@
#!/usr/bin/env bash
# Shared plumbing for the deploy/site cf-migrate scripts: paths, logging,
# dry-run, stamps and guards. Sourced, never executed.
# Shared plumbing for the site verification scripts: paths, config, logging.
# Sourced, never executed.
#
# bash 3.2 compatible (/usr/bin/env bash on macOS is 3.2.57): no associative
# arrays, no `mapfile`, no ${x,,}. Accumulators use temp files.
# Repo + site paths. Every script cd's to SITE_DIR before building or deploying:
# `wrangler pages deploy` discovers Functions at $PWD/functions, so running from
# the wrong directory silently ships a static-only site with /api/waitlist 404ing.
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_SITE_DIR="$(dirname "$LIB_DIR")"
PCBJAM_DIR="$(cd "$DEPLOY_SITE_DIR/../.." && pwd)"
SITE_DIR="$PCBJAM_DIR/site"
STATE_DIR="$SITE_DIR/.cf-migrate"
# Defaults, overridable by env.
# Defaults, all overridable by env — which is how you point the sweep at a
# preview deployment instead of production:
# PROD_BASE=https://abc123.pcbjam-site.pages.dev deploy/site/verify.sh
PAGES_PROJECT="${PAGES_PROJECT:-pcbjam-site}"
PAGES_PROD_BRANCH="${PAGES_PROD_BRANCH:-production}"
ZONE_NAME="${ZONE_NAME:-pcbjam.com}"
PROD_BASE="${PROD_BASE:-https://www.pcbjam.com}"
APEX_BASE="${APEX_BASE:-https://pcbjam.com}"
VERCEL_PROJECT="${VERCEL_PROJECT:-pcbjam}"
VERCEL_TEAM="${VERCEL_TEAM:-pcbj-am}"
VERCEL_DNS_TARGET="${VERCEL_DNS_TARGET:-dcfb2907091b7240.vercel-dns-016.com}"
# How the apex behaves. Two supported topologies:
# serve (default) apex is a SECOND Pages custom domain and serves 200. No
# redirect rule, no placeholder record — the same two clicks as any
# other host. Pages still emit canonical=www so search consolidates.
# redirect apex 308s to www via a zone Redirect Rule + proxied placeholder
# record. This is what Vercel did, and needs zone DNS/rules scopes.
APEX_MODE="${APEX_MODE:-serve}"
WRANGLER="${WRANGLER_CMD:-npx --yes wrangler@4}"
mkdir -p "$STATE_DIR/state" "$STATE_DIR/stamps" "$STATE_DIR/baseline" "$STATE_DIR/logs"
# How the apex behaves.
# serve (current) the apex is a SECOND Pages custom domain and answers 200.
# No redirect rule, no placeholder record. The pages emit
# canonical=www, which is what consolidates the two hosts for search.
# redirect the apex 308s to www. Kept because the assertion is cheap to keep
# and the decision could be revisited; nothing sets it up any more.
APEX_MODE="${APEX_MODE:-serve}"
section() { echo; echo "== $* =="; }
warn() { echo "warn: $*" >&2; }
die() { echo "cf-migrate: $*" >&2; exit 1; }
die() { echo "verify: $*" >&2; exit 1; }
require_cmd() {
for c in "$@"; do
command -v "$c" >/dev/null 2>&1 || die "missing required command: $c"
done
}
# Refuse to run under `set -x` where secrets would land in the log.
no_xtrace() {
case "$-" in
*x*) die "refusing to run under 'set -x' (secrets would be echoed)" ;;
esac
}
# Identity of "the thing that was verified" — the inputs that affect the built
# output. Stamps are keyed on this so a stamp can never vouch for a later edit.
ctx_hash() {
( cd "$SITE_DIR" && \
find src public functions astro.config.mjs package.json package-lock.json \
wrangler.toml -type f 2>/dev/null \
| LC_ALL=C sort | tr '\n' '\0' | xargs -0 shasum -a 256 2>/dev/null \
| shasum -a 256 | cut -c1-16 )
}
stamp_write() {
_name="$1"; shift
_f="$STATE_DIR/stamps/${_name}.$(ctx_hash)"
{
echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "ctx=$(ctx_hash)"
echo "git=$(git -C "$PCBJAM_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown)"
for kv in "$@"; do echo "$kv"; done
} > "$_f"
echo "stamped: ${_name} (ctx=$(ctx_hash))"
}
# Dies unless a stamp for the CURRENT ctx_hash exists and is fresh.
# CFM_FORCE=1 downgrades to a loud warning; it also requires CFM_I_UNDERSTAND=1.
stamp_require() {
_name="$1"; _max_h="${2:-${STAMP_MAX_AGE_H:-24}}"
_f="$STATE_DIR/stamps/${_name}.$(ctx_hash)"
if [ ! -f "$_f" ]; then
if [ "${CFM_FORCE:-0}" = 1 ] && [ "${CFM_I_UNDERSTAND:-0}" = 1 ]; then
warn "BYPASS: no '${_name}' stamp for the current tree, continuing anyway"
return 0
fi
die "no '${_name}' stamp for the current tree (ctx=$(ctx_hash)).
The site changed since it was last verified. Run the matching script first:
${_name} -> see deploy/site/README.md
To override (not advised): CFM_FORCE=1 CFM_I_UNDERSTAND=1 $0 ..."
fi
_age=$(( ( $(date +%s) - $(stat -f %m "$_f" 2>/dev/null || echo 0) ) / 3600 ))
if [ "$_age" -ge "$_max_h" ]; then
if [ "${CFM_FORCE:-0}" = 1 ] && [ "${CFM_I_UNDERSTAND:-0}" = 1 ]; then
warn "BYPASS: '${_name}' stamp is ${_age}h old (max ${_max_h}h), continuing"
return 0
fi
die "'${_name}' stamp is ${_age}h old (max ${_max_h}h) — re-verify."
fi
}
# --- dry run ---------------------------------------------------------------
# DRY_RUN=1 is the DEFAULT for every mutating script. `--apply` clears it.
DRY_RUN="${DRY_RUN:-1}"
parse_common_flags() {
CFM_ARGS=""
for a in "$@"; do
case "$a" in
--apply) DRY_RUN=0 ;;
--dry-run) DRY_RUN=1 ;;
--yes) CFM_YES=1 ;;
*) CFM_ARGS="$CFM_ARGS $a" ;;
esac
done
}
# dry "<label>" -- cmd... → prints WOULD in dry-run, else runs.
dry() {
_label="$1"; shift
[ "$1" = "--" ] && shift
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: $_label"
echo " \$ $*"
return 0
fi
echo "RUN: $_label"
"$@"
}
confirm_cutover() {
[ "${CFM_YES:-0}" = 1 ] && return 0
echo
echo "This mutates PRODUCTION DNS for $ZONE_NAME."
printf 'Type CUTOVER to proceed: '
read -r _reply
[ "$_reply" = "CUTOVER" ] || die "aborted (got '$_reply')"
}
dry_banner() {
if [ "$DRY_RUN" = 1 ]; then
echo "(dry run — nothing will be changed. re-run with --apply to act.)"
fi
}

View file

@ -1,11 +1,15 @@
#!/usr/bin/env bash
# The one HTTP parity sweep. Sourced by the verify scripts and run against four
# bases, so "did anything change?" is always the same question:
# The one HTTP parity sweep. Point it at any base URL and it asks the same
# question, so a regression has nowhere to hide:
#
# https://www.pcbjam.com live Vercel (baseline)
# http://127.0.0.1:8788 wrangler pages dev
# https://<hash>.pcbjam-site.pages.dev the Pages deployment, pre-DNS
# https://www.pcbjam.com after cutover
# https://www.pcbjam.com production
# https://<hash>.pcbjam-site.pages.dev a specific deployment, before promoting
# http://127.0.0.1:8788 `wrangler pages dev` locally
#
# It was written for the Vercel -> Cloudflare Pages migration and several
# assertions exist because that migration found real defects; the comments say
# which, since the reason is the only thing stopping someone "simplifying" them
# back out.
#
# Usage: assert_parity <base-url> [--scope local|preview|prod] [--live-post]
# Exit: 0 if every hard assertion passed (warnings do not fail), else 1.
@ -331,8 +335,8 @@ assert_apex_serves() {
return $_rc
}
# APEX_MODE=redirect: apex -> www, path + query preserved. Also used by
# 00-baseline.sh, since that is what Vercel does today.
# APEX_MODE=redirect: apex -> www, path + query preserved. Unused with the
# current `serve` topology; kept in case that decision is revisited.
assert_apex_redirect() {
_apex="${1:-$APEX_BASE}"
section "apex redirect: $_apex"

118
deploy/site/verify.sh Executable file
View file

@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Health check for www.pcbjam.com — the Astro marketing site on Cloudflare Pages.
#
# deploy/site/verify.sh # production
# PROD_BASE=https://abc123.pcbjam-site.pages.dev \
# deploy/site/verify.sh --skip-dns --skip-domains # one deployment
# EXPECT_HSTS=1 deploy/site/verify.sh # strict on HSTS
#
# Read-only: it makes GET/OPTIONS/POST requests and changes nothing. The POSTs are
# side-effect-free — the full-path probe uses the endpoint's honeypot branch,
# which returns before validation, before the rate limiter and before any Resend
# call, so it sends no mail and creates no contact.
#
# Exit 0 if every hard assertion passes. Warnings do not fail.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/parity.sh"
require_cmd curl jq awk sed
SKIP_DNS=0; SKIP_DOMAINS=0
while [ $# -gt 0 ]; do
case "$1" in
--skip-dns) SKIP_DNS=1; shift ;;
--skip-domains) SKIP_DOMAINS=1; shift ;;
-h|--help) sed -n '2,14p' "$0"; exit 0 ;;
*) die "unknown arg: $1" ;;
esac
done
rc=0
if [ "$SKIP_DNS" = 0 ]; then
require_cmd dig
section "DNS"
# Both hosts are PROXIED Pages custom domains, so they answer with Cloudflare
# anycast A records and expose no CNAME — an empty CNAME here is the expected
# state, not a problem. Failing to resolve at all is what's worth catching.
for h in "$ZONE_NAME" "www.$ZONE_NAME"; do
a="$(dig +short A "$h" | tr '\n' ' ')"
printf ' %-20s A=%s\n' "$h" "${a:-<none>}"
[ -n "$a" ] || { echo " FAIL $h does not resolve"; rc=1; }
done
fi
section "served by Cloudflare"
h="$(_headers "$PROD_BASE/")"
if [ -n "$(_hdr "$h" cf-ray)" ]; then
echo " PASS cf-ray present"
else
echo " FAIL no cf-ray — not being served through Cloudflare"; rc=1
fi
assert_parity "$PROD_BASE" --scope prod || rc=$?
assert_apex "$APEX_BASE" || rc=$?
section "cross-origin isolation on BOTH URL forms of the Gerber post"
# Explicit and duplicated on purpose. The bare form 308s to the trailing-slash
# form, and the trailing-slash form is the page's own canonical — what search
# sends people to. Scoping these headers to one form only is exactly the defect
# this site shipped with before the migration, and it is silent: the page renders
# fine, the embedded viewer just loses SharedArrayBuffer and degrades.
for path in /blog/porting-kicad-graphics-to-webgl-in-2026 /blog/porting-kicad-graphics-to-webgl-in-2026/; do
eff="$(_trace "$PROD_BASE$path" | cut -f1)"
hh="$(_headers "$eff")"
coop="$(_hdr "$hh" cross-origin-opener-policy)"; coep="$(_hdr "$hh" cross-origin-embedder-policy)"
if [ "$coop" = same-origin ] && [ "$coep" = require-corp ]; then
echo " PASS $path -> isolated"
else
echo " FAIL $path -> coop='${coop:-absent}' coep='${coep:-absent}' ($eff)"; rc=1
fi
done
section "demo integration (load-bearing)"
# demo.pcbjam.com has no backend of its own and cross-posts the waitlist form
# here. A CORS preflight cannot follow a redirect, so this must be 204 with zero
# hops — put a redirect in front of the endpoint and the demo breaks silently.
hops="$(curl -sS -o /dev/null -w '%{num_redirects}' -X OPTIONS "$PROD_BASE/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null || echo 9)"
st="$(curl -sS -o /dev/null -w '%{http_code}' -X OPTIONS "$PROD_BASE/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' -H 'Access-Control-Request-Method: POST' 2>/dev/null || true)"
if [ "$st" = "204" ] && [ "$hops" = "0" ]; then
echo " PASS preflight 204 with 0 redirects"
else
echo " FAIL status=$st hops=$hops (both 204 and 0 hops are required)"; rc=1
fi
if [ "$SKIP_DOMAINS" = 0 ]; then
section "custom domains on $PAGES_PROJECT"
doms="$($WRANGLER pages project list --json 2>/dev/null \
| jq -r --arg n "$PAGES_PROJECT" '.[] | select(."Project Name"==$n) | ."Project Domains"' || true)"
if [ -z "$doms" ]; then
warn "could not list projects (is \`wrangler login\` valid?) — skipping"
else
echo " $doms"
want="www.$ZONE_NAME"
[ "${APEX_MODE:-serve}" = serve ] && want="$want $ZONE_NAME"
for d in $want; do
case ",$(printf '%s' "$doms" | tr -d ' ')," in
*",$d,"*) echo " PASS $d attached" ;;
*) echo " FAIL $d NOT attached"; rc=1 ;;
esac
done
fi
fi
section "summary"
if [ "$rc" -ne 0 ]; then
echo "FAILED. To recover, promote a previous deployment:"
echo " Workers & Pages -> $PAGES_PROJECT -> Deployments -> Rollback to this deployment"
echo "or redeploy a known-good tree (git stays the source of truth):"
echo " git checkout <good-commit> -- site/"
echo " (cd site && npm ci && npm run build && npx --yes wrangler@4 pages deploy \\"
echo " --project-name $PAGES_PROJECT --branch production)"
exit 1
fi
echo "All checks passed."
echo "done: $PROD_BASE"

View file

@ -94,4 +94,5 @@ carries the COOP/COEP rules the embedded Gerber viewer needs. Do **not** widen
them to `/*` — the landing page must stay un-isolated so the YouTube hero embed
loads.
The full one-time setup and the cutover runbook are in `../deploy/site/README.md`.
One-time setup, the invariants that fail silently if changed, and the health
check (`deploy/site/verify.sh`) are in `../deploy/site/README.md`.

View file

@ -12,8 +12,8 @@
# and locally come from .dev.vars (gitignored). WAITLIST_ALLOWED_ORIGINS is
# deliberately unset so it keeps the default in functions/api/waitlist.ts.
#
# The custom domain (www.pcbjam.com) is attached out of band too — there is no
# `wrangler pages domain` subcommand; see deploy/site/README.md.
# The custom domains (www.pcbjam.com and the apex) are attached out of band too —
# there is no `wrangler pages domain` subcommand; see deploy/site/README.md.
name = "pcbjam-site"
pages_build_output_dir = "./dist"