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
96 lines
4 KiB
Shell
Executable file
96 lines
4 KiB
Shell
Executable file
#!/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)"
|