feat(deploy/site): serve the apex from the same Pages project, no redirect rule

Vercel was doing the apex->www 308 itself (its "redirect to www" project
setting), so nothing about Cloudflare requires a redirect — the behaviour
just disappears with Vercel. Rather than rebuild it with a zone Redirect
Rule plus a proxied placeholder record, attach pcbjam.com as a SECOND
custom domain on pcbjam-site. Both hosts serve the site and the pages
already emit canonical=www, which is what consolidates them for search.

That drops the riskiest artefact in the migration. Redirect Rules are
zone-scoped and run BEFORE Workers/Pages routing, so a `contains` match
instead of `eq` would 308 app./editor./demo./api. to www — breaking the
product API, not just a marketing page. The sibling hosts are also the
reason this was worth avoiding rather than merely guarding.

APEX_MODE (lib/common.sh) selects the topology, defaulting to `serve`.
08-verify-prod.sh now dispatches through assert_apex: in serve mode it
requires the apex to answer 200 with no hop, to not be a stale Vercel
response, to declare canonical=www, and to expose /api/waitlist. The
`redirect` mode and 07's rules/apex phases are kept for the alternative.

08 also checks the attached domains via wrangler rather than the REST API,
so the whole serve-mode path needs only `wrangler login` — no zone scopes
at all.

Comments that explained themselves via the old redirect are corrected:
astro.config.mjs, web/standalone/src/lib/config.ts and
scripts/deploy/build-demo.mjs. The demo keeps posting to www — not because
the apex redirects, but because a CORS preflight cannot follow one, so
aiming at a host that might ever redirect is a latent breakage.

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 14:34:16 +02:00
commit df38ebafb7
7 changed files with 138 additions and 50 deletions

View file

