www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
121 lines
5 KiB
Shell
Executable file
121 lines
5 KiB
Shell
Executable file
#!/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"
|