chore(deploy/site): retire the migration scaffolding, keep the health check
The Vercel -> Cloudflare Pages move is done and the Vercel project is deleted, so the one-shot scripts have no remaining purpose. Nothing in CI ever called them — deploy-site.yml runs npm ci / test / build / pages deploy inline — so this removes 10 files and orphans nothing. Deleted: 00-baseline (refused to run without x-vercel-id, so permanently unrunnable), 01-preflight (proved Vercel state and API-token scopes), 07-dns-cutover (the phased cutover; in the end the records were attached through the dashboard, and the rules/apex phases went unused once we chose APEX_MODE=serve), 09-detach-vercel (its target project is gone), plus 03-ensure-project, 04-set-secrets, 05-deploy, 06-verify-deploy, 02-verify-local and 99-rollback, all either spent or duplicating CI. Their lib/cf-api.sh went with them: the survivors use wrangler, so the whole remaining path needs only `wrangler login` and no zone scopes. What is kept is the part with ongoing value: lib/parity.sh, the assertion set that caught five real defects during the migration — the live COOP/COEP bug on the post's canonical URL, the soft-404 Pages would have introduced, the cross-site form-POST guard Vercel had been providing for free, the missing immutable header, and HSTS max-age=0. "Does the page return 200" catches none of those. 08-verify-prod.sh becomes verify.sh, since the numbered sequence it belonged to no longer exists. It drops the stamp machinery, the dry-run plumbing and the Vercel-fallback messaging (there is no fallback now: recovery is promoting a previous Pages deployment), and gains --skip-dns / --skip-domains so it can be pointed at a single deployment via PROD_BASE before promoting it. The README is rewritten around the four invariants that fail SILENTLY — never widen _headers to /*, keep both URL forms of the Gerber post, never delete 404.astro, keep the cross-site form-POST guard — each with the reason, since the reason is the only thing that stops someone simplifying them back out. Verified after: 21 probes, 20 pass, 1 warn (HSTS max-age is 6 months vs Vercel's 2 years — on, just shorter), 0 fail. 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
3259fb3c85
commit
fdc4583288
20 changed files with 228 additions and 1631 deletions
3
.github/workflows/deploy-site.yml
vendored
3
.github/workflows/deploy-site.yml
vendored
|
|
@ -19,7 +19,8 @@ name: deploy-site (marketing site)
|
|||
# from site/functions/. Its secrets (RESEND_API_KEY, RESEND_SEGMENT_ID,
|
||||
# WAITLIST_FROM_EMAIL) are NOT deploy inputs: set once with
|
||||
# `wrangler pages secret put <NAME> --project-name pcbjam-site`.
|
||||
# See deploy/site/README.md for the full runbook.
|
||||
# deploy/site/README.md documents the setup, the invariants that fail silently,
|
||||
# and deploy/site/verify.sh (a fuller health check than step 5 below).
|
||||
#
|
||||
# Secrets (Settings → Secrets → Actions) — already present for demo/editor:
|
||||
# CLOUDFLARE_API_TOKEN Cloudflare Pages:Edit
|
||||
|
|
|
|||
|
|
@ -39,4 +39,5 @@ from; because it pins the kicad + wxwidgets submodule revisions implicitly, it i
|
|||
our GPLv3 corresponding-source pointer (see /licenses). It resolves automatically
|
||||
at build time in site/src/components/Footer.astro (CF_PAGES_COMMIT_SHA / GITHUB_SHA
|
||||
in CI, `git rev-parse` locally) — no manual bump needed.
|
||||
The Cloudflare setup + cutover runbook is in pcbjam/deploy/site/README.md.
|
||||
Cloudflare setup, the invariants that fail silently, and the health check
|
||||
(deploy/site/verify.sh) are documented in pcbjam/deploy/site/README.md.
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Freeze the LIVE Vercel behaviour as the reference every later step is compared
|
||||
# against. Read-only; needs no Cloudflare credentials. Run this while Vercel is
|
||||
# still serving — you cannot re-create it afterwards.
|
||||
#
|
||||
# deploy/site/00-baseline.sh [--force]
|
||||
#
|
||||
# Two probes are EXPECTED to fail here: post_coi (the blog post's COOP/COEP) is
|
||||
# genuinely broken in production today — the page's canonical is the
|
||||
# trailing-slash URL and that URL serves 200 with no isolation headers. Recording
|
||||
# it is the point: 08-verify-prod.sh asserts those same probes PASS afterwards,
|
||||
# so the migration proves it fixed the bug rather than porting it.
|
||||
set -euo pipefail
|
||||
. "$(dirname "$0")/lib/common.sh"
|
||||
. "$(dirname "$0")/lib/parity.sh"
|
||||
|
||||
require_cmd curl dig awk sed jq
|
||||
|
||||
OUT="$STATE_DIR/baseline/vercel"
|
||||
if [ -d "$OUT" ] && [ "${1:-}" != "--force" ]; then
|
||||
die "baseline already exists at $OUT — refusing to overwrite (use --force).
|
||||
Re-baselining after cutover would silently replace the reference."
|
||||
fi
|
||||
mkdir -p "$OUT"
|
||||
|
||||
section "confirming $PROD_BASE is still served by Vercel"
|
||||
hdrs="$(_headers "$PROD_BASE/")"
|
||||
if [ -z "$(_hdr "$hdrs" x-vercel-id)" ]; then
|
||||
die "no x-vercel-id header on $PROD_BASE — this host is not on Vercel any more.
|
||||
Baselining a Cloudflare response as 'the Vercel reference' would be useless."
|
||||
fi
|
||||
echo "ok: x-vercel-id present"
|
||||
|
||||
section "DNS + SOA snapshot"
|
||||
{
|
||||
echo "# captured $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "apex_cname=$(dig +short CNAME "$ZONE_NAME" || true)"
|
||||
echo "www_cname=$(dig +short CNAME "www.$ZONE_NAME" || true)"
|
||||
echo "ns=$(dig +short NS "$ZONE_NAME" | sort | tr '\n' ' ')"
|
||||
# The SOA minimum is the NEGATIVE cache TTL. It is the number that makes
|
||||
# delete-then-create dangerous: a resolver that asks while the record is gone
|
||||
# caches NODATA for this long, and you cannot flush it.
|
||||
echo "soa=$(dig +short SOA "$ZONE_NAME" || true)"
|
||||
echo "soa_minimum_ttl=$(dig +short SOA "$ZONE_NAME" | awk '{print $NF}')"
|
||||
} | tee "$OUT/dns.txt"
|
||||
|
||||
section "page titles (used to detect a soft-404 later)"
|
||||
printf 'home_title=%s\n' "$(_title "$PROD_BASE/")" | tee "$OUT/titles.txt"
|
||||
|
||||
section "header snapshots"
|
||||
for p in / /pricing /blog /privacy /blog/porting-kicad-graphics-to-webgl-in-2026 /gerber-demo/boot.js; do
|
||||
f="$(printf '%s' "$p" | sed 's|/|_|g')"; [ "$f" = "_" ] && f="_home"
|
||||
t="$(_trace "$PROD_BASE$p")"; eff="$(printf '%s' "$t" | cut -f1)"
|
||||
{
|
||||
echo "# requested: $PROD_BASE$p"
|
||||
echo "# effective: $eff (hops $(printf '%s' "$t" | cut -f3))"
|
||||
# Drop volatile headers so a later diff shows real changes, not timestamps.
|
||||
_headers "$eff" | grep -vE '^(date|age|etag|last-modified|content-length|server|cf-ray|cf-cache-status|nel|report-to|alt-svc|set-cookie|x-vercel-id|x-vercel-cache|x-matched-path|expires|via):' | sort
|
||||
} > "$OUT/$f.headers"
|
||||
echo " $p -> $OUT/$f.headers"
|
||||
done
|
||||
|
||||
rc=0
|
||||
assert_parity "$PROD_BASE" --scope prod || rc=$?
|
||||
assert_apex_redirect "$APEX_BASE" || rc=$?
|
||||
|
||||
{
|
||||
echo "# baseline captured $(date -u +%Y-%m-%dT%H:%M:%SZ) against $PROD_BASE (Vercel)"
|
||||
echo "# parity exit code: $rc (non-zero is EXPECTED — see the header of this script)"
|
||||
} > "$OUT/parity-exit.txt"
|
||||
|
||||
section "done"
|
||||
echo "Baseline written to $OUT"
|
||||
echo
|
||||
echo "Read the FAIL rows above and keep them: they are the 'before' half of the"
|
||||
echo "COOP/COEP fix. 08-verify-prod.sh requires those same probes to pass."
|
||||
echo "done: file://$OUT"
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Prove every credential and token scope the later steps need, and snapshot DNS
|
||||
# for rollback. Read-only — makes no changes anywhere.
|
||||
#
|
||||
# export CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=...
|
||||
# deploy/site/01-preflight.sh
|
||||
#
|
||||
# Re-run this freely; it is the script to come back to after fixing a scope or
|
||||
# logging wrangler in.
|
||||
set -euo pipefail
|
||||
. "$(dirname "$0")/lib/common.sh"
|
||||
. "$(dirname "$0")/lib/cf-api.sh"
|
||||
|
||||
require_cmd curl dig jq awk node npm npx shasum
|
||||
|
||||
ok=0; bad=0
|
||||
chk() { # chk "<label>" <0|1> ["remediation"]
|
||||
if [ "$2" = 0 ]; then printf ' ok %s\n' "$1"; ok=$((ok+1))
|
||||
else printf ' FAIL %s\n' "$1"; [ -n "${3:-}" ] && printf ' -> %s\n' "$3"; bad=$((bad+1)); fi
|
||||
}
|
||||
|
||||
section "toolchain"
|
||||
nv="$(node -v | sed 's/^v//')"
|
||||
node_ok=1
|
||||
# Astro 6 needs >= 22.12.0
|
||||
maj="${nv%%.*}"; rest="${nv#*.}"; min="${rest%%.*}"
|
||||
if [ "$maj" -gt 22 ] 2>/dev/null || { [ "$maj" = 22 ] && [ "$min" -ge 12 ]; } 2>/dev/null; then node_ok=0; fi
|
||||
chk "node $nv >= 22.12 (Astro 6)" $node_ok "install Node 22.12+ (nvm use 22)"
|
||||
chk "wrangler reachable ($($WRANGLER --version 2>/dev/null | head -1))" \
|
||||
"$($WRANGLER --version >/dev/null 2>&1; echo $?)"
|
||||
|
||||
section "cloudflare auth"
|
||||
if [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then
|
||||
chk "CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID exported" 0
|
||||
tv="$(cf_api GET /user/tokens/verify 2>/dev/null || true)"
|
||||
st="$(printf '%s' "$tv" | jq -r '.result.status // "unknown"' 2>/dev/null || echo unknown)"
|
||||
chk "token status = active (got '$st')" "$([ "$st" = active ] && echo 0 || echo 1)"
|
||||
else
|
||||
chk "CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID exported" 1 \
|
||||
"the API steps (03,07,09) need both. Create a token with the scopes listed in lib/cf-api.sh"
|
||||
wo="$($WRANGLER whoami 2>&1 || true)"
|
||||
case "$wo" in
|
||||
*"Not logged in"*|*"could not be refreshed"*)
|
||||
chk "wrangler logged in" 1 "run: $WRANGLER login (the local token is expired)" ;;
|
||||
*) chk "wrangler logged in" 0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "${CLOUDFLARE_API_TOKEN:-}" ]; then
|
||||
section "zone"
|
||||
zid="$(cf_zone_id 2>/dev/null || true)"
|
||||
chk "zone $ZONE_NAME resolved (${zid:-none})" "$([ -n "$zid" ] && echo 0 || echo 1)"
|
||||
|
||||
if [ -n "$zid" ]; then
|
||||
section "token scopes (each probe maps to one permission)"
|
||||
cf_api GET "/zones/$zid/dns_records?per_page=5" >/dev/null 2>&1
|
||||
chk "Zone -> DNS -> Read/Edit" $?
|
||||
# 404 here is fine and expected — it means the redirect phase has no ruleset
|
||||
# yet. 403 is the failure we are probing for.
|
||||
rs="$($CURL_BIN -sS "$CF_API/zones/$zid/rulesets/phases/http_request_dynamic_redirect/entrypoint" \
|
||||
-H "Authorization: Bearer $(cf_token)" || true)"
|
||||
code="$(printf '%s' "$rs" | jq -r '.errors[0].code // "ok"' 2>/dev/null)"
|
||||
chk "Zone -> Dynamic Redirect -> Edit (probe code=$code)" \
|
||||
"$([ "$code" = "ok" ] || [ "$code" = "10000" ] || printf '%s' "$rs" | jq -e '.success==false and (.errors[0].code==1002 or .errors[0].code==10000)' >/dev/null 2>&1; echo $?)" \
|
||||
"add 'Dynamic Redirect: Edit' (sometimes shown as Config/Transform Rules) to the token"
|
||||
cf_api GET "/zones/$zid/settings/security_header" >/dev/null 2>&1
|
||||
chk "Zone -> Zone Settings -> Edit (HSTS)" $?
|
||||
cf_api GET "/accounts/$(cf_account)/pages/projects" >/dev/null 2>&1
|
||||
chk "Account -> Cloudflare Pages -> Edit" $?
|
||||
|
||||
section "free-plan redirect headroom"
|
||||
nrules="$(printf '%s' "$rs" | jq '.result.rules | length' 2>/dev/null || echo 0)"
|
||||
[ "$nrules" = "null" ] && nrules=0
|
||||
chk "existing dynamic redirects: $nrules (< 10 on the free plan)" \
|
||||
"$([ "$nrules" -lt 10 ] && echo 0 || echo 1)"
|
||||
|
||||
section "DNS snapshot -> state/dns-before.json (rollback source of truth)"
|
||||
cf_dns_list > "$STATE_DIR/state/dns-before.json"
|
||||
for spec in "$ZONE_NAME:CNAME" "www.$ZONE_NAME:CNAME"; do
|
||||
n="${spec%%:*}"; t="${spec##*:}"
|
||||
c="$(jq -r --arg n "$n" --arg t "$t" '.result[]|select(.name==$n and .type==$t)|.content' \
|
||||
"$STATE_DIR/state/dns-before.json" | head -1)"
|
||||
chk "$n $t -> ${c:-MISSING} (expect $VERCEL_DNS_TARGET)" \
|
||||
"$([ "$c" = "$VERCEL_DNS_TARGET" ] && echo 0 || echo 1)" \
|
||||
"the record moved since this migration was planned — re-read the plan before continuing"
|
||||
done
|
||||
mx="$(jq -r '.result[]|select(.type=="MX")|.content' "$STATE_DIR/state/dns-before.json" | head -1)"
|
||||
chk "Google MX still present ($mx) — must survive untouched" \
|
||||
"$([ -n "$mx" ] && echo 0 || echo 1)"
|
||||
fi
|
||||
fi
|
||||
|
||||
section "nameservers"
|
||||
ns="$(dig +short NS "$ZONE_NAME" | tr '\n' ' ')"
|
||||
case "$ns" in *becky*|*ernest*) chk "on Cloudflare NS ($ns)" 0 ;;
|
||||
*) chk "on Cloudflare NS ($ns)" 1 "the zone must be on Cloudflare nameservers" ;;
|
||||
esac
|
||||
|
||||
section "vercel (env-var names for 04-set-secrets; detach/rollback for 09/99)"
|
||||
# Deliberately via npx rather than a global `vercel` binary — that is how this
|
||||
# repo invokes it everywhere else, and a global install is not a prerequisite.
|
||||
if [ "${SKIP_VERCEL_CHECK:-0}" != 1 ]; then
|
||||
names="$(npx --yes vercel@latest env ls production --project "$VERCEL_PROJECT" --scope "$VERCEL_TEAM" 2>/dev/null \
|
||||
| awk '/^ [A-Z]/{print $1}' | sort | tr '\n' ' ' || true)"
|
||||
echo " env var NAMES on Vercel (values never read): ${names:-<unavailable>}"
|
||||
[ -n "$names" ] && printf '%s\n' "$names" > "$STATE_DIR/state/vercel-env-names.txt"
|
||||
for want in RESEND_API_KEY RESEND_SEGMENT_ID WAITLIST_FROM_EMAIL; do
|
||||
case " $names " in *" $want "*) chk "$want present on Vercel" 0 ;;
|
||||
*) chk "$want present on Vercel" 1 "expected it there; 04-set-secrets.sh needs its value" ;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
warn "SKIP_VERCEL_CHECK=1 — skipping the Vercel probe"
|
||||
fi
|
||||
|
||||
section "summary"
|
||||
echo " $ok ok, $bad failed"
|
||||
[ "$bad" -eq 0 ] || die "preflight failed — fix the FAIL rows above before continuing."
|
||||
stamp_write 01-preflight "zone=${zid:-unknown}"
|
||||
echo "done: preflight ok (zone=${zid:-unknown})"
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Idempotently ensure the Cloudflare Pages project exists with the right
|
||||
# production branch and compatibility settings.
|
||||
#
|
||||
# deploy/site/03-ensure-project.sh # dry run
|
||||
# deploy/site/03-ensure-project.sh --apply
|
||||
#
|
||||
# Naming this "ensure" rather than "create" is the contract: running it twice is
|
||||
# normal and must not look like an error.
|
||||
set -euo pipefail
|
||||
. "$(dirname "$0")/lib/common.sh"
|
||||
. "$(dirname "$0")/lib/cf-api.sh"
|
||||
|
||||
require_cmd curl jq npx
|
||||
parse_common_flags "$@"
|
||||
dry_banner
|
||||
|
||||
section "project $PAGES_PROJECT"
|
||||
row="$($WRANGLER pages project list --json 2>/dev/null \
|
||||
| jq -r --arg n "$PAGES_PROJECT" '.[] | select(."Project Name"==$n)')"
|
||||
if [ -n "$row" ]; then
|
||||
echo "exists already"
|
||||
else
|
||||
dry "create Pages project $PAGES_PROJECT (production branch: $PAGES_PROD_BRANCH)" -- \
|
||||
$WRANGLER pages project create "$PAGES_PROJECT" \
|
||||
--production-branch "$PAGES_PROD_BRANCH" \
|
||||
--compatibility-date 2026-06-01 --compatibility-flags nodejs_compat
|
||||
[ "$DRY_RUN" = 1 ] && { echo; echo "done: (dry run) would create $PAGES_PROJECT"; exit 0; }
|
||||
row="$($WRANGLER pages project list --json 2>/dev/null \
|
||||
| jq -r --arg n "$PAGES_PROJECT" '.[] | select(."Project Name"==$n)')"
|
||||
[ -n "$row" ] || die "project still not listed after create"
|
||||
fi
|
||||
|
||||
section "asserting settings"
|
||||
# `wrangler pages project list` reports the Git provider but not the production
|
||||
# branch, and the REST endpoint that would needs an API token. So prove the branch
|
||||
# EMPIRICALLY instead, from the deployments: if a deployment on $PAGES_PROD_BRANCH
|
||||
# is labelled Production, the project's production branch is that branch. That is
|
||||
# a stronger check than reading the setting.
|
||||
src="$(printf '%s' "$row" | jq -r '."Git Provider" // "?"')"
|
||||
dom="$(printf '%s' "$row" | jq -r '."Project Domains" // "?"')"
|
||||
|
||||
if [ "$src" != "No" ]; then
|
||||
die "project appears Git-connected (Git Provider: $src). Cloudflare's own builds
|
||||
would race the uploads from deploy-site.yml. Disconnect it in the dashboard."
|
||||
fi
|
||||
echo " ok Direct Upload (not Git-connected)"
|
||||
|
||||
prod="$($WRANGLER pages deployment list --project-name "$PAGES_PROJECT" \
|
||||
--environment production --json 2>/dev/null || true)"
|
||||
nprod="$(printf '%s' "$prod" | jq 'length' 2>/dev/null || echo 0)"
|
||||
if [ "${nprod:-0}" -gt 0 ]; then
|
||||
br="$(printf '%s' "$prod" | jq -r '.[0].Branch // "?"')"
|
||||
env="$(printf '%s' "$prod" | jq -r '.[0].Environment // "?"')"
|
||||
# The most expensive mistake available here: if the project's production branch
|
||||
# is not what deploy-site.yml passes to --branch, every deploy lands as a
|
||||
# PREVIEW and the live site silently never updates (see deploy-demo.yml).
|
||||
if [ "$br" = "$PAGES_PROD_BRANCH" ] && [ "$env" = "Production" ]; then
|
||||
echo " ok branch '$br' deploys land as $env"
|
||||
else
|
||||
die "latest production-environment deployment is branch='$br' env='$env',
|
||||
but deploys use --branch '$PAGES_PROD_BRANCH'. If those disagree, every deploy
|
||||
becomes a preview and www.pcbjam.com never updates. Fix it in
|
||||
Pages -> $PAGES_PROJECT -> Settings -> Builds & deployments."
|
||||
fi
|
||||
else
|
||||
warn "no production deployment yet — the branch check runs after 05-deploy.sh"
|
||||
fi
|
||||
sub="$(printf '%s' "$dom" | sed 's/,.*//')"
|
||||
|
||||
section "done"
|
||||
echo "note: the custom domain is NOT set here — there is no 'wrangler pages domain'"
|
||||
echo " subcommand. 07-dns-cutover.sh attaches www.pcbjam.com via the API,"
|
||||
echo " which needs a real API token (wrangler's OAuth login is zone:read only)."
|
||||
echo "done: https://${sub}"
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Put the three waitlist secrets into the Pages project, for BOTH the production
|
||||
# and preview environments, without ever printing a value.
|
||||
#
|
||||
# deploy/site/04-set-secrets.sh --prompt --apply
|
||||
# deploy/site/04-set-secrets.sh --from-env-file site/.migration-secrets.env --apply
|
||||
#
|
||||
# Getting the values: pull them yourself, e.g.
|
||||
# cd site && npx vercel env pull .migration-secrets.env \
|
||||
# --environment=production --project pcbjam --scope pcbj-am
|
||||
# .migration-secrets.env is gitignored. Delete it when you're done.
|
||||
#
|
||||
# Pages applies environment-variable changes to NEW deployments only, so this must
|
||||
# run BEFORE 05-deploy.sh (or you must redeploy afterwards).
|
||||
set -euo pipefail
|
||||
. "$(dirname "$0")/lib/common.sh"
|
||||
|
||||
no_xtrace # never run with tracing: values would be echoed
|
||||
require_cmd npx shasum awk
|
||||
|
||||
NAMES="RESEND_API_KEY RESEND_SEGMENT_ID WAITLIST_FROM_EMAIL"
|
||||
MODE=prompt; ENV_FILE=""
|
||||
parse_common_flags "$@"
|
||||
set -- $CFM_ARGS
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--prompt) MODE=prompt; shift ;;
|
||||
--from-env-file) MODE=file; ENV_FILE="$2"; shift 2 ;;
|
||||
"") shift ;;
|
||||
*) die "unknown arg: $1" ;;
|
||||
esac
|
||||
done
|
||||
dry_banner
|
||||
|
||||
# WAITLIST_ALLOWED_ORIGINS is deliberately NOT settable here. It must stay unset
|
||||
# so the Function keeps its in-code default (https://demo.pcbjam.com), matching
|
||||
# what Vercel had. Setting it as a Pages secret would work but would hide the
|
||||
# allowlist from code review.
|
||||
guard_name() {
|
||||
case "$1" in
|
||||
WAITLIST_ALLOWED_ORIGINS)
|
||||
die "refusing to set WAITLIST_ALLOWED_ORIGINS: it is intentionally unset so
|
||||
the allowlist lives in functions/api/waitlist.ts where it is reviewable." ;;
|
||||
esac
|
||||
grep -q "$1" "$SITE_DIR/functions/api/waitlist.ts" \
|
||||
|| die "refusing to set $1: functions/api/waitlist.ts never reads it"
|
||||
}
|
||||
|
||||
read_value() { # read_value NAME -> echoes the value (never logged)
|
||||
_n="$1"
|
||||
if [ "$MODE" = file ]; then
|
||||
[ -f "$ENV_FILE" ] || die "env file not found: $ENV_FILE"
|
||||
( cd "$(dirname "$ENV_FILE")" && git check-ignore -q "$(basename "$ENV_FILE")" ) \
|
||||
|| die "$ENV_FILE is NOT gitignored — refusing to read secrets from a trackable file"
|
||||
awk -F= -v k="$_n" '$1==k { sub(/^[^=]*=/,""); gsub(/^"|"$/,""); print; exit }' "$ENV_FILE"
|
||||
else
|
||||
printf 'value for %s (input hidden, empty to skip): ' "$_n" >&2
|
||||
read -r -s _v; echo >&2
|
||||
printf '%s' "$_v"
|
||||
fi
|
||||
}
|
||||
|
||||
section "setting secrets on $PAGES_PROJECT"
|
||||
set_count=0
|
||||
for n in $NAMES; do
|
||||
guard_name "$n"
|
||||
v="$(read_value "$n")"
|
||||
if [ -z "$v" ]; then warn "$n: empty, skipped"; continue; fi
|
||||
# Length + short hash only. Enough to compare against Vercel without ever
|
||||
# revealing the value.
|
||||
fp="$(printf '%s' "$v" | shasum -a 256 | cut -c1-8)"
|
||||
echo " $n: len=$(printf '%s' "$v" | wc -c | tr -d ' ') sha256=$fp"
|
||||
for envname in production preview; do
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "WOULD: pages secret put $n --project-name $PAGES_PROJECT --env $envname (value via stdin)"
|
||||
else
|
||||
# stdin, never argv — an argv value would be visible in `ps`.
|
||||
printf '%s' "$v" | $WRANGLER pages secret put "$n" \
|
||||
--project-name "$PAGES_PROJECT" --env "$envname" >/dev/null \
|
||||
|| die "failed to set $n ($envname)"
|
||||
echo " set: $n ($envname)"
|
||||
fi
|
||||
done
|
||||
set_count=$((set_count+1))
|
||||
unset v
|
||||
done
|
||||
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
section "verifying (names only)"
|
||||
for envname in production preview; do
|
||||
have="$($WRANGLER pages secret list --project-name "$PAGES_PROJECT" --env "$envname" 2>/dev/null || true)"
|
||||
for n in $NAMES; do
|
||||
case "$have" in *"$n"*) echo " ok $n ($envname)" ;;
|
||||
*) die "$n missing from $envname after setting it" ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
stamp_write 04-secrets "count=$set_count"
|
||||
fi
|
||||
|
||||
section "done"
|
||||
echo "Pages applies env changes to NEW deployments only — run 05-deploy.sh next."
|
||||
echo "If you used --from-env-file, delete it now: rm -f $ENV_FILE"
|
||||
echo "done: $set_count secrets set (production + preview)"
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Sweep a Pages deployment on its *.pages.dev URL — the real Cloudflare runtime,
|
||||
# before any DNS is touched. One script for both the preview and the production
|
||||
# deployment; only the stamp it writes differs.
|
||||
#
|
||||
# deploy/site/06-verify-deploy.sh --latest
|
||||
# deploy/site/06-verify-deploy.sh --url https://abc123.pcbjam-site.pages.dev --scope prod-deploy
|
||||
set -euo pipefail
|
||||
. "$(dirname "$0")/lib/common.sh"
|
||||
. "$(dirname "$0")/lib/parity.sh"
|
||||
. "$(dirname "$0")/lib/cf-api.sh"
|
||||
|
||||
require_cmd curl jq awk sed
|
||||
|
||||
URL=""; SCOPE=preview
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--url) URL="$2"; shift 2 ;;
|
||||
--latest) URL=""; shift ;;
|
||||
--scope) SCOPE="$2"; shift 2 ;;
|
||||
*) die "unknown arg: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$URL" ] && [ -s "$STATE_DIR/state/last-deploy.json" ]; then
|
||||
URL="$(jq -r .url "$STATE_DIR/state/last-deploy.json")"
|
||||
fi
|
||||
[ -n "$URL" ] || die "no deployment URL — pass --url, or run 05-deploy.sh first"
|
||||
|
||||
section "target"
|
||||
echo " $URL (scope=$SCOPE)"
|
||||
|
||||
rc=0
|
||||
assert_parity "$URL" --scope preview || rc=$?
|
||||
|
||||
section "Pages-specific checks"
|
||||
# If the Function were not bundled (e.g. deployed from the wrong directory) the
|
||||
# whole endpoint would simply be a static 404. The parity sweep would catch it,
|
||||
# but say so explicitly — it is the most likely deploy-time mistake.
|
||||
st="$(curl -sS -o /dev/null -w '%{http_code}' -X OPTIONS "$URL/api/waitlist" \
|
||||
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null || true)"
|
||||
if [ "$st" = "404" ]; then
|
||||
echo "FAIL /api/waitlist is 404 — the Function was not bundled."
|
||||
echo " wrangler discovers functions/ relative to \$PWD; deploy from site/."
|
||||
rc=1
|
||||
else
|
||||
echo "PASS /api/waitlist is routed (status $st)"
|
||||
fi
|
||||
|
||||
# _routes.json restricts the Function to /api/*. Confirm that did not also break
|
||||
# static 404 handling for everything else.
|
||||
nf="$(curl -sS -L -o /dev/null -w '%{http_code}' "$URL/__cfm-parity-404__/" 2>/dev/null || true)"
|
||||
[ "$nf" = "404" ] && echo "PASS static 404 handling intact under _routes.json" \
|
||||
|| { echo "FAIL unknown path returned $nf, expected 404"; rc=1; }
|
||||
|
||||
if [ "$SCOPE" = prod-deploy ]; then
|
||||
section "confirming this is the PRODUCTION deployment"
|
||||
# Via wrangler, not the REST API: `wrangler login` is enough for this, whereas
|
||||
# the REST call would need an API token that the Pages steps otherwise don't.
|
||||
latest="$($WRANGLER pages deployment list --project-name "$PAGES_PROJECT" \
|
||||
--environment production --json 2>/dev/null || true)"
|
||||
envname="$(printf '%s' "$latest" | jq -r '.[0].Environment // "?"' 2>/dev/null || echo '?')"
|
||||
did="$(printf '%s' "$latest" | jq -r '.[0].Id // "?"' 2>/dev/null || echo '?')"
|
||||
brn="$(printf '%s' "$latest" | jq -r '.[0].Branch // "?"' 2>/dev/null || echo '?')"
|
||||
if [ "$envname" != "Production" ]; then
|
||||
echo "FAIL latest deployment environment is '$envname', not 'Production'"
|
||||
echo " (a branch name other than $PAGES_PROD_BRANCH makes it a preview)"
|
||||
rc=1
|
||||
else
|
||||
echo "PASS latest deployment is Production (branch=$brn id=$did)"
|
||||
fi
|
||||
fi
|
||||
|
||||
section "diff vs the Vercel baseline (informational)"
|
||||
BASE="$STATE_DIR/baseline/vercel"
|
||||
if [ -d "$BASE" ]; then
|
||||
OUT="$STATE_DIR/baseline/pages-$(date +%s)"; mkdir -p "$OUT"
|
||||
for p in / /pricing /blog /privacy /blog/porting-kicad-graphics-to-webgl-in-2026 /gerber-demo/boot.js; do
|
||||
f="$(printf '%s' "$p" | sed 's|/|_|g')"; [ "$f" = "_" ] && f="_home"
|
||||
eff="$(_trace "$URL$p" | cut -f1)"
|
||||
_headers "$eff" | grep -vE '^(date|age|etag|last-modified|content-length|server|cf-ray|cf-cache-status|nel|report-to|alt-svc|set-cookie|x-vercel-id|x-vercel-cache|x-matched-path|expires|via):' | sort > "$OUT/$f.headers"
|
||||
if [ -f "$BASE/$f.headers" ]; then
|
||||
d="$(diff -u "$BASE/$f.headers" "$OUT/$f.headers" || true)"
|
||||
[ -n "$d" ] && { echo "--- $p"; printf '%s\n' "$d" | sed -n '3,$p'; }
|
||||
fi
|
||||
done
|
||||
echo "(header differences above are for human review, not assertions)"
|
||||
else
|
||||
warn "no baseline at $BASE — run 00-baseline.sh while Vercel is still live"
|
||||
fi
|
||||
|
||||
[ "$rc" -eq 0 ] || die "deployment verification FAILED — do not cut DNS."
|
||||
|
||||
case "$SCOPE" in
|
||||
prod-deploy) stamp_write 06-prod-deploy-parity "url=$URL" "deployment_id=${did:-unknown}" ;;
|
||||
*) stamp_write 06-preview-parity "url=$URL" ;;
|
||||
esac
|
||||
section "done"
|
||||
echo "done: verified $URL"
|
||||
|
|
@ -1,347 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Move www.pcbjam.com from Vercel to the Pages project, and point the apex at a
|
||||
# 308 redirect to www. THE ONLY SCRIPT HERE THAT AFFECTS LIVE TRAFFIC.
|
||||
#
|
||||
# deploy/site/07-dns-cutover.sh --phase probe # dry run (default)
|
||||
# deploy/site/07-dns-cutover.sh --phase probe --apply
|
||||
# ...
|
||||
# deploy/site/07-dns-cutover.sh --phase swap --apply
|
||||
# deploy/site/07-dns-cutover.sh --rollback --apply
|
||||
#
|
||||
# Phases, in the order they should be run. Only `swap` and `apex` are live:
|
||||
#
|
||||
# prelower T-24h drop the www TTL to 60 so the cut AND any rollback
|
||||
# propagate in ~1 min instead of ~5.
|
||||
# probe T-days create a throwaway hostname, attach it as a Pages custom
|
||||
# domain, delete both. Settles — at zero risk to www —
|
||||
# whether Pages will attach over an existing CNAME, and
|
||||
# measures how long validation takes.
|
||||
# rules T-1d create the apex->www redirect rule. INERT until the apex is
|
||||
# proxied, so it is safe to create early and verify later.
|
||||
# hsts T-1d enable zone HSTS at Vercel's existing max-age. Additive.
|
||||
# swap T+0 www -> Pages. See "the window" below.
|
||||
# apex T+2m apex -> proxied placeholder, which activates the rule.
|
||||
#
|
||||
# THE WINDOW. The swap is an in-place PATCH, not a delete-then-create, and that
|
||||
# distinction is the single most important safety property here. A PATCH changes
|
||||
# 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 asks in that instant caches NODATA for the zone's SOA minimum (typically
|
||||
# 1800s) — an un-flushable ~30-minute partial outage. 00-baseline.sh records the
|
||||
# actual SOA minimum so you can see the exposure. The residual risk with PATCH is
|
||||
# HTTP-only and self-healing: for a second or two 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.
|
||||
#
|
||||
# Vercel stays attached throughout, so resolvers still holding the old answer keep
|
||||
# serving the identical site. This is a fade, not a switch.
|
||||
set -euo pipefail
|
||||
. "$(dirname "$0")/lib/common.sh"
|
||||
. "$(dirname "$0")/lib/cf-api.sh"
|
||||
. "$(dirname "$0")/lib/parity.sh"
|
||||
|
||||
require_cmd curl dig jq awk
|
||||
|
||||
PHASE=""; ROLLBACK=0; SWAP_MODE=auto
|
||||
parse_common_flags "$@"
|
||||
set -- $CFM_ARGS
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--phase) PHASE="$2"; shift 2 ;;
|
||||
--swap-mode) SWAP_MODE="$2"; shift 2 ;;
|
||||
--rollback) ROLLBACK=1; shift ;;
|
||||
"") shift ;;
|
||||
*) die "unknown arg: $1" ;;
|
||||
esac
|
||||
done
|
||||
dry_banner
|
||||
|
||||
ZID="$(cf_zone_id)"
|
||||
PROBE_HOST="cf-attach-probe.$ZONE_NAME"
|
||||
SNAP="$STATE_DIR/state/dns-before.json"
|
||||
[ -s "$SNAP" ] || die "no DNS snapshot at $SNAP — run 01-preflight.sh first (it is the rollback source of truth)"
|
||||
|
||||
# ---------------------------------------------------------------- rollback ----
|
||||
if [ "$ROLLBACK" = 1 ]; then
|
||||
section "rollback: restore the Vercel CNAMEs and disable the redirect rule"
|
||||
for n in "www.$ZONE_NAME" "$ZONE_NAME"; do
|
||||
for t in CNAME A; do
|
||||
id="$(cf_dns_find "$n" "$t")"
|
||||
[ -n "$id" ] || continue
|
||||
cf_dns_guard "$id" "$n"
|
||||
dry "restore $n -> $VERCEL_DNS_TARGET (CNAME, unproxied, ttl 60)" -- true
|
||||
[ "$DRY_RUN" = 0 ] && cf_api PUT "/zones/$ZID/dns_records/$id" \
|
||||
"$(jq -n --arg n "$n" --arg c "$VERCEL_DNS_TARGET" \
|
||||
'{type:"CNAME",name:$n,content:$c,proxied:false,ttl:60}')" >/dev/null
|
||||
break
|
||||
done
|
||||
done
|
||||
if [ -s "$STATE_DIR/state/redirect-rule.json" ]; then
|
||||
rsid="$(jq -r .ruleset_id "$STATE_DIR/state/redirect-rule.json")"
|
||||
rid="$(jq -r .rule_id "$STATE_DIR/state/redirect-rule.json")"
|
||||
dry "disable redirect rule $rid" -- true
|
||||
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/rulesets/$rsid/rules/$rid" \
|
||||
'{"enabled":false}' >/dev/null || true
|
||||
fi
|
||||
echo
|
||||
echo "The Pages project, its deployments and secrets are left intact — re-cutting"
|
||||
echo "over later is just: 07-dns-cutover.sh --phase swap --apply"
|
||||
echo "done: rolled back (www ttl=60)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -n "$PHASE" ] || die "pass --phase prelower|probe|rules|hsts|swap|apex (or --rollback)"
|
||||
|
||||
# ---------------------------------------------------------------- prelower ----
|
||||
phase_prelower() {
|
||||
section "prelower: www TTL -> 60"
|
||||
id="$(cf_dns_find "www.$ZONE_NAME" CNAME)"
|
||||
[ -n "$id" ] || die "no www CNAME found"
|
||||
cf_dns_guard "$id" "www.$ZONE_NAME"
|
||||
cur="$(cf_api GET "/zones/$ZID/dns_records/$id")"
|
||||
content="$(printf '%s' "$cur" | jq -r .result.content)"
|
||||
ttl="$(printf '%s' "$cur" | jq -r .result.ttl)"
|
||||
proxied="$(printf '%s' "$cur" | jq -r .result.proxied)"
|
||||
echo " current: $content ttl=$ttl proxied=$proxied"
|
||||
[ "$proxied" = "false" ] || die "record is proxied; TTL is not settable while proxied (and it should still be Vercel's at this point)"
|
||||
dry "PATCH www TTL 60" -- true
|
||||
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/dns_records/$id" '{"ttl":60}' >/dev/null
|
||||
echo "done: www ttl 60 (was $ttl)"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- probe ----
|
||||
phase_probe() {
|
||||
section "probe: can Pages attach a custom domain over an existing CNAME?"
|
||||
echo "Uses the throwaway host $PROBE_HOST. www is never touched."
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "WOULD: POST /zones/$ZID/dns_records (CNAME $PROBE_HOST -> $PAGES_PROJECT.pages.dev, proxied)"
|
||||
echo "WOULD: POST /accounts/<acct>/pages/projects/$PAGES_PROJECT/domains {\"name\":\"$PROBE_HOST\"}"
|
||||
echo "WOULD: poll until status=active, curl it, then DELETE both"
|
||||
exit 0
|
||||
fi
|
||||
rid="$(cf_api POST "/zones/$ZID/dns_records" \
|
||||
"$(jq -n --arg n "$PROBE_HOST" --arg c "$PAGES_PROJECT.pages.dev" \
|
||||
'{type:"CNAME",name:$n,content:$c,proxied:true,ttl:1,comment:"cf-migrate probe - safe to delete"}')" \
|
||||
| jq -r .result.id)"
|
||||
echo " created probe record $rid"
|
||||
attach_rc=0
|
||||
cf_api POST "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains" \
|
||||
"$(jq -n --arg n "$PROBE_HOST" '{name:$n}')" >/dev/null 2>&1 || attach_rc=$?
|
||||
t0=$(date +%s); status=unknown
|
||||
if [ "$attach_rc" = 0 ]; then
|
||||
for _ in $(seq 1 60); do
|
||||
status="$(cf_pages_domains | jq -r --arg n "$PROBE_HOST" '.result[]|select(.name==$n)|.status' | head -1)"
|
||||
[ "$status" = active ] && break
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
secs=$(( $(date +%s) - t0 ))
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$PROBE_HOST/" 2>/dev/null || echo 000)"
|
||||
echo " attach_rc=$attach_rc status=$status seconds_to_active=$secs http=$code"
|
||||
|
||||
if [ "$attach_rc" = 0 ] && [ "$status" = active ]; then
|
||||
printf 'ok seconds_to_active=%s\n' "$secs" > "$STATE_DIR/state/attach-over-existing-record.ok"
|
||||
rm -f "$STATE_DIR/state/attach-over-existing-record.unsupported"
|
||||
echo " => in-place PATCH swap is supported (swap-mode auto will use it)"
|
||||
else
|
||||
printf 'unsupported attach_rc=%s status=%s\n' "$attach_rc" "$status" \
|
||||
> "$STATE_DIR/state/attach-over-existing-record.unsupported"
|
||||
rm -f "$STATE_DIR/state/attach-over-existing-record.ok"
|
||||
warn "attach over an existing record did NOT work; swap-mode auto will fall back to delete-create"
|
||||
warn "read the NODATA warning in this script's header before running the swap"
|
||||
fi
|
||||
|
||||
cf_api DELETE "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains/$PROBE_HOST" >/dev/null 2>&1 || true
|
||||
cf_dns_guard "$rid" "$PROBE_HOST"
|
||||
cf_api DELETE "/zones/$ZID/dns_records/$rid" >/dev/null
|
||||
echo "done: probe cleaned up"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- rules ----
|
||||
phase_rules() {
|
||||
section "rules: apex -> www, 308, path + query preserved"
|
||||
ep="$($CURL_BIN -sS "$CF_API/zones/$ZID/rulesets/phases/http_request_dynamic_redirect/entrypoint" \
|
||||
-H "Authorization: Bearer $(cf_token)")"
|
||||
have="$(printf '%s' "$ep" | jq -r '.success')"
|
||||
rule="$(jq -n --arg h "$ZONE_NAME" --arg w "https://www.$ZONE_NAME" '{
|
||||
ref: "cfm_apex_to_www",
|
||||
description: "apex \($h) -> www (308, preserve query) [cf-migrate]",
|
||||
expression: "(http.host eq \"\($h)\")",
|
||||
action: "redirect",
|
||||
action_parameters: { from_value: {
|
||||
target_url: { expression: "concat(\"\($w)\", http.request.uri.path)" },
|
||||
status_code: 308,
|
||||
preserve_query_string: true } },
|
||||
enabled: true }')"
|
||||
|
||||
# Exact host match only. A `contains` match would also catch app./demo./editor.
|
||||
printf '%s' "$rule" | jq -e '.expression | contains("eq")' >/dev/null \
|
||||
|| die "refusing a redirect expression that is not an exact host match"
|
||||
|
||||
if [ "$have" = "true" ]; then
|
||||
rsid="$(printf '%s' "$ep" | jq -r '.result.id')"
|
||||
n="$(printf '%s' "$ep" | jq '.result.rules | length // 0')"
|
||||
echo " existing ruleset $rsid with $n rule(s) — APPENDING"
|
||||
# POST /rules appends. A PUT on the entrypoint would replace the whole rule
|
||||
# list and silently delete unrelated redirects.
|
||||
[ "$n" -lt 10 ] || die "$n dynamic redirects already exist (free plan allows 10)"
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "WOULD: POST /zones/$ZID/rulesets/$rsid/rules"; printf '%s\n' "$rule" | jq .
|
||||
return 0
|
||||
fi
|
||||
out="$(cf_api POST "/zones/$ZID/rulesets/$rsid/rules" "$rule")"
|
||||
else
|
||||
echo " no dynamic-redirect ruleset yet — creating the phase entrypoint"
|
||||
body="$(jq -n --argjson r "$rule" '{name:"Redirect rules ruleset",kind:"zone",phase:"http_request_dynamic_redirect",rules:[$r]}')"
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "WOULD: POST /zones/$ZID/rulesets"; printf '%s\n' "$body" | jq .
|
||||
return 0
|
||||
fi
|
||||
out="$(cf_api POST "/zones/$ZID/rulesets" "$body")"
|
||||
rsid="$(printf '%s' "$out" | jq -r '.result.id')"
|
||||
fi
|
||||
|
||||
rid="$(printf '%s' "$out" | jq -r '.result.rules[]? | select(.ref=="cfm_apex_to_www") | .id' | head -1)"
|
||||
jq -n --arg rs "$rsid" --arg r "$rid" '{ruleset_id:$rs,rule_id:$r}' \
|
||||
> "$STATE_DIR/state/redirect-rule.json"
|
||||
echo " recorded ruleset=$rsid rule=$rid"
|
||||
echo
|
||||
echo "NOT verified yet, deliberately: the rule cannot fire until the apex record"
|
||||
echo "is proxied. --phase apex asserts it."
|
||||
echo "done: redirect rule created (inert)"
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------- hsts ----
|
||||
phase_hsts() {
|
||||
section "hsts: max-age 63072000 (matching what Vercel sends today)"
|
||||
cur="$(cf_api GET "/zones/$ZID/settings/security_header")"
|
||||
printf '%s' "$cur" | jq -c '.result.value' 2>/dev/null || true
|
||||
body='{"value":{"strict_transport_security":{"enabled":true,"max_age":63072000,"include_subdomains":false,"preload":false,"nosniff":true}}}'
|
||||
# include_subdomains / preload are effectively irreversible (browsers cache the
|
||||
# directive for the max-age, and preload lists are slow to leave). Refused.
|
||||
echo " include_subdomains=false, preload=false — both deliberately off."
|
||||
echo " Note this is ZONE-WIDE: cdn/app/demo/editor responses gain the header too."
|
||||
echo " All are already HTTPS-only, so it is a no-op for them."
|
||||
dry "PATCH zone HSTS" -- true
|
||||
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/settings/security_header" "$body" >/dev/null
|
||||
echo "done: HSTS enabled"
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------- swap ----
|
||||
phase_swap() {
|
||||
section "swap: www.$ZONE_NAME -> Pages"
|
||||
stamp_require 06-prod-deploy-parity
|
||||
|
||||
# Refuse to cut DNS onto a deployment other than the one that was verified.
|
||||
want="$(jq -r '.deployment_id // "unknown"' "$STATE_DIR/stamps/06-prod-deploy-parity.$(ctx_hash)" 2>/dev/null \
|
||||
|| grep -h '^deployment_id=' "$STATE_DIR/stamps/06-prod-deploy-parity.$(ctx_hash)" 2>/dev/null | cut -d= -f2)"
|
||||
have="$(cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/deployments?per_page=1" \
|
||||
| jq -r '.result[0].id // "none"')"
|
||||
if [ -n "$want" ] && [ "$want" != unknown ] && [ "$want" != "$have" ]; then
|
||||
die "verified deployment ($want) is not the current production deployment ($have).
|
||||
Re-run 06-verify-deploy.sh --scope prod-deploy before cutting DNS."
|
||||
fi
|
||||
echo " verified deployment == current production deployment ($have)"
|
||||
|
||||
id="$(cf_dns_find "www.$ZONE_NAME" CNAME)"
|
||||
[ -n "$id" ] || die "no www CNAME found"
|
||||
cf_dns_guard "$id" "www.$ZONE_NAME"
|
||||
cur="$(cf_api GET "/zones/$ZID/dns_records/$id" | jq -r .result.content)"
|
||||
snap="$(jq -r --arg n "www.$ZONE_NAME" '.result[]|select(.name==$n and .type=="CNAME")|.content' "$SNAP" | head -1)"
|
||||
[ "$cur" = "$snap" ] || die "www currently points at '$cur' but the snapshot says '$snap' — someone else changed it. Stop and re-check."
|
||||
|
||||
mode="$SWAP_MODE"
|
||||
if [ "$mode" = auto ]; then
|
||||
if [ -f "$STATE_DIR/state/attach-over-existing-record.ok" ]; then mode=update
|
||||
else mode=delete-create
|
||||
warn "no successful --phase probe result on record; falling back to delete-create"
|
||||
warn "that risks NODATA negative-caching for the SOA minimum. Run --phase probe first."
|
||||
fi
|
||||
fi
|
||||
echo " swap mode: $mode"
|
||||
confirm_cutover
|
||||
|
||||
if [ "$mode" = update ]; then
|
||||
dry "PATCH www -> $PAGES_PROJECT.pages.dev (proxied, atomic — no DNS gap)" -- true
|
||||
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/dns_records/$id" \
|
||||
"$(jq -n --arg n "www.$ZONE_NAME" --arg c "$PAGES_PROJECT.pages.dev" \
|
||||
'{type:"CNAME",name:$n,content:$c,proxied:true,ttl:1,comment:"Pages custom domain [cf-migrate]"}')" >/dev/null
|
||||
else
|
||||
dry "DELETE www record, then immediately attach (leaves a brief NODATA window)" -- true
|
||||
[ "$DRY_RUN" = 0 ] && cf_api DELETE "/zones/$ZID/dns_records/$id" >/dev/null
|
||||
fi
|
||||
|
||||
dry "POST pages custom domain www.$ZONE_NAME" -- true
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
cf_api POST "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains" \
|
||||
"$(jq -n --arg n "www.$ZONE_NAME" '{name:$n}')" >/dev/null 2>&1 \
|
||||
|| warn "attach returned an error — it may already be attached; checking status"
|
||||
for _ in $(seq 1 60); do
|
||||
s="$(cf_pages_domains | jq -r --arg n "www.$ZONE_NAME" '.result[]|select(.name==$n)|.status' | head -1)"
|
||||
echo " domain status: ${s:-<absent>}"
|
||||
[ "$s" = active ] && break
|
||||
sleep 5
|
||||
done
|
||||
# Host-header probe: proves the edge routes the hostname to Pages regardless
|
||||
# of what any resolver currently caches.
|
||||
hp="$(curl -sS -o /dev/null -w '%{http_code}' -H "Host: www.$ZONE_NAME" \
|
||||
"https://$PAGES_PROJECT.pages.dev/" 2>/dev/null || echo 000)"
|
||||
echo " host-header probe via pages.dev: $hp"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "done: www swapped. Next: --phase apex, then 08-verify-prod.sh"
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------- apex ----
|
||||
phase_apex() {
|
||||
section "apex: $ZONE_NAME -> proxied placeholder (activates the redirect rule)"
|
||||
# Order matters: a proxied apex pointing at TEST-NET with no rule serves 522s.
|
||||
[ -s "$STATE_DIR/state/redirect-rule.json" ] \
|
||||
|| die "no redirect rule recorded — run --phase rules first, or the apex will 522"
|
||||
rsid="$(jq -r .ruleset_id "$STATE_DIR/state/redirect-rule.json")"
|
||||
rid="$(jq -r .rule_id "$STATE_DIR/state/redirect-rule.json")"
|
||||
en="$(cf_api GET "/zones/$ZID/rulesets/$rsid" | jq -r --arg r "$rid" '.result.rules[]|select(.id==$r)|.enabled')"
|
||||
[ "$en" = "true" ] || die "redirect rule $rid is not enabled — refusing to proxy the apex"
|
||||
echo " redirect rule $rid is enabled"
|
||||
|
||||
id="$(cf_dns_find "$ZONE_NAME" CNAME)"
|
||||
[ -n "$id" ] || id="$(cf_dns_find "$ZONE_NAME" A)"
|
||||
[ -n "$id" ] || die "no apex record found"
|
||||
cf_dns_guard "$id" "$ZONE_NAME"
|
||||
before="$(cf_api GET "/zones/$ZID/dns_records/$id" | jq -c '{type:.result.type,content:.result.content,proxied:.result.proxied}')"
|
||||
echo " before: $before"
|
||||
|
||||
# A single PUT changes CNAME -> A atomically; no delete/create gap.
|
||||
dry "PUT apex -> A 192.0.2.0 proxied (placeholder; requests never reach it)" -- true
|
||||
if [ "$DRY_RUN" = 1 ]; then echo; echo "done: (dry run)"; return 0; fi
|
||||
cf_api PUT "/zones/$ZID/dns_records/$id" \
|
||||
"$(jq -n --arg n "$ZONE_NAME" \
|
||||
'{type:"A",name:$n,content:"192.0.2.0",proxied:true,ttl:1,comment:"placeholder for apex->www redirect rule [cf-migrate]"}')" >/dev/null
|
||||
|
||||
section "verifying the 308 (auto-rollback armed)"
|
||||
ok=1
|
||||
for _ in $(seq 1 12); do
|
||||
if assert_apex_redirect "$APEX_BASE" >/dev/null 2>&1; then ok=0; break; fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$ok" != 0 ]; then
|
||||
warn "apex redirect did not come up — rolling the apex record back"
|
||||
cf_api PUT "/zones/$ZID/dns_records/$id" \
|
||||
"$(jq -n --arg n "$ZONE_NAME" --arg c "$VERCEL_DNS_TARGET" \
|
||||
'{type:"CNAME",name:$n,content:$c,proxied:false,ttl:60}')" >/dev/null
|
||||
die "apex rolled back to Vercel. Investigate the redirect rule, then retry."
|
||||
fi
|
||||
assert_apex_redirect "$APEX_BASE"
|
||||
echo "done: apex 308s to www"
|
||||
}
|
||||
|
||||
case "$PHASE" in
|
||||
prelower) phase_prelower ;;
|
||||
probe) phase_probe ;;
|
||||
rules) phase_rules ;;
|
||||
hsts) phase_hsts ;;
|
||||
swap) phase_swap ;;
|
||||
apex) phase_apex ;;
|
||||
*) die "unknown phase '$PHASE' (prelower|probe|rules|hsts|swap|apex)" ;;
|
||||
esac
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# The go/no-go after cutover. Read-only.
|
||||
#
|
||||
# deploy/site/08-verify-prod.sh
|
||||
#
|
||||
# Also asserts the two probes that FAILED in 00-baseline.sh now pass, so the
|
||||
# migration demonstrably fixed the COOP/COEP bug rather than carrying it over.
|
||||
set -euo pipefail
|
||||
. "$(dirname "$0")/lib/common.sh"
|
||||
. "$(dirname "$0")/lib/parity.sh"
|
||||
. "$(dirname "$0")/lib/cf-api.sh"
|
||||
|
||||
require_cmd curl dig jq awk sed
|
||||
|
||||
rc=0
|
||||
|
||||
section "DNS now resolves to Cloudflare"
|
||||
# A PROXIED record answers with Cloudflare anycast A records and exposes no CNAME,
|
||||
# so an empty CNAME lookup here is the expected result once the custom domain is
|
||||
# attached — not a problem. What would be wrong is failing to resolve at all, or
|
||||
# still CNAMEing to Vercel. The authoritative "are we on Cloudflare" check is the
|
||||
# cf-ray / x-vercel-id pair below.
|
||||
for h in "$ZONE_NAME" "www.$ZONE_NAME"; do
|
||||
a="$(dig +short A "$h" | tr '\n' ' ')"
|
||||
c="$(dig +short CNAME "$h" | tr '\n' ' ')"
|
||||
printf ' %-20s A=%s%s\n' "$h" "${a:-<none>}" "${c:+ CNAME=$c}"
|
||||
if [ -z "$a" ]; then echo " FAIL $h does not resolve"; rc=1; fi
|
||||
case "$c" in
|
||||
*vercel-dns*) echo " FAIL $h still CNAMEs to Vercel — not propagated, or the attach did not happen"; rc=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
section "we are actually being served by Cloudflare, not a stale Vercel cache"
|
||||
h="$(_headers "$PROD_BASE/")"
|
||||
if [ -n "$(_hdr "$h" x-vercel-id)" ]; then
|
||||
echo " FAIL x-vercel-id still present — you are seeing cached DNS."
|
||||
echo " Wait for the TTL and re-run; this is NOT a pass."
|
||||
rc=1
|
||||
else
|
||||
echo " PASS no x-vercel-id"
|
||||
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 "$APEX_BASE" || rc=$?
|
||||
|
||||
section "the two baseline failures must now pass"
|
||||
# This is the whole point of having captured a baseline: prove the fix.
|
||||
for path in /blog/porting-kicad-graphics-to-webgl-in-2026 /blog/porting-kicad-graphics-to-webgl-in-2026/; do
|
||||
eff="$(_trace "$PROD_BASE$path" | cut -f1)"
|
||||
hh="$(_headers "$eff")"
|
||||
coop="$(_hdr "$hh" cross-origin-opener-policy)"; coep="$(_hdr "$hh" cross-origin-embedder-policy)"
|
||||
if [ "$coop" = same-origin ] && [ "$coep" = require-corp ]; then
|
||||
echo " PASS $path -> isolated ($eff)"
|
||||
else
|
||||
echo " FAIL $path -> coop='${coop:-absent}' coep='${coep:-absent}' ($eff)"
|
||||
echo " this is the bug the migration was supposed to fix"
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
|
||||
section "the demo integration (load-bearing)"
|
||||
# demo.pcbjam.com has no backend; it cross-posts here. A CORS preflight cannot
|
||||
# follow a redirect, so this must be 204 on www with zero hops.
|
||||
hops="$(curl -sS -o /dev/null -w '%{num_redirects}' -X OPTIONS "$PROD_BASE/api/waitlist" \
|
||||
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null || echo 9)"
|
||||
st="$(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 || true)"
|
||||
if [ "$st" = "204" ] && [ "$hops" = "0" ]; then
|
||||
echo " PASS preflight 204 with 0 redirects"
|
||||
else
|
||||
echo " FAIL status=$st hops=$hops (both 204 and 0 hops are required)"; 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
|
||||
echo "PROD VERIFICATION FAILED."
|
||||
echo "Rollback is one command: deploy/site/99-rollback.sh --apply"
|
||||
exit 1
|
||||
fi
|
||||
stamp_write 08-prod-parity
|
||||
echo "All checks passed. Vercel still holds the domains as a fallback;"
|
||||
echo "detach them after a soak: deploy/site/09-detach-vercel.sh --apply"
|
||||
echo "done: $PROD_BASE"
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
#!/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)"
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# One command, incident-grade: put www.pcbjam.com and the apex back on Vercel.
|
||||
#
|
||||
# deploy/site/99-rollback.sh # dry run — read it first
|
||||
# deploy/site/99-rollback.sh --apply --yes
|
||||
#
|
||||
# Order matters: re-attach at Vercel FIRST so the target exists before DNS points
|
||||
# at it, then restore DNS, then disable the redirect rule.
|
||||
#
|
||||
# Leaves the Pages project, its deployments and its secrets alone — re-cutting
|
||||
# over later is just `07-dns-cutover.sh --phase swap --apply`.
|
||||
set -euo pipefail
|
||||
. "$(dirname "$0")/lib/common.sh"
|
||||
. "$(dirname "$0")/lib/cf-api.sh"
|
||||
|
||||
require_cmd curl dig jq
|
||||
SKIP_VERCEL=0
|
||||
parse_common_flags "$@"
|
||||
set -- $CFM_ARGS
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skip-vercel) SKIP_VERCEL=1; shift ;;
|
||||
"") shift ;;
|
||||
*) die "unknown arg: $1" ;;
|
||||
esac
|
||||
done
|
||||
dry_banner
|
||||
|
||||
HERE="$(dirname "$0")"
|
||||
FLAGS=""; [ "$DRY_RUN" = 0 ] && FLAGS="--apply"
|
||||
|
||||
section "1/3 re-attach the domains at Vercel"
|
||||
if [ "$SKIP_VERCEL" = 1 ]; then
|
||||
echo " skipped (--skip-vercel)"
|
||||
elif [ -z "${VERCEL_TOKEN:-}" ]; then
|
||||
warn "VERCEL_TOKEN unset — skipping the re-attach step."
|
||||
warn "Do it by hand NOW (Vercel -> project $VERCEL_PROJECT -> Domains -> add"
|
||||
warn "$ZONE_NAME and www.$ZONE_NAME) before the DNS change below propagates."
|
||||
else
|
||||
# shellcheck disable=SC2086
|
||||
"$HERE/09-detach-vercel.sh" --rollback $FLAGS || warn "re-attach reported an error; check the Vercel dashboard"
|
||||
fi
|
||||
|
||||
section "2/3 restore DNS + disable the redirect rule"
|
||||
# shellcheck disable=SC2086
|
||||
"$HERE/07-dns-cutover.sh" --rollback $FLAGS --yes
|
||||
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo; echo "done: (dry run) nothing changed. Re-run with --apply --yes to roll back."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
section "3/3 waiting for Vercel to serve again (www ttl is 60)"
|
||||
ok=1
|
||||
for i in $(seq 1 60); do
|
||||
h="$(curl -sS -o /dev/null -D - "$PROD_BASE/" 2>/dev/null | tr -d '\r' || true)"
|
||||
st="$(printf '%s' "$h" | awk '/^HTTP/{c=$2} END{print c}')"
|
||||
vid="$(printf '%s' "$h" | awk 'tolower($1)=="x-vercel-id:"{print $2}' | tail -1)"
|
||||
echo " [${i}] status=$st x-vercel-id=${vid:-none} cname=$(dig +short CNAME "www.$ZONE_NAME" | head -1)"
|
||||
if [ "$st" = "200" ] && [ -n "$vid" ]; then ok=0; break; fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
section "done"
|
||||
if [ "$ok" = 0 ]; then
|
||||
echo "www.$ZONE_NAME is served by Vercel again."
|
||||
else
|
||||
echo "www.$ZONE_NAME is NOT confirmably back on Vercel yet."
|
||||
echo "Check: the domains are attached in Vercel, and dig www.$ZONE_NAME."
|
||||
fi
|
||||
echo "The Pages project is untouched; re-cutover is 07-dns-cutover.sh --phase swap --apply"
|
||||
echo "done: rollback complete (re-baseline with 00-baseline.sh --force if you want a fresh reference)"
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
# www.pcbjam.com deploy runbook
|
||||
# www.pcbjam.com
|
||||
|
||||
The Astro marketing site + blog (`../../site`) on **Cloudflare Pages** (project
|
||||
`pcbjam-site`), with one **Pages Function** for `/api/waitlist`. The apex
|
||||
`pcbjam.com` 308s to `www` via a zone Redirect Rule.
|
||||
`pcbjam-site`), with one **Pages Function** for `/api/waitlist`. Both
|
||||
`www.pcbjam.com` and the apex `pcbjam.com` are custom domains on that project and
|
||||
serve identically; the pages emit `canonical=www`, which is what consolidates them
|
||||
for search.
|
||||
|
||||
```
|
||||
push to main (site/**) ──▶ .github/workflows/deploy-site.yml
|
||||
|
|
@ -16,134 +18,82 @@ push to main (site/**) ──▶ .github/workflows/deploy-site.yml
|
|||
Not tag-gated: content must not wait for a release. The WASM editor ships from
|
||||
`release.yml`; the two are independent.
|
||||
|
||||
## Layout
|
||||
## Files that carry load
|
||||
|
||||
```
|
||||
site/functions/api/waitlist.ts the only server-side code (Pages Function)
|
||||
site/public/_headers prod COOP/COEP, scoped to 2 routes
|
||||
site/public/_headers prod response headers, scoped to 3 patterns
|
||||
site/public/_routes.json only /api/* invokes the Function
|
||||
site/wrangler.toml nodejs_compat + pages_build_output_dir
|
||||
site/src/pages/404.astro required — see "soft-404" below
|
||||
site/src/pages/404.astro required — see below
|
||||
```
|
||||
|
||||
Two things you can get wrong here, both of which fail quietly:
|
||||
Four things here fail **silently** if changed carelessly. Each is asserted by
|
||||
`verify.sh`, and each is asserted because it was a real defect at some point:
|
||||
|
||||
- **Never widen `_headers` to `/*`.** `deploy/demo/_headers` does exactly that,
|
||||
which is right for the demo and wrong here: a `require-corp` document cannot
|
||||
load the no-COEP YouTube hero iframe, so the landing page must stay
|
||||
un-isolated. The sweep asserts `/` is *not* isolated for this reason.
|
||||
- **Never delete `404.astro`.** Without a `404.html` in the output, Pages answers
|
||||
every unknown URL with the **homepage at HTTP 200** — a soft-404 that invites
|
||||
search engines to index arbitrary URLs as the homepage.
|
||||
- **Never widen `_headers` to `/*`.** `../demo/_headers` does exactly that, which
|
||||
is right for the demo and wrong here: a `require-corp` document cannot load the
|
||||
no-COEP YouTube hero iframe, so the landing page must stay un-isolated.
|
||||
- **Keep both URL forms of the Gerber blog post in `_headers`.** Pages serves the
|
||||
trailing-slash form and 308s the bare form to it, and the trailing-slash form is
|
||||
the page's own canonical. Scoping the headers to one form only means search
|
||||
arrivals get a page without `SharedArrayBuffer` and the embedded viewer quietly
|
||||
degrades. That shipped in production for months.
|
||||
- **Never delete `404.astro`.** With no `404.html` in the output, Pages answers
|
||||
every unknown URL with the **homepage at HTTP 200** — a soft 404 that invites
|
||||
indexing junk URLs as the homepage.
|
||||
- **Keep the cross-site form-POST guard** in `waitlist.ts`. Vercel's edge refused
|
||||
those for free; Cloudflare does not, and a cross-site `<form>` submit needs no
|
||||
CORS permission to be *sent*, so the allowlist cannot stop it.
|
||||
|
||||
## One-time setup (Cloudflare — needs your account)
|
||||
## Verifying
|
||||
|
||||
1. `pcbjam.com` zone on Cloudflare; note the **account id**.
|
||||
2. API token with: Zone→Zone:Read, Zone→DNS:Edit, Zone→Zone Settings:Edit,
|
||||
Zone→Dynamic Redirect:Edit, Account→Cloudflare Pages:Edit.
|
||||
Export `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.
|
||||
3. Pages project `pcbjam-site`, production branch `production` —
|
||||
`03-ensure-project.sh --apply`.
|
||||
4. Secrets — `04-set-secrets.sh --apply`. `WAITLIST_ALLOWED_ORIGINS` stays
|
||||
**unset** so the allowlist lives in code.
|
||||
5. Custom domain `www.pcbjam.com` + the apex Redirect Rule —
|
||||
`07-dns-cutover.sh`. There is no `wrangler pages domain` subcommand, so this
|
||||
goes through the API (or the dashboard).
|
||||
6. The repo's GitHub secrets `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`
|
||||
already exist for the demo/editor deploys — nothing to add.
|
||||
```sh
|
||||
deploy/site/verify.sh # production
|
||||
EXPECT_HSTS=1 deploy/site/verify.sh # once HSTS max-age is set, assert it
|
||||
|
||||
## Migration / cutover (one time, from Vercel)
|
||||
# a specific deployment, before promoting it
|
||||
PROD_BASE=https://abc123.pcbjam-site.pages.dev \
|
||||
deploy/site/verify.sh --skip-dns --skip-domains
|
||||
```
|
||||
|
||||
Two supported apex topologies, selected by `APEX_MODE` (see `lib/common.sh`):
|
||||
`lib/parity.sh` holds the assertion set: pages resolve to themselves (which is how
|
||||
a soft-404 gets caught), cross-origin isolation on the right paths *and its
|
||||
absence on the landing page*, immutable asset caching, and the waitlist endpoint's
|
||||
whole contract — preflight 204 with **zero** redirects for the demo origin, CORS
|
||||
denied for others, 405 on GET, 400 on a bad address, 303 for the no-JS form, 403
|
||||
for a cross-site form POST.
|
||||
|
||||
- **`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.
|
||||
Header assertions are made on the **final** response after following redirects,
|
||||
via two separate requests (`_trace`, then `_headers`). That split is deliberate:
|
||||
the isolation bug above was invisible precisely because the headers were present
|
||||
on a redirect hop and absent on the document.
|
||||
|
||||
Every mutating script is **dry-run by default**; add `--apply`. Read the dry-run
|
||||
output before applying — that is the point of the split.
|
||||
The POSTs are safe against production. The full-path probe uses the honeypot
|
||||
branch, which returns before validation, before the rate limiter and before any
|
||||
Resend call — so it exercises routing, Functions bundling, body parsing and CORS
|
||||
while sending no mail. The one destructive probe (a *valid* email POST) is behind
|
||||
`--live-post` and is not used here.
|
||||
|
||||
| when | action | live? |
|
||||
|---|---|---|
|
||||
| T−days | `00-baseline.sh` | no — read-only |
|
||||
| T−days | `01-preflight.sh` | no — read-only |
|
||||
| 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 | *(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** | 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 |
|
||||
## Secrets
|
||||
|
||||
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.
|
||||
`RESEND_API_KEY`, `RESEND_SEGMENT_ID`, `WAITLIST_FROM_EMAIL`, set on the Pages
|
||||
project (dashboard → Settings → Variables and Secrets, or
|
||||
`wrangler pages secret put <NAME> --project-name pcbjam-site`). Pages applies
|
||||
changes to **new deployments only**, so redeploy after editing them.
|
||||
|
||||
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.
|
||||
`WAITLIST_ALLOWED_ORIGINS` is deliberately unset — it keeps its default in
|
||||
`functions/api/waitlist.ts`, where the allowlist is reviewable in code. Preview is
|
||||
deliberately keyless too: a preview holding the live key would email real people
|
||||
and write real Resend contacts.
|
||||
|
||||
### Attach, don't delete-and-recreate
|
||||
## Recovery
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
|
||||
`lib/parity.sh` is one assertion set, run against four bases: live Vercel
|
||||
(baseline), `localhost:8788`, the `*.pages.dev` deployment, then `www`. Same
|
||||
question every time, so a regression has nowhere to hide.
|
||||
|
||||
It asserts header values on the **final** response after following redirects,
|
||||
via two separate requests (`_trace` then `_headers`). That is deliberate: the
|
||||
COOP/COEP bug this migration fixes was invisible precisely because the headers
|
||||
were present on a redirect hop and absent on the document.
|
||||
|
||||
`00-baseline.sh` is **expected to report failures** — the blog post's COOP/COEP
|
||||
is genuinely broken on Vercel today (its canonical is the trailing-slash URL,
|
||||
which serves 200 with no isolation headers). `08-verify-prod.sh` requires those
|
||||
same probes to pass, which is how the fix is proven rather than assumed.
|
||||
|
||||
The honeypot POST is safe against production: that branch returns before
|
||||
validation, before the rate limiter and before any Resend call, so it exercises
|
||||
routing, Functions bundling, body parsing and CORS while sending no mail. The
|
||||
only destructive probe (a *valid* email POST) is gated behind `--live-post` and
|
||||
never runs against `www`.
|
||||
|
||||
## Gates
|
||||
|
||||
`02-verify-local.sh` writes a stamp keyed to a hash of `src/`, `public/`,
|
||||
`functions/` and the configs. `05-deploy.sh` refuses to deploy without a stamp
|
||||
for the *current* tree, and `07 --phase swap` refuses to cut DNS unless the
|
||||
verified deployment id is still the live production deployment. Override with
|
||||
`CFM_FORCE=1 CFM_I_UNDERSTAND=1`, which logs the bypass.
|
||||
|
||||
Scratch state (stamps, snapshots, logs) lives in `site/.cf-migrate/`, gitignored.
|
||||
There is no cross-vendor fallback — the Vercel project is gone. Recovery is a
|
||||
Pages concern: every deployment stays addressable at its own `*.pages.dev` URL, so
|
||||
verify a candidate first, then either roll back to it in the dashboard
|
||||
(Deployments → Rollback to this deployment) or re-deploy a known-good tree so git
|
||||
stays the source of truth.
|
||||
|
||||
## Local development
|
||||
|
||||
|
|
@ -153,3 +103,6 @@ npm run dev # Astro only — does NOT run functions/
|
|||
cp .dev.vars.example .dev.vars # gitignored
|
||||
npm run build && npm run pages:dev # http://localhost:8788, Function included
|
||||
```
|
||||
|
||||
`astro dev` does not execute `functions/`, so any change to the waitlist endpoint
|
||||
needs `pages:dev` to be exercised at all.
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Cloudflare API v4 wrapper + the DNS allowlist guard. Sourced, never executed.
|
||||
#
|
||||
# Required token scopes (one token, CLOUDFLARE_API_TOKEN):
|
||||
# Zone -> Zone -> Read (pcbjam.com)
|
||||
# Zone -> DNS -> Edit (record CRUD)
|
||||
# Zone -> Zone Settings -> Edit (HSTS / security_header)
|
||||
# Zone -> Dynamic Redirect-> Edit (http_request_dynamic_redirect ruleset)
|
||||
# Account-> Cloudflare Pages-> Edit (project, secrets, deploy, custom domains)
|
||||
|
||||
CF_API="https://api.cloudflare.com/client/v4"
|
||||
|
||||
# True when a REST token is available. `wrangler login` gives an OAuth token that
|
||||
# wrangler itself uses but that cannot be replayed as a REST bearer token, and its
|
||||
# zone scope is read-only — so the DNS/ruleset/HSTS steps need a real API token
|
||||
# while the Pages steps are happy with either. Scripts branch on this.
|
||||
cf_have_token() { [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]; }
|
||||
|
||||
cf_token() {
|
||||
[ -n "${CLOUDFLARE_API_TOKEN:-}" ] || die "CLOUDFLARE_API_TOKEN is not set.
|
||||
\`wrangler login\` is not enough for this step: its OAuth token cannot be used
|
||||
as a REST bearer token, and it only carries zone:read. Create an API token with
|
||||
the scopes at the top of this file and export it (plus CLOUDFLARE_ACCOUNT_ID)."
|
||||
printf '%s' "$CLOUDFLARE_API_TOKEN"
|
||||
}
|
||||
cf_account() {
|
||||
[ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || die "CLOUDFLARE_ACCOUNT_ID is not set"
|
||||
printf '%s' "$CLOUDFLARE_ACCOUNT_ID"
|
||||
}
|
||||
|
||||
# cf_api METHOD PATH [JSON_BODY]
|
||||
# GETs always execute (read-only). Non-GET honours DRY_RUN and prints the exact
|
||||
# call it would make, body included, so a reviewer sees it before it happens.
|
||||
cf_api() {
|
||||
_m="$1"; _p="$2"; _b="${3:-}"
|
||||
if [ "$_m" != "GET" ] && [ "${DRY_RUN:-1}" = 1 ]; then
|
||||
echo "WOULD: $_m $_p" >&2
|
||||
[ -n "$_b" ] && printf '%s\n' "$_b" | jq . >&2
|
||||
echo '{"success":true,"result":{},"dry_run":true}'
|
||||
return 0
|
||||
fi
|
||||
_log="$STATE_DIR/logs/api-$(date +%s)-$$.json"
|
||||
if [ -n "$_b" ]; then
|
||||
_out="$($CURL_BIN -sS -X "$_m" "$CF_API$_p" \
|
||||
-H "Authorization: Bearer $(cf_token)" \
|
||||
-H 'Content-Type: application/json' --data "$_b")"
|
||||
else
|
||||
_out="$($CURL_BIN -sS -X "$_m" "$CF_API$_p" \
|
||||
-H "Authorization: Bearer $(cf_token)" \
|
||||
-H 'Content-Type: application/json')"
|
||||
fi
|
||||
printf '%s' "$_out" > "$_log"
|
||||
printf '%s' "$_out" | jq -e '.success == true' >/dev/null 2>&1 \
|
||||
|| die "API $_m $_p failed — see $_log
|
||||
$(printf '%s' "$_out" | jq -r '.errors[]? | " [\(.code)] \(.message)"' 2>/dev/null)"
|
||||
printf '%s' "$_out"
|
||||
}
|
||||
CURL_BIN="${CURL_BIN:-curl}"
|
||||
|
||||
cf_zone_id() {
|
||||
if [ -s "$STATE_DIR/state/zone-id" ]; then cat "$STATE_DIR/state/zone-id"; return; fi
|
||||
_r="$(cf_api GET "/zones?name=$ZONE_NAME")"
|
||||
_n="$(printf '%s' "$_r" | jq '.result | length')"
|
||||
[ "$_n" = "1" ] || die "expected exactly 1 zone named $ZONE_NAME, found $_n"
|
||||
printf '%s' "$_r" | jq -r '.result[0].id' | tee "$STATE_DIR/state/zone-id"
|
||||
}
|
||||
|
||||
cf_dns_list() { cf_api GET "/zones/$(cf_zone_id)/dns_records?per_page=500"; }
|
||||
|
||||
# cf_dns_find <name> <type> -> record id ("" if none)
|
||||
cf_dns_find() {
|
||||
cf_dns_list | jq -r --arg n "$1" --arg t "$2" \
|
||||
'.result[] | select(.name==$n and .type==$t) | .id' | head -1
|
||||
}
|
||||
|
||||
# THE guard. Every DNS mutation passes through this first. The zone also holds
|
||||
# app/demo/editor/cdn/assets records, the Google MX and the two _vercel TXT
|
||||
# verification records — none of which this migration may touch.
|
||||
cf_dns_guard() { # cf_dns_guard <record_id> <expected_name>
|
||||
_r="$(cf_api GET "/zones/$(cf_zone_id)/dns_records/$1")"
|
||||
_n="$(printf '%s' "$_r" | jq -r .result.name)"
|
||||
_t="$(printf '%s' "$_r" | jq -r .result.type)"
|
||||
case "$_n" in
|
||||
"$ZONE_NAME"|"www.$ZONE_NAME"|"cf-attach-probe.$ZONE_NAME") : ;;
|
||||
*) die "refusing to touch DNS record '$_n' (allowlist: apex, www, cf-attach-probe)" ;;
|
||||
esac
|
||||
[ "$_n" = "$2" ] || die "record $1 is '$_n', expected '$2' (stale id? re-run 01-preflight.sh)"
|
||||
case "$_t" in
|
||||
A|CNAME) : ;;
|
||||
*) die "refusing to touch a $_t record ($_n) — only A/CNAME are in scope" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cf_pages_project() { cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT"; }
|
||||
cf_pages_domains() { cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains"; }
|
||||
|
|
@ -1,143 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
# Shared plumbing for the deploy/site cf-migrate scripts: paths, logging,
|
||||
# dry-run, stamps and guards. Sourced, never executed.
|
||||
# Shared plumbing for the site verification scripts: paths, config, logging.
|
||||
# Sourced, never executed.
|
||||
#
|
||||
# bash 3.2 compatible (/usr/bin/env bash on macOS is 3.2.57): no associative
|
||||
# arrays, no `mapfile`, no ${x,,}. Accumulators use temp files.
|
||||
|
||||
# Repo + site paths. Every script cd's to SITE_DIR before building or deploying:
|
||||
# `wrangler pages deploy` discovers Functions at $PWD/functions, so running from
|
||||
# the wrong directory silently ships a static-only site with /api/waitlist 404ing.
|
||||
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_SITE_DIR="$(dirname "$LIB_DIR")"
|
||||
PCBJAM_DIR="$(cd "$DEPLOY_SITE_DIR/../.." && pwd)"
|
||||
SITE_DIR="$PCBJAM_DIR/site"
|
||||
STATE_DIR="$SITE_DIR/.cf-migrate"
|
||||
|
||||
# Defaults, overridable by env.
|
||||
# Defaults, all overridable by env — which is how you point the sweep at a
|
||||
# preview deployment instead of production:
|
||||
# PROD_BASE=https://abc123.pcbjam-site.pages.dev deploy/site/verify.sh
|
||||
PAGES_PROJECT="${PAGES_PROJECT:-pcbjam-site}"
|
||||
PAGES_PROD_BRANCH="${PAGES_PROD_BRANCH:-production}"
|
||||
ZONE_NAME="${ZONE_NAME:-pcbjam.com}"
|
||||
PROD_BASE="${PROD_BASE:-https://www.pcbjam.com}"
|
||||
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"
|
||||
# How the apex behaves.
|
||||
# serve (current) the apex is a SECOND Pages custom domain and answers 200.
|
||||
# No redirect rule, no placeholder record. The pages emit
|
||||
# canonical=www, which is what consolidates the two hosts for search.
|
||||
# redirect the apex 308s to www. Kept because the assertion is cheap to keep
|
||||
# and the decision could be revisited; nothing sets it up any more.
|
||||
APEX_MODE="${APEX_MODE:-serve}"
|
||||
|
||||
section() { echo; echo "== $* =="; }
|
||||
warn() { echo "warn: $*" >&2; }
|
||||
die() { echo "cf-migrate: $*" >&2; exit 1; }
|
||||
die() { echo "verify: $*" >&2; exit 1; }
|
||||
|
||||
require_cmd() {
|
||||
for c in "$@"; do
|
||||
command -v "$c" >/dev/null 2>&1 || die "missing required command: $c"
|
||||
done
|
||||
}
|
||||
|
||||
# Refuse to run under `set -x` where secrets would land in the log.
|
||||
no_xtrace() {
|
||||
case "$-" in
|
||||
*x*) die "refusing to run under 'set -x' (secrets would be echoed)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Identity of "the thing that was verified" — the inputs that affect the built
|
||||
# output. Stamps are keyed on this so a stamp can never vouch for a later edit.
|
||||
ctx_hash() {
|
||||
( cd "$SITE_DIR" && \
|
||||
find src public functions astro.config.mjs package.json package-lock.json \
|
||||
wrangler.toml -type f 2>/dev/null \
|
||||
| LC_ALL=C sort | tr '\n' '\0' | xargs -0 shasum -a 256 2>/dev/null \
|
||||
| shasum -a 256 | cut -c1-16 )
|
||||
}
|
||||
|
||||
stamp_write() {
|
||||
_name="$1"; shift
|
||||
_f="$STATE_DIR/stamps/${_name}.$(ctx_hash)"
|
||||
{
|
||||
echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "ctx=$(ctx_hash)"
|
||||
echo "git=$(git -C "$PCBJAM_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown)"
|
||||
for kv in "$@"; do echo "$kv"; done
|
||||
} > "$_f"
|
||||
echo "stamped: ${_name} (ctx=$(ctx_hash))"
|
||||
}
|
||||
|
||||
# Dies unless a stamp for the CURRENT ctx_hash exists and is fresh.
|
||||
# CFM_FORCE=1 downgrades to a loud warning; it also requires CFM_I_UNDERSTAND=1.
|
||||
stamp_require() {
|
||||
_name="$1"; _max_h="${2:-${STAMP_MAX_AGE_H:-24}}"
|
||||
_f="$STATE_DIR/stamps/${_name}.$(ctx_hash)"
|
||||
if [ ! -f "$_f" ]; then
|
||||
if [ "${CFM_FORCE:-0}" = 1 ] && [ "${CFM_I_UNDERSTAND:-0}" = 1 ]; then
|
||||
warn "BYPASS: no '${_name}' stamp for the current tree, continuing anyway"
|
||||
return 0
|
||||
fi
|
||||
die "no '${_name}' stamp for the current tree (ctx=$(ctx_hash)).
|
||||
The site changed since it was last verified. Run the matching script first:
|
||||
${_name} -> see deploy/site/README.md
|
||||
To override (not advised): CFM_FORCE=1 CFM_I_UNDERSTAND=1 $0 ..."
|
||||
fi
|
||||
_age=$(( ( $(date +%s) - $(stat -f %m "$_f" 2>/dev/null || echo 0) ) / 3600 ))
|
||||
if [ "$_age" -ge "$_max_h" ]; then
|
||||
if [ "${CFM_FORCE:-0}" = 1 ] && [ "${CFM_I_UNDERSTAND:-0}" = 1 ]; then
|
||||
warn "BYPASS: '${_name}' stamp is ${_age}h old (max ${_max_h}h), continuing"
|
||||
return 0
|
||||
fi
|
||||
die "'${_name}' stamp is ${_age}h old (max ${_max_h}h) — re-verify."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- dry run ---------------------------------------------------------------
|
||||
# DRY_RUN=1 is the DEFAULT for every mutating script. `--apply` clears it.
|
||||
DRY_RUN="${DRY_RUN:-1}"
|
||||
|
||||
parse_common_flags() {
|
||||
CFM_ARGS=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--apply) DRY_RUN=0 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--yes) CFM_YES=1 ;;
|
||||
*) CFM_ARGS="$CFM_ARGS $a" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# dry "<label>" -- cmd... → prints WOULD in dry-run, else runs.
|
||||
dry() {
|
||||
_label="$1"; shift
|
||||
[ "$1" = "--" ] && shift
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "WOULD: $_label"
|
||||
echo " \$ $*"
|
||||
return 0
|
||||
fi
|
||||
echo "RUN: $_label"
|
||||
"$@"
|
||||
}
|
||||
|
||||
confirm_cutover() {
|
||||
[ "${CFM_YES:-0}" = 1 ] && return 0
|
||||
echo
|
||||
echo "This mutates PRODUCTION DNS for $ZONE_NAME."
|
||||
printf 'Type CUTOVER to proceed: '
|
||||
read -r _reply
|
||||
[ "$_reply" = "CUTOVER" ] || die "aborted (got '$_reply')"
|
||||
}
|
||||
|
||||
dry_banner() {
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "(dry run — nothing will be changed. re-run with --apply to act.)"
|
||||
fi
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
#!/usr/bin/env bash
|
||||
# The one HTTP parity sweep. Sourced by the verify scripts and run against four
|
||||
# bases, so "did anything change?" is always the same question:
|
||||
# The one HTTP parity sweep. Point it at any base URL and it asks the same
|
||||
# question, so a regression has nowhere to hide:
|
||||
#
|
||||
# https://www.pcbjam.com live Vercel (baseline)
|
||||
# http://127.0.0.1:8788 wrangler pages dev
|
||||
# https://<hash>.pcbjam-site.pages.dev the Pages deployment, pre-DNS
|
||||
# https://www.pcbjam.com after cutover
|
||||
# https://www.pcbjam.com production
|
||||
# https://<hash>.pcbjam-site.pages.dev a specific deployment, before promoting
|
||||
# http://127.0.0.1:8788 `wrangler pages dev` locally
|
||||
#
|
||||
# It was written for the Vercel -> Cloudflare Pages migration and several
|
||||
# assertions exist because that migration found real defects; the comments say
|
||||
# which, since the reason is the only thing stopping someone "simplifying" them
|
||||
# back out.
|
||||
#
|
||||
# Usage: assert_parity <base-url> [--scope local|preview|prod] [--live-post]
|
||||
# Exit: 0 if every hard assertion passed (warnings do not fail), else 1.
|
||||
|
|
@ -331,8 +335,8 @@ assert_apex_serves() {
|
|||
return $_rc
|
||||
}
|
||||
|
||||
# APEX_MODE=redirect: apex -> www, path + query preserved. Also used by
|
||||
# 00-baseline.sh, since that is what Vercel does today.
|
||||
# APEX_MODE=redirect: apex -> www, path + query preserved. Unused with the
|
||||
# current `serve` topology; kept in case that decision is revisited.
|
||||
assert_apex_redirect() {
|
||||
_apex="${1:-$APEX_BASE}"
|
||||
section "apex redirect: $_apex"
|
||||
|
|
|
|||
118
deploy/site/verify.sh
Executable file
118
deploy/site/verify.sh
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
#!/usr/bin/env bash
|
||||
# Health check for www.pcbjam.com — the Astro marketing site on Cloudflare Pages.
|
||||
#
|
||||
# deploy/site/verify.sh # production
|
||||
# PROD_BASE=https://abc123.pcbjam-site.pages.dev \
|
||||
# deploy/site/verify.sh --skip-dns --skip-domains # one deployment
|
||||
# EXPECT_HSTS=1 deploy/site/verify.sh # strict on HSTS
|
||||
#
|
||||
# Read-only: it makes GET/OPTIONS/POST requests and changes nothing. The POSTs are
|
||||
# side-effect-free — the full-path probe uses the endpoint's honeypot branch,
|
||||
# which returns before validation, before the rate limiter and before any Resend
|
||||
# call, so it sends no mail and creates no contact.
|
||||
#
|
||||
# Exit 0 if every hard assertion passes. Warnings do not fail.
|
||||
set -euo pipefail
|
||||
. "$(dirname "$0")/lib/common.sh"
|
||||
. "$(dirname "$0")/lib/parity.sh"
|
||||
|
||||
require_cmd curl jq awk sed
|
||||
|
||||
SKIP_DNS=0; SKIP_DOMAINS=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skip-dns) SKIP_DNS=1; shift ;;
|
||||
--skip-domains) SKIP_DOMAINS=1; shift ;;
|
||||
-h|--help) sed -n '2,14p' "$0"; exit 0 ;;
|
||||
*) die "unknown arg: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
rc=0
|
||||
|
||||
if [ "$SKIP_DNS" = 0 ]; then
|
||||
require_cmd dig
|
||||
section "DNS"
|
||||
# Both hosts are PROXIED Pages custom domains, so they answer with Cloudflare
|
||||
# anycast A records and expose no CNAME — an empty CNAME here is the expected
|
||||
# state, not a problem. Failing to resolve at all is what's worth catching.
|
||||
for h in "$ZONE_NAME" "www.$ZONE_NAME"; do
|
||||
a="$(dig +short A "$h" | tr '\n' ' ')"
|
||||
printf ' %-20s A=%s\n' "$h" "${a:-<none>}"
|
||||
[ -n "$a" ] || { echo " FAIL $h does not resolve"; rc=1; }
|
||||
done
|
||||
fi
|
||||
|
||||
section "served by Cloudflare"
|
||||
h="$(_headers "$PROD_BASE/")"
|
||||
if [ -n "$(_hdr "$h" cf-ray)" ]; then
|
||||
echo " PASS cf-ray present"
|
||||
else
|
||||
echo " FAIL no cf-ray — not being served through Cloudflare"; rc=1
|
||||
fi
|
||||
|
||||
assert_parity "$PROD_BASE" --scope prod || rc=$?
|
||||
assert_apex "$APEX_BASE" || rc=$?
|
||||
|
||||
section "cross-origin isolation on BOTH URL forms of the Gerber post"
|
||||
# Explicit and duplicated on purpose. The bare form 308s to the trailing-slash
|
||||
# form, and the trailing-slash form is the page's own canonical — what search
|
||||
# sends people to. Scoping these headers to one form only is exactly the defect
|
||||
# this site shipped with before the migration, and it is silent: the page renders
|
||||
# fine, the embedded viewer just loses SharedArrayBuffer and degrades.
|
||||
for path in /blog/porting-kicad-graphics-to-webgl-in-2026 /blog/porting-kicad-graphics-to-webgl-in-2026/; do
|
||||
eff="$(_trace "$PROD_BASE$path" | cut -f1)"
|
||||
hh="$(_headers "$eff")"
|
||||
coop="$(_hdr "$hh" cross-origin-opener-policy)"; coep="$(_hdr "$hh" cross-origin-embedder-policy)"
|
||||
if [ "$coop" = same-origin ] && [ "$coep" = require-corp ]; then
|
||||
echo " PASS $path -> isolated"
|
||||
else
|
||||
echo " FAIL $path -> coop='${coop:-absent}' coep='${coep:-absent}' ($eff)"; rc=1
|
||||
fi
|
||||
done
|
||||
|
||||
section "demo integration (load-bearing)"
|
||||
# demo.pcbjam.com has no backend of its own and cross-posts the waitlist form
|
||||
# here. A CORS preflight cannot follow a redirect, so this must be 204 with zero
|
||||
# hops — put a redirect in front of the endpoint and the demo breaks silently.
|
||||
hops="$(curl -sS -o /dev/null -w '%{num_redirects}' -X OPTIONS "$PROD_BASE/api/waitlist" \
|
||||
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null || echo 9)"
|
||||
st="$(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 || true)"
|
||||
if [ "$st" = "204" ] && [ "$hops" = "0" ]; then
|
||||
echo " PASS preflight 204 with 0 redirects"
|
||||
else
|
||||
echo " FAIL status=$st hops=$hops (both 204 and 0 hops are required)"; rc=1
|
||||
fi
|
||||
|
||||
if [ "$SKIP_DOMAINS" = 0 ]; then
|
||||
section "custom domains on $PAGES_PROJECT"
|
||||
doms="$($WRANGLER pages project list --json 2>/dev/null \
|
||||
| jq -r --arg n "$PAGES_PROJECT" '.[] | select(."Project Name"==$n) | ."Project Domains"' || true)"
|
||||
if [ -z "$doms" ]; then
|
||||
warn "could not list projects (is \`wrangler login\` valid?) — skipping"
|
||||
else
|
||||
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"; rc=1 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
section "summary"
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "FAILED. To recover, promote a previous deployment:"
|
||||
echo " Workers & Pages -> $PAGES_PROJECT -> Deployments -> Rollback to this deployment"
|
||||
echo "or redeploy a known-good tree (git stays the source of truth):"
|
||||
echo " git checkout <good-commit> -- site/"
|
||||
echo " (cd site && npm ci && npm run build && npx --yes wrangler@4 pages deploy \\"
|
||||
echo " --project-name $PAGES_PROJECT --branch production)"
|
||||
exit 1
|
||||
fi
|
||||
echo "All checks passed."
|
||||
echo "done: $PROD_BASE"
|
||||
|
|
@ -94,4 +94,5 @@ carries the COOP/COEP rules the embedded Gerber viewer needs. Do **not** widen
|
|||
them to `/*` — the landing page must stay un-isolated so the YouTube hero embed
|
||||
loads.
|
||||
|
||||
The full one-time setup and the cutover runbook are in `../deploy/site/README.md`.
|
||||
One-time setup, the invariants that fail silently if changed, and the health
|
||||
check (`deploy/site/verify.sh`) are in `../deploy/site/README.md`.
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
# and locally come from .dev.vars (gitignored). WAITLIST_ALLOWED_ORIGINS is
|
||||
# deliberately unset so it keeps the default in functions/api/waitlist.ts.
|
||||
#
|
||||
# The custom domain (www.pcbjam.com) is attached out of band too — there is no
|
||||
# `wrangler pages domain` subcommand; see deploy/site/README.md.
|
||||
# The custom domains (www.pcbjam.com and the apex) are attached out of band too —
|
||||
# there is no `wrangler pages domain` subcommand; see deploy/site/README.md.
|
||||
|
||||
name = "pcbjam-site"
|
||||
pages_build_output_dir = "./dist"
|
||||
|
|
|
|||
Loading…
Reference in a new issue