feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
This commit is contained in:
parent
2e1b69998f
commit
7edfade53c
41 changed files with 2443 additions and 910 deletions
77
deploy/site/00-baseline.sh
Executable file
77
deploy/site/00-baseline.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
#!/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"
|
||||
118
deploy/site/01-preflight.sh
Executable file
118
deploy/site/01-preflight.sh
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
#!/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 (needed only by 09-detach / 99-rollback)"
|
||||
if command -v vercel >/dev/null 2>&1 || [ -n "${VERCEL_TOKEN:-}" ]; 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 "vercel CLI not on PATH and VERCEL_TOKEN unset — skipping (only 09/99 need it)"
|
||||
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})"
|
||||
121
deploy/site/02-verify-local.sh
Executable file
121
deploy/site/02-verify-local.sh
Executable file
|
|
@ -0,0 +1,121 @@
|
|||
#!/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"
|
||||
57
deploy/site/03-ensure-project.sh
Executable file
57
deploy/site/03-ensure-project.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
|||
#!/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"
|
||||
if existing="$(cf_pages_project 2>/dev/null)"; then
|
||||
echo "exists already"
|
||||
else
|
||||
existing=""
|
||||
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; }
|
||||
existing="$(cf_pages_project)"
|
||||
fi
|
||||
|
||||
section "asserting settings"
|
||||
pb="$(printf '%s' "$existing" | jq -r '.result.production_branch // "?"')"
|
||||
src="$(printf '%s' "$existing" | jq -r '.result.source // "null"')"
|
||||
sub="$(printf '%s' "$existing" | jq -r '.result.subdomain // "?"')"
|
||||
|
||||
# The single most expensive mistake available here: if the project's production
|
||||
# branch is anything other than what deploy-site.yml passes to --branch, every
|
||||
# deploy lands as a PREVIEW and the live site silently never updates. The same
|
||||
# warning is written into deploy-demo.yml.
|
||||
if [ "$pb" != "$PAGES_PROD_BRANCH" ]; then
|
||||
die "production_branch is '$pb' but deploys use '$PAGES_PROD_BRANCH'.
|
||||
Every deploy would land as a preview and www.pcbjam.com would never update.
|
||||
Fix it in Pages -> $PAGES_PROJECT -> Settings -> Builds & deployments, then re-run.
|
||||
(This script will NOT change it: doing so retroactively re-labels deployments.)"
|
||||
fi
|
||||
echo " ok production_branch = $pb"
|
||||
|
||||
if [ "$src" != "null" ]; then
|
||||
die "project is Git-connected (source: $src). Cloudflare's own builds would race
|
||||
the uploads from deploy-site.yml. Disconnect it in the dashboard first."
|
||||
fi
|
||||
echo " ok Direct Upload (not Git-connected)"
|
||||
|
||||
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 "done: https://${sub}.pages.dev"
|
||||
104
deploy/site/04-set-secrets.sh
Executable file
104
deploy/site/04-set-secrets.sh
Executable file
|
|
@ -0,0 +1,104 @@
|
|||
#!/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)"
|
||||
71
deploy/site/05-deploy.sh
Executable file
71
deploy/site/05-deploy.sh
Executable file
|
|
@ -0,0 +1,71 @@
|
|||
#!/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"
|
||||
95
deploy/site/06-verify-deploy.sh
Executable file
95
deploy/site/06-verify-deploy.sh
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
#!/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"
|
||||
latest="$(cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/deployments?per_page=1" 2>/dev/null || true)"
|
||||
envname="$(printf '%s' "$latest" | jq -r '.result[0].environment // "?"')"
|
||||
did="$(printf '%s' "$latest" | jq -r '.result[0].id // "?"')"
|
||||
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 ($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"
|
||||
347
deploy/site/07-dns-cutover.sh
Executable file
347
deploy/site/07-dns-cutover.sh
Executable file
|
|
@ -0,0 +1,347 @@
|
|||
#!/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
|
||||
87
deploy/site/08-verify-prod.sh
Executable file
87
deploy/site/08-verify-prod.sh
Executable file
|
|
@ -0,0 +1,87 @@
|
|||
#!/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"
|
||||
www_cname="$(dig +short CNAME "www.$ZONE_NAME" || true)"
|
||||
echo " www CNAME: ${www_cname:-<none>}"
|
||||
case "$www_cname" in
|
||||
*pages.dev*) echo " PASS points at Pages" ;;
|
||||
*vercel-dns*) echo " FAIL still points at Vercel — DNS has not propagated (or the swap did not run)"; rc=1 ;;
|
||||
*) echo " WARN unexpected target" ;;
|
||||
esac
|
||||
|
||||
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_redirect "$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 domain status"
|
||||
dom="$(cf_pages_domains 2>/dev/null || true)"
|
||||
if [ -n "$dom" ]; then
|
||||
printf '%s' "$dom" | jq -r '.result[]? | " \(.name): \(.status)"'
|
||||
printf '%s' "$dom" | jq -e --arg n "www.$ZONE_NAME" \
|
||||
'[.result[]? | select(.name==$n and .status=="active")] | length > 0' >/dev/null 2>&1 \
|
||||
&& echo " PASS www.$ZONE_NAME active" \
|
||||
|| { echo " FAIL www.$ZONE_NAME not active"; rc=1; }
|
||||
fi
|
||||
|
||||
section "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"
|
||||
96
deploy/site/09-detach-vercel.sh
Executable file
96
deploy/site/09-detach-vercel.sh
Executable file
|
|
@ -0,0 +1,96 @@
|
|||
#!/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)"
|
||||
72
deploy/site/99-rollback.sh
Executable file
72
deploy/site/99-rollback.sh
Executable file
|
|
@ -0,0 +1,72 @@
|
|||
#!/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)"
|
||||
139
deploy/site/README.md
Normal file
139
deploy/site/README.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# www.pcbjam.com deploy runbook
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
push to main (site/**) ──▶ .github/workflows/deploy-site.yml
|
||||
1. npm ci
|
||||
2. npm test (vitest — nothing else runs it)
|
||||
3. astro build → site/dist/ (static; no adapter)
|
||||
4. wrangler pages deploy → www.pcbjam.com
|
||||
5. smoke: /api/waitlist preflight == 204
|
||||
```
|
||||
|
||||
Not tag-gated: content must not wait for a release. The WASM editor ships from
|
||||
`release.yml`; the two are independent.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
site/functions/api/waitlist.ts the only server-side code (Pages Function)
|
||||
site/public/_headers prod COOP/COEP, scoped to 2 routes
|
||||
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
|
||||
```
|
||||
|
||||
Two things you can get wrong here, both of which fail quietly:
|
||||
|
||||
- **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.
|
||||
|
||||
## One-time setup (Cloudflare — needs your account)
|
||||
|
||||
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.
|
||||
|
||||
## Migration / cutover (one time, from Vercel)
|
||||
|
||||
Every mutating script is **dry-run by default**; add `--apply`. Read the dry-run
|
||||
output before applying — that is the whole point of the split.
|
||||
|
||||
| when | command | live? |
|
||||
|---|---|---|
|
||||
| T−days | `00-baseline.sh` | no — read-only |
|
||||
| T−days | `01-preflight.sh` | no — read-only |
|
||||
| T−days | `07-dns-cutover.sh --phase probe --apply` | no — throwaway hostname |
|
||||
| T−days | `02-verify-local.sh` | no |
|
||||
| T−days | `03-ensure-project.sh --apply` → `04-set-secrets.sh --apply` | new project only |
|
||||
| T−days | `05-deploy.sh --preview --apply` → `06-verify-deploy.sh --latest` | no — pages.dev only |
|
||||
| T−1d | `07-dns-cutover.sh --phase rules --apply` | no — inert until the apex is proxied |
|
||||
| T−1d | `07-dns-cutover.sh --phase hsts --apply` | no — additive |
|
||||
| T−24h | `07-dns-cutover.sh --phase prelower --apply` | no — TTL only |
|
||||
| T−1h | `05-deploy.sh --production --apply` → `06-verify-deploy.sh --scope prod-deploy` | no — no DNS yet |
|
||||
| **T+0** | `07-dns-cutover.sh --phase swap --apply` | **yes** |
|
||||
| T+2m | `07-dns-cutover.sh --phase apex --apply` | yes (auto-rollback armed) |
|
||||
| T+5m | `08-verify-prod.sh` | verify only |
|
||||
| T+24h | `09-detach-vercel.sh --apply` | Vercel only |
|
||||
|
||||
Rollback, any time: **`99-rollback.sh --apply --yes`**. Keep it ready in a second
|
||||
terminal during the swap.
|
||||
|
||||
### Why the swap is a PATCH, not a delete + create
|
||||
|
||||
A single `PATCH` flips the `www` record's content and proxied status atomically,
|
||||
so there is **no DNS gap**. Delete-then-create leaves the name with no record for
|
||||
a second or two, and any resolver that queries in that instant caches NODATA for
|
||||
the zone's **SOA minimum** — typically 1800s. That is an un-flushable ~30-minute
|
||||
partial outage. `00-baseline.sh` records the zone's actual value so the exposure
|
||||
is a number, not a guess.
|
||||
|
||||
The residual risk with `PATCH` is HTTP-only and self-healing: for a second or two
|
||||
the edge has no route for the hostname and serves the Pages not-found page.
|
||||
Universal SSL already covers `*.pcbjam.com`, so TLS is never in question.
|
||||
|
||||
`--phase probe` settles whether Pages will attach a custom domain over an
|
||||
existing CNAME, days early, on a throwaway hostname. `--swap-mode auto` reads
|
||||
that result and only falls back to `delete-create` if it has to.
|
||||
|
||||
Throughout, Vercel stays attached, so resolvers still holding the old answer keep
|
||||
serving the identical site. The cutover is a fade, not a switch.
|
||||
|
||||
## 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.
|
||||
|
||||
## Local development
|
||||
|
||||
```sh
|
||||
cd site
|
||||
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
|
||||
```
|
||||
86
deploy/site/lib/cf-api.sh
Normal file
86
deploy/site/lib/cf-api.sh
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/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"
|
||||
|
||||
cf_token() {
|
||||
[ -n "${CLOUDFLARE_API_TOKEN:-}" ] || die "CLOUDFLARE_API_TOKEN is not set"
|
||||
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"; }
|
||||
136
deploy/site/lib/common.sh
Normal file
136
deploy/site/lib/common.sh
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#!/usr/bin/env bash
|
||||
# Shared plumbing for the deploy/site cf-migrate scripts: paths, logging,
|
||||
# dry-run, stamps and guards. 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.
|
||||
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}"
|
||||
WRANGLER="${WRANGLER_CMD:-npx --yes wrangler@4}"
|
||||
|
||||
mkdir -p "$STATE_DIR/state" "$STATE_DIR/stamps" "$STATE_DIR/baseline" "$STATE_DIR/logs"
|
||||
|
||||
section() { echo; echo "== $* =="; }
|
||||
warn() { echo "warn: $*" >&2; }
|
||||
die() { echo "cf-migrate: $*" >&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
|
||||
}
|
||||
282
deploy/site/lib/parity.sh
Normal file
282
deploy/site/lib/parity.sh
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
#!/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:
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# Usage: assert_parity <base-url> [--scope local|preview|prod] [--live-post]
|
||||
# Exit: 0 if every hard assertion passed (warnings do not fail), else 1.
|
||||
|
||||
CURL="curl -sS --max-time 20"
|
||||
|
||||
# --- the two primitives ----------------------------------------------------
|
||||
# Deliberately split. _trace follows redirects to learn WHERE we land; _headers
|
||||
# then re-fetches that exact URL WITHOUT -L so there is exactly one response to
|
||||
# parse. Asserting on a multi-block `-IL` dump is precisely how the COOP/COEP
|
||||
# regression stayed hidden on Vercel: the headers were on the redirect hop, not
|
||||
# on the document.
|
||||
_trace() { # _trace URL -> "eff_url<TAB>status<TAB>hops"
|
||||
$CURL -L -o /dev/null -w '%{url_effective}\t%{http_code}\t%{num_redirects}' "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
_headers() { # _headers URL [extra curl args...] -> "name: value" lines, name lowercased
|
||||
$CURL -D - -o /dev/null "$@" 2>/dev/null | tr -d '\r' | awk '
|
||||
/^HTTP\/[0-9.]+ [0-9][0-9][0-9]/ { buf=""; next } # reset on each block; keep the last
|
||||
NF==0 { next }
|
||||
{ i=index($0,":"); if(i){ k=tolower(substr($0,1,i-1)); v=substr($0,i+1);
|
||||
sub(/^[ \t]+/,"",v); buf = buf k ": " v "\n" } }
|
||||
END { printf "%s", buf }'
|
||||
}
|
||||
|
||||
_hdr() { # _hdr "<headers>" name -> value ("" if absent)
|
||||
printf '%s\n' "$1" | awk -v k="$2: " 'index($0,k)==1 { print substr($0, length(k)+1); exit }'
|
||||
}
|
||||
|
||||
_title() { $CURL -L "$1" 2>/dev/null | tr -d '\n' | sed -n 's/.*<title>\([^<]*\)<\/title>.*/\1/p'; }
|
||||
|
||||
# --- result accumulation (bash 3.2: temp file, not an array) ---------------
|
||||
_P_RESULTS=""
|
||||
_p_init() { _P_RESULTS="$(mktemp -t cfmparity)"; }
|
||||
_p_add() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$_P_RESULTS"; }
|
||||
pass() { _p_add PASS "$1" "${2:-}" "${3:-}"; }
|
||||
fail() { _p_add FAIL "$1" "${2:-}" "${3:-}"; }
|
||||
soft() { _p_add WARN "$1" "${2:-}" "${3:-}"; }
|
||||
|
||||
# assert_status <probe> <url> <expected>
|
||||
_expect_page() { # _expect_page <probe> <base> <path>
|
||||
_pr="$1"; _b="$2"; _path="$3"
|
||||
_t="$(_trace "$_b$_path")"
|
||||
_eff="$(printf '%s' "$_t" | cut -f1)"; _st="$(printf '%s' "$_t" | cut -f2)"
|
||||
_hops="$(printf '%s' "$_t" | cut -f3)"
|
||||
_effpath="$(printf '%s' "$_eff" | sed -e 's|^[a-z]*://[^/]*||' -e 's|?.*$||')"
|
||||
_h="$(_headers "$_eff")"
|
||||
_ct="$(_hdr "$_h" content-type)"
|
||||
# Hard: final 200, landed on the requested path (+/- a trailing slash), HTML.
|
||||
# Trailing-slash HOPS are recorded, never failed on: Vercel serves both forms
|
||||
# at 200; Pages 308s the bare form to the slash form. Both are fine. What is
|
||||
# NOT fine is landing somewhere else — that is how a soft-404 shows up.
|
||||
if [ "$_st" != "200" ]; then fail "$_pr" "$_hops" "expected 200, got $_st"; return; fi
|
||||
case "$_effpath" in
|
||||
"$_path"|"$_path/") : ;;
|
||||
*) fail "$_pr" "$_hops" "landed on '$_effpath', expected '$_path'"; return ;;
|
||||
esac
|
||||
case "$_ct" in
|
||||
text/html*) : ;;
|
||||
*) fail "$_pr" "$_hops" "content-type '$_ct'"; return ;;
|
||||
esac
|
||||
pass "$_pr" "$_hops" "200 $_effpath"
|
||||
}
|
||||
|
||||
_expect_coi() { # _expect_coi <probe> <base> <path> (cross-origin isolated)
|
||||
_pr="$1"; _b="$2"; _path="$3"
|
||||
_t="$(_trace "$_b$_path")"; _eff="$(printf '%s' "$_t" | cut -f1)"
|
||||
_hops="$(printf '%s' "$_t" | cut -f3)"
|
||||
_h="$(_headers "$_eff")"
|
||||
_coop="$(_hdr "$_h" cross-origin-opener-policy)"
|
||||
_coep="$(_hdr "$_h" cross-origin-embedder-policy)"
|
||||
if [ "$_coop" = "same-origin" ] && [ "$_coep" = "require-corp" ]; then
|
||||
pass "$_pr" "$_hops" "COOP+COEP on $_eff"
|
||||
else
|
||||
fail "$_pr" "$_hops" "coop='${_coop:-absent}' coep='${_coep:-absent}' on $_eff"
|
||||
fi
|
||||
}
|
||||
|
||||
_expect_not_coi() { # the landing page MUST NOT be isolated
|
||||
_pr="$1"; _b="$2"; _path="$3"
|
||||
_t="$(_trace "$_b$_path")"; _eff="$(printf '%s' "$_t" | cut -f1)"
|
||||
_h="$(_headers "$_eff")"
|
||||
_coep="$(_hdr "$_h" cross-origin-embedder-policy)"
|
||||
if [ -z "$_coep" ]; then
|
||||
pass "$_pr" "-" "not isolated (correct — YouTube hero embed)"
|
||||
else
|
||||
fail "$_pr" "-" "COEP '$_coep' present; a require-corp landing page cannot load the YouTube embed"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_parity() {
|
||||
_base="${1:?usage: assert_parity <base-url> [--scope local|preview|prod] [--live-post]}"
|
||||
shift
|
||||
_scope=preview; _live_post=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--scope) _scope="$2"; shift 2 ;;
|
||||
--live-post) _live_post=1; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
_base="$(printf '%s' "$_base" | sed 's|/*$||')"
|
||||
_p_init
|
||||
section "parity sweep: $_base (scope=$_scope)"
|
||||
|
||||
# 1) pages
|
||||
for p in / /pricing /blog /privacy /terms /cookies /licenses; do
|
||||
_n="$(printf '%s' "$p" | sed 's|^/||')"; [ -z "$_n" ] && _n=home
|
||||
_expect_page "$_n" "$_base" "$p"
|
||||
done
|
||||
|
||||
# 2) cross-origin isolation, asserted on the FINAL response.
|
||||
# BOTH URL forms of the blog post are probed on purpose. Vercel serves the bare
|
||||
# form at 200 with the headers and the TRAILING-SLASH form at 200 WITHOUT them —
|
||||
# and the trailing-slash form is the page's own canonical, i.e. what search
|
||||
# sends people to. So post_coi passes on Vercel while post_coi_slash fails; that
|
||||
# asymmetry IS the bug, and probing only one form would hide it.
|
||||
_expect_coi post_coi "$_base" /blog/porting-kicad-graphics-to-webgl-in-2026
|
||||
_expect_coi post_coi_slash "$_base" /blog/porting-kicad-graphics-to-webgl-in-2026/
|
||||
_expect_coi gerber_boot "$_base" /gerber-demo/boot.js
|
||||
_expect_not_coi landing_iso "$_base" /
|
||||
|
||||
# 3) immutable asset caching
|
||||
_asset="$($CURL -L "$_base/" 2>/dev/null | tr '"' '\n' | grep -m1 '^/_astro/[^ ]*\.css$' || true)"
|
||||
if [ -n "$_asset" ]; then
|
||||
_cc="$(_hdr "$(_headers "$_base$_asset")" cache-control)"
|
||||
case "$_cc" in
|
||||
*immutable*) pass astro_cache - "$_cc" ;;
|
||||
*) soft astro_cache - "cache-control='${_cc:-absent}'" ;;
|
||||
esac
|
||||
else
|
||||
soft astro_cache - "no hashed css found on /"
|
||||
fi
|
||||
|
||||
# 4) the waitlist endpoint
|
||||
# Preflight from the allowlisted demo origin. hops MUST be 0: a CORS preflight
|
||||
# cannot follow a redirect, so this is the check that guards demo.pcbjam.com's
|
||||
# entire waitlist integration.
|
||||
_pre="$($CURL -o /dev/null -D - -X OPTIONS "$_base/api/waitlist" \
|
||||
-H 'Origin: https://demo.pcbjam.com' \
|
||||
-H 'Access-Control-Request-Method: POST' \
|
||||
-H 'Access-Control-Request-Headers: content-type' 2>/dev/null | tr -d '\r')"
|
||||
_pre_st="$(printf '%s' "$_pre" | awk '/^HTTP/{c=$2} END{print c}')"
|
||||
_pre_h="$(_headers "$_base/api/waitlist" -X OPTIONS -H 'Origin: https://demo.pcbjam.com')"
|
||||
_acao="$(_hdr "$_pre_h" access-control-allow-origin)"
|
||||
_hops0="$($CURL -o /dev/null -w '%{num_redirects}' -X OPTIONS "$_base/api/waitlist" \
|
||||
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null)"
|
||||
if [ "$_pre_st" = "204" ] && [ "$_acao" = "https://demo.pcbjam.com" ] && [ "$_hops0" = "0" ]; then
|
||||
pass api_preflight "$_hops0" "204 acao=$_acao"
|
||||
else
|
||||
fail api_preflight "$_hops0" "status=$_pre_st acao='${_acao:-absent}' hops=$_hops0 (all of 204/echoed-origin/0-hops required)"
|
||||
fi
|
||||
|
||||
_deny_h="$(_headers "$_base/api/waitlist" -X OPTIONS -H 'Origin: https://not-allowed.example')"
|
||||
if [ -z "$(_hdr "$_deny_h" access-control-allow-origin)" ]; then
|
||||
pass api_cors_deny - "no CORS for a non-allowlisted origin"
|
||||
else
|
||||
fail api_cors_deny - "CORS granted to https://not-allowed.example"
|
||||
fi
|
||||
|
||||
_gst="$($CURL -o /dev/null -w '%{http_code}' "$_base/api/waitlist" 2>/dev/null)"
|
||||
[ "$_gst" = "405" ] && pass api_get - "405" || fail api_get - "expected 405, got $_gst"
|
||||
|
||||
_bad="$($CURL -o /dev/null -w '%{http_code}' -X POST "$_base/api/waitlist" \
|
||||
-H 'content-type: application/json' --data '{"email":"nope"}' 2>/dev/null)"
|
||||
[ "$_bad" = "400" ] && pass api_invalid_email - "400" || fail api_invalid_email - "expected 400, got $_bad"
|
||||
|
||||
# The honeypot branch returns BEFORE validation, BEFORE the rate limiter and
|
||||
# BEFORE any Resend call, so this exercises the whole request path (routing,
|
||||
# Functions bundling, body parsing, CORS) with zero side effects — safe to run
|
||||
# against production.
|
||||
_hp="$($CURL -X POST "$_base/api/waitlist" -H 'content-type: application/json' \
|
||||
-H 'Origin: https://demo.pcbjam.com' \
|
||||
--data '{"email":"parity@pcbjam.com","company_url":"bot","source":"cfm-parity"}' \
|
||||
-w '\n%{http_code}' 2>/dev/null)"
|
||||
_hp_st="$(printf '%s' "$_hp" | tail -1)"
|
||||
case "$_hp" in
|
||||
*'"ok":true'*) [ "$_hp_st" = "200" ] && pass api_honeypot - "200 ok:true" \
|
||||
|| fail api_honeypot - "ok:true but status $_hp_st" ;;
|
||||
*) fail api_honeypot - "status=$_hp_st body did not contain ok:true" ;;
|
||||
esac
|
||||
|
||||
# No-JS native form submit -> 303 back to the page. The same-origin Origin
|
||||
# header is required, not cosmetic: a real browser form submit sends it, and
|
||||
# both Vercel's edge and (post-migration) the Function itself refuse a
|
||||
# form-encoded POST that carries a foreign Origin. Omitting it here gets a 403
|
||||
# and looks like a broken endpoint.
|
||||
_form="$($CURL -o /dev/null -D - -X POST "$_base/api/waitlist" \
|
||||
-H "Origin: $_base" \
|
||||
--data-urlencode 'email=parity@pcbjam.com' \
|
||||
--data-urlencode 'company_url=bot' 2>/dev/null | tr -d '\r')"
|
||||
_form_st="$(printf '%s' "$_form" | awk '/^HTTP/{c=$2} END{print c}')"
|
||||
_loc="$(printf '%s' "$_form" | awk 'tolower($1)=="location:"{print $2}' | tail -1)"
|
||||
if [ "$_form_st" = "303" ] && [ "$_loc" = "/?waitlist=ok#waitlist" ]; then
|
||||
pass api_form_303 - "303 -> $_loc"
|
||||
else
|
||||
fail api_form_303 - "status=$_form_st location='${_loc:-absent}'"
|
||||
fi
|
||||
|
||||
# Cross-site form POST must be refused. Vercel's edge did this for free; the
|
||||
# Function reproduces it, so this probe must pass on BOTH platforms.
|
||||
_csrf="$($CURL -o /dev/null -w '%{http_code}' -X POST "$_base/api/waitlist" \
|
||||
-H 'Origin: https://evil.example' \
|
||||
--data-urlencode 'email=parity@pcbjam.com' 2>/dev/null || true)"
|
||||
[ "$_csrf" = "403" ] && pass api_form_csrf - "403 cross-site form POST refused" \
|
||||
|| fail api_form_csrf - "expected 403, got $_csrf (cross-site form POST is a CSRF vector)"
|
||||
|
||||
if [ "$_live_post" = 1 ]; then
|
||||
_live="$($CURL -X POST "$_base/api/waitlist" -H 'content-type: application/json' \
|
||||
--data '{"email":"cfm-live@example.com","source":"cfm-parity"}' \
|
||||
-w '\n%{http_code}' 2>/dev/null)"
|
||||
_live_st="$(printf '%s' "$_live" | tail -1)"
|
||||
# 200 = accepted (no key configured); 502 = the Resend SDK loaded and the API
|
||||
# rejected our (bogus) key. Both prove the module resolved under workerd.
|
||||
case "$_live_st" in
|
||||
200|502) pass api_live_post - "status=$_live_st (SDK loaded)" ;;
|
||||
*) fail api_live_post - "expected 200 or 502, got $_live_st — likely a module-resolution error (nodejs_compat?)" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# 5) a real 404, not the homepage at 200
|
||||
_nf_url="$_base/__cfm-parity-404__/"
|
||||
_nf_st="$($CURL -L -o /dev/null -w '%{http_code}' "$_nf_url" 2>/dev/null)"
|
||||
_nf_title="$(_title "$_nf_url")"
|
||||
_home_title="$(_title "$_base/")"
|
||||
if [ "$_nf_st" != "404" ]; then
|
||||
fail notfound - "expected 404, got $_nf_st (a 200 here is a soft-404 serving the homepage)"
|
||||
elif [ -n "$_home_title" ] && [ "$_nf_title" = "$_home_title" ]; then
|
||||
fail notfound - "404 status but the homepage document was served"
|
||||
else
|
||||
pass notfound - "404 '$_nf_title'"
|
||||
fi
|
||||
|
||||
# 6) prod-only
|
||||
if [ "$_scope" = prod ]; then
|
||||
_hsts="$(_hdr "$(_headers "$_base/")" strict-transport-security)"
|
||||
case "$_hsts" in
|
||||
*max-age=63072000*) pass hsts - "$_hsts" ;;
|
||||
"") fail hsts - "absent (Vercel sent max-age=63072000)" ;;
|
||||
*) soft hsts - "$_hsts (differs from the Vercel baseline)" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- report -------------------------------------------------------------
|
||||
printf '\n%-6s %-18s %-5s %s\n' STATUS PROBE HOPS DETAIL
|
||||
awk -F'\t' '{ printf "%-6s %-18s %-5s %s\n", $1, $2, ($3==""?"-":$3), $4 }' "$_P_RESULTS"
|
||||
_np=$(grep -c '^PASS' "$_P_RESULTS" || true)
|
||||
_nf=$(grep -c '^FAIL' "$_P_RESULTS" || true)
|
||||
_nw=$(grep -c '^WARN' "$_P_RESULTS" || true)
|
||||
echo
|
||||
echo "$(( _np + _nf + _nw )) probes: ${_np} pass, ${_nf} fail, ${_nw} warn"
|
||||
rm -f "$_P_RESULTS"
|
||||
if [ "$_nf" -gt 0 ]; then echo "parity: FAIL (${_nf})"; return 1; fi
|
||||
echo "parity: PASS"; return 0
|
||||
}
|
||||
|
||||
# Apex -> www redirect, path + query preserved. Prod only.
|
||||
assert_apex_redirect() {
|
||||
_apex="${1:-$APEX_BASE}"
|
||||
section "apex redirect: $_apex"
|
||||
_rc=0
|
||||
for pair in "/:https://www.pcbjam.com/" "/pricing?a=1&b=2:https://www.pcbjam.com/pricing?a=1&b=2"; do
|
||||
_path="${pair%%:*}"; _want="${pair#*:}"
|
||||
_h="$($CURL -o /dev/null -D - "$_apex$_path" 2>/dev/null | tr -d '\r')"
|
||||
_st="$(printf '%s' "$_h" | awk '/^HTTP/{c=$2} END{print c}')"
|
||||
_loc="$(printf '%s' "$_h" | awk 'tolower($1)=="location:"{print $2}' | tail -1)"
|
||||
if [ "$_st" = "308" ] && [ "$_loc" = "$_want" ]; then
|
||||
echo "PASS $_apex$_path -> 308 $_loc"
|
||||
else
|
||||
echo "FAIL $_apex$_path -> status=$_st location='${_loc:-absent}' (wanted 308 $_want)"; _rc=1
|
||||
fi
|
||||
done
|
||||
return $_rc
|
||||
}
|
||||
Loading…
Reference in a new issue