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:
parent
04ce88f8a4
commit
df38ebafb7
7 changed files with 138 additions and 50 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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? |
|
||||
|---|---|---|
|
||||
| T−days | `00-baseline.sh` | no — read-only |
|
||||
| T−days | `01-preflight.sh` | no — read-only |
|
||||
| T−days | `07-dns-cutover.sh --phase probe --apply` | no — throwaway hostname |
|
||||
| T−days | `02-verify-local.sh` | no |
|
||||
| T−days | `03-ensure-project.sh --apply` → `04-set-secrets.sh --apply` | new project only |
|
||||
| T−days | `05-deploy.sh --preview --apply` → `06-verify-deploy.sh --latest` | no — pages.dev only |
|
||||
| T−1d | `07-dns-cutover.sh --phase rules --apply` | no — inert until the apex is proxied |
|
||||
| T−1d | `07-dns-cutover.sh --phase hsts --apply` | no — additive |
|
||||
| T−24h | `07-dns-cutover.sh --phase prelower --apply` | no — TTL only |
|
||||
| T−1d | *(optional)* HSTS: `07-dns-cutover.sh --phase hsts --apply`, or the dashboard | no — additive |
|
||||
| T−24h | *(optional)* `07-dns-cutover.sh --phase prelower --apply` — www TTL → 60 | no — TTL only |
|
||||
| T−1h | `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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue