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
71 lines
2.6 KiB
Shell
Executable file
71 lines
2.6 KiB
Shell
Executable file
#!/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"
|