@ -35,7 +35,7 @@ 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_redirect "$APEX_BASE" || 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.
@ -65,15 +65,20 @@ else
echo " FAIL status=$st hops=$hops (both 204 and 0 hops are required)"; rc=1
fi
section "custom domain status"
dom="$(cf_pages_domains 2>/dev/null || true)"
if [ -n "$dom" ]; then
printf '%s' "$dom" | jq -r '.result[]? | " \(.name): \(.status)"'
printf '%s' "$dom" | jq -e --arg n "www.$ZONE_NAME" \
'[.result[]? | select(.name==$n and .status=="active")] | length > 0' >/dev/null 2>&1 \
&& echo " PASS www.$ZONE_NAME active" \
|| { echo " FAIL www.$ZONE_NAME not active"; 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

View file

@ -54,48 +54,64 @@ Two things you can get wrong here, both of which fail quietly:
## Migration / cutover (one time, from Vercel)
Every mutating script is **dry-run by default**; add `--apply`. Read the dry-run
output before applying — that is the whole point of the split.
Two supported apex topologies, selected by `APEX_MODE` (see `lib/common.sh`):
| when | command | live? |
- **`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.
Every mutating script is **dry-run by default**; add `--apply`. Read the dry-run
output before applying — that is the point of the split.
| when | action | live? |
|---|---|---|
| Tdays | `00-baseline.sh` | no — read-only |
| Tdays | `01-preflight.sh` | no — read-only |
| Tdays | `07-dns-cutover.sh --phase probe --apply` | no — throwaway hostname |
| 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 | `07-dns-cutover.sh --phase rules --apply` | no — inert until the apex is proxied |
| T1d | `07-dns-cutover.sh --phase hsts --apply` | no — additive |
| T24h | `07-dns-cutover.sh --phase prelower --apply` | no — TTL 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** | `07-dns-cutover.sh --phase swap --apply` | **yes** |
| T+2m | `07-dns-cutover.sh --phase apex --apply` | yes (auto-rollback armed) |
| **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 |
Rollback, any time: **`99-rollback.sh --apply --yes`**. Keep it ready in a second
terminal during the swap.
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.
### Why the swap is a PATCH, not a delete + create
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.
A single `PATCH` flips the `www` record's 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 queries in that instant caches NODATA for
the zone's **SOA minimum** — typically 1800s. That is an un-flushable ~30-minute
partial outage. `00-baseline.sh` records the zone's actual value so the exposure
is a number, not a guess.
### Attach, don't delete-and-recreate
The residual risk with `PATCH` is HTTP-only and self-healing: for a second or two
the edge has no route for the hostname and serves the Pages not-found page.
Universal SSL already covers `*.pcbjam.com`, so TLS is never in question.
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.
`--phase probe` settles whether Pages will attach a custom domain over an
existing CNAME, days early, on a throwaway hostname. `--swap-mode auto` reads
that result and only falls back to `delete-create` if it has to.
Throughout, Vercel stays attached, so resolvers still holding the old answer keep
serving the identical site. The cutover is a fade, not a switch.
`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

View file

@ -23,6 +23,13 @@ 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"

View file

@ -262,7 +262,62 @@ assert_parity() {
echo "parity: PASS"; return 0
}
# Apex -> www redirect, path + query preserved. Prod only.
# Dispatch on the chosen apex topology (see APEX_MODE in lib/common.sh).
assert_apex() {
case "${APEX_MODE:-serve}" in
serve) assert_apex_serves "${1:-$APEX_BASE}" ;;
redirect) assert_apex_redirect "${1:-$APEX_BASE}" ;;
*) die "APEX_MODE must be 'serve' or 'redirect' (got '${APEX_MODE}')" ;;
esac
}
# APEX_MODE=serve: the apex is its own Pages custom domain. It must answer 200
# directly (no hop), and it must still declare www as canonical — that tag is the
# only thing consolidating the two hostnames for search, since we are deliberately
# not redirecting.
assert_apex_serves() {
_apex="${1:-$APEX_BASE}"
section "apex serves directly: $_apex (APEX_MODE=serve)"
_rc=0
_t="$(_trace "$_apex/")"
_st="$(printf '%s' "$_t" | cut -f2)"; _hops="$(printf '%s' "$_t" | cut -f3)"
if [ "$_st" = "200" ]; then
echo "PASS $_apex/ -> 200 (hops $_hops)"
else
echo "FAIL $_apex/ -> $_st (expected 200; is the apex attached as a custom domain?)"; _rc=1
fi
_h="$(_headers "$_apex/")"
if [ -n "$(_hdr "$_h" x-vercel-id)" ]; then
echo "FAIL x-vercel-id present on the apex — still Vercel (stale DNS, not a pass)"; _rc=1
else
echo "PASS no x-vercel-id on the apex"
fi
_can="$($CURL -L "$_apex/" 2>/dev/null | tr -d '\n' \
| sed -n 's/.*rel="canonical" href="\([^"]*\)".*/\1/p' | head -1)"
case "$_can" in
https://www.pcbjam.com/*|https://www.pcbjam.com)
echo "PASS apex declares canonical $_can" ;;
*)
echo "FAIL apex canonical is '${_can:-absent}' — must point at www, otherwise the"
echo " two hostnames compete instead of consolidating"; _rc=1 ;;
esac
# The demo cross-posts to www, but if anyone ever points it at the apex, the
# endpoint has to be reachable there too. Cheap to confirm.
_pre="$($CURL -o /dev/null -w '%{http_code}' -X OPTIONS "$_apex/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' \
-H 'Access-Control-Request-Method: POST' 2>/dev/null || true)"
[ "$_pre" = "204" ] && echo "PASS apex /api/waitlist preflight 204" \
|| { echo "FAIL apex /api/waitlist preflight $_pre"; _rc=1; }
return $_rc
}
# APEX_MODE=redirect: apex -> www, path + query preserved. Also used by
# 00-baseline.sh, since that is what Vercel does today.
assert_apex_redirect() {
_apex="${1:-$APEX_BASE}"
section "apex redirect: $_apex"

View file

@ -58,11 +58,12 @@ function parseArgs(argv) {
a.cdn = a.cdn.replace(/\/+$/, "");
a.repo = a.repo.replace(/\/+$/, "");
a.landing = a.landing.replace(/\/+$/, "");
// The cross-origin demo POSTs the waitlist to the marketing site's serverless
// endpoint. The apex (pcbjam.com) 308-redirects to www on Vercel, and a CORS
// preflight can't follow redirects, so the demo must hit the canonical www host
// directly. Derive from --landing for custom/staging hosts, but pin the
// production apex to www. (Landing/version-badge link stays on the apex.)
// The cross-origin demo POSTs the waitlist to the marketing site's endpoint (a
// Cloudflare Pages Function). Always target the canonical www host: a CORS
// preflight cannot follow a redirect, so aiming at a host that might ever
// redirect is a latent breakage. Derive from --landing for custom/staging
// hosts, but pin the production apex to www. (The landing/version-badge link
// stays on the apex.)
const waitlistHost =
a.landing === "https://pcbjam.com" ? "https://www.pcbjam.com" : a.landing;
a.waitlist = a.waitlist || `${waitlistHost}/api/waitlist`;

View file

@ -7,8 +7,10 @@ import mdx from '@astrojs/mdx';
// The one dynamic route, /api/waitlist, is a Cloudflare Pages Function in
// functions/ rather than an Astro SSR route. See README.md ("Deploying").
export default defineConfig({
// Canonical origin (apex 308s to www). Without this, prerendered Astro.url
// is localhost, which leaked into canonical/OG tags on production.
// Canonical origin. Both www.pcbjam.com and the apex are custom domains on the
// same Pages project and serve identically, so this tag is what consolidates
// them for search — keep it pointing at www. Without `site` at all, prerendered
// Astro.url is localhost, which leaked into canonical/OG tags on production.
site: 'https://www.pcbjam.com',
output: 'static',
// Deliberately NO trailingSlash setting: Astro's default emits the canonical

View file

@ -64,8 +64,10 @@ export const APP_URL =
* Where the in-editor waitlist form POSTs. The demo is a fully static deploy with
* no backend, so it cross-posts to the landing site's serverless endpoint (which
* sends CORS for this origin). Same JSON contract as site/src/pages/api/waitlist.ts.
* Targets the canonical www host: the apex 308-redirects to www on Vercel, and a
* CORS preflight can't follow a redirect (so the apex would break the POST).
* Targets the canonical www host. The apex now serves the same Pages project
* rather than redirecting, so either would work but keep www: it is the
* canonical host, and a CORS preflight cannot follow a redirect, so pointing at
* a host that might ever redirect is a latent breakage.
*/
export const WAITLIST_URL =
import.meta.env.VITE_WAITLIST_URL || "https://www.pcbjam.com/api/waitlist";