chore(deploy/site): retire the migration scaffolding, keep the health check
The Vercel -> Cloudflare Pages move is done and the Vercel project is deleted, so the one-shot scripts have no remaining purpose. Nothing in CI ever called them — deploy-site.yml runs npm ci / test / build / pages deploy inline — so this removes 10 files and orphans nothing. Deleted: 00-baseline (refused to run without x-vercel-id, so permanently unrunnable), 01-preflight (proved Vercel state and API-token scopes), 07-dns-cutover (the phased cutover; in the end the records were attached through the dashboard, and the rules/apex phases went unused once we chose APEX_MODE=serve), 09-detach-vercel (its target project is gone), plus 03-ensure-project, 04-set-secrets, 05-deploy, 06-verify-deploy, 02-verify-local and 99-rollback, all either spent or duplicating CI. Their lib/cf-api.sh went with them: the survivors use wrangler, so the whole remaining path needs only `wrangler login` and no zone scopes. What is kept is the part with ongoing value: lib/parity.sh, the assertion set that caught five real defects during the migration — the live COOP/COEP bug on the post's canonical URL, the soft-404 Pages would have introduced, the cross-site form-POST guard Vercel had been providing for free, the missing immutable header, and HSTS max-age=0. "Does the page return 200" catches none of those. 08-verify-prod.sh becomes verify.sh, since the numbered sequence it belonged to no longer exists. It drops the stamp machinery, the dry-run plumbing and the Vercel-fallback messaging (there is no fallback now: recovery is promoting a previous Pages deployment), and gains --skip-dns / --skip-domains so it can be pointed at a single deployment via PROD_BASE before promoting it. The README is rewritten around the four invariants that fail SILENTLY — never widen _headers to /*, keep both URL forms of the Gerber post, never delete 404.astro, keep the cross-site form-POST guard — each with the reason, since the reason is the only thing that stops someone simplifying them back out. Verified after: 21 probes, 20 pass, 1 warn (HSTS max-age is 6 months vs Vercel's 2 years — on, just shorter), 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
This commit is contained in:
parent
3259fb3c85
commit
fdc4583288
20 changed files with 228 additions and 1631 deletions
|
|
@ -1,95 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Cloudflare API v4 wrapper + the DNS allowlist guard. Sourced, never executed.
|
||||
#
|
||||
# Required token scopes (one token, CLOUDFLARE_API_TOKEN):
|
||||
# Zone -> Zone -> Read (pcbjam.com)
|
||||
# Zone -> DNS -> Edit (record CRUD)
|
||||
# Zone -> Zone Settings -> Edit (HSTS / security_header)
|
||||
# Zone -> Dynamic Redirect-> Edit (http_request_dynamic_redirect ruleset)
|
||||
# Account-> Cloudflare Pages-> Edit (project, secrets, deploy, custom domains)
|
||||
|
||||
CF_API="https://api.cloudflare.com/client/v4"
|
||||
|
||||
# True when a REST token is available. `wrangler login` gives an OAuth token that
|
||||
# wrangler itself uses but that cannot be replayed as a REST bearer token, and its
|
||||
# zone scope is read-only — so the DNS/ruleset/HSTS steps need a real API token
|
||||
# while the Pages steps are happy with either. Scripts branch on this.
|
||||
cf_have_token() { [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]; }
|
||||
|
||||
cf_token() {
|
||||
[ -n "${CLOUDFLARE_API_TOKEN:-}" ] || die "CLOUDFLARE_API_TOKEN is not set.
|
||||
\`wrangler login\` is not enough for this step: its OAuth token cannot be used
|
||||
as a REST bearer token, and it only carries zone:read. Create an API token with
|
||||
the scopes at the top of this file and export it (plus CLOUDFLARE_ACCOUNT_ID)."
|
||||
printf '%s' "$CLOUDFLARE_API_TOKEN"
|
||||
}
|
||||
cf_account() {
|
||||
[ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || die "CLOUDFLARE_ACCOUNT_ID is not set"
|
||||
printf '%s' "$CLOUDFLARE_ACCOUNT_ID"
|
||||
}
|
||||
|
||||
# cf_api METHOD PATH [JSON_BODY]
|
||||
# GETs always execute (read-only). Non-GET honours DRY_RUN and prints the exact
|
||||
# call it would make, body included, so a reviewer sees it before it happens.
|
||||
cf_api() {
|
||||
_m="$1"; _p="$2"; _b="${3:-}"
|
||||
if [ "$_m" != "GET" ] && [ "${DRY_RUN:-1}" = 1 ]; then
|
||||
echo "WOULD: $_m $_p" >&2
|
||||
[ -n "$_b" ] && printf '%s\n' "$_b" | jq . >&2
|
||||
echo '{"success":true,"result":{},"dry_run":true}'
|
||||
return 0
|
||||
fi
|
||||
_log="$STATE_DIR/logs/api-$(date +%s)-$$.json"
|
||||
if [ -n "$_b" ]; then
|
||||
_out="$($CURL_BIN -sS -X "$_m" "$CF_API$_p" \
|
||||
-H "Authorization: Bearer $(cf_token)" \
|
||||
-H 'Content-Type: application/json' --data "$_b")"
|
||||
else
|
||||
_out="$($CURL_BIN -sS -X "$_m" "$CF_API$_p" \
|
||||
-H "Authorization: Bearer $(cf_token)" \
|
||||
-H 'Content-Type: application/json')"
|
||||
fi
|
||||
printf '%s' "$_out" > "$_log"
|
||||
printf '%s' "$_out" | jq -e '.success == true' >/dev/null 2>&1 \
|
||||
|| die "API $_m $_p failed — see $_log
|
||||
$(printf '%s' "$_out" | jq -r '.errors[]? | " [\(.code)] \(.message)"' 2>/dev/null)"
|
||||
printf '%s' "$_out"
|
||||
}
|
||||
CURL_BIN="${CURL_BIN:-curl}"
|
||||
|
||||
cf_zone_id() {
|
||||
if [ -s "$STATE_DIR/state/zone-id" ]; then cat "$STATE_DIR/state/zone-id"; return; fi
|
||||
_r="$(cf_api GET "/zones?name=$ZONE_NAME")"
|
||||
_n="$(printf '%s' "$_r" | jq '.result | length')"
|
||||
[ "$_n" = "1" ] || die "expected exactly 1 zone named $ZONE_NAME, found $_n"
|
||||
printf '%s' "$_r" | jq -r '.result[0].id' | tee "$STATE_DIR/state/zone-id"
|
||||
}
|
||||
|
||||
cf_dns_list() { cf_api GET "/zones/$(cf_zone_id)/dns_records?per_page=500"; }
|
||||
|
||||
# cf_dns_find <name> <type> -> record id ("" if none)
|
||||
cf_dns_find() {
|
||||
cf_dns_list | jq -r --arg n "$1" --arg t "$2" \
|
||||
'.result[] | select(.name==$n and .type==$t) | .id' | head -1
|
||||
}
|
||||
|
||||
# THE guard. Every DNS mutation passes through this first. The zone also holds
|
||||
# app/demo/editor/cdn/assets records, the Google MX and the two _vercel TXT
|
||||
# verification records — none of which this migration may touch.
|
||||
cf_dns_guard() { # cf_dns_guard <record_id> <expected_name>
|
||||
_r="$(cf_api GET "/zones/$(cf_zone_id)/dns_records/$1")"
|
||||
_n="$(printf '%s' "$_r" | jq -r .result.name)"
|
||||
_t="$(printf '%s' "$_r" | jq -r .result.type)"
|
||||
case "$_n" in
|
||||
"$ZONE_NAME"|"www.$ZONE_NAME"|"cf-attach-probe.$ZONE_NAME") : ;;
|
||||
*) die "refusing to touch DNS record '$_n' (allowlist: apex, www, cf-attach-probe)" ;;
|
||||
esac
|
||||
[ "$_n" = "$2" ] || die "record $1 is '$_n', expected '$2' (stale id? re-run 01-preflight.sh)"
|
||||
case "$_t" in
|
||||
A|CNAME) : ;;
|
||||
*) die "refusing to touch a $_t record ($_n) — only A/CNAME are in scope" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cf_pages_project() { cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT"; }
|
||||
cf_pages_domains() { cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains"; }
|
||||
|
|
@ -1,143 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
# Shared plumbing for the deploy/site cf-migrate scripts: paths, logging,
|
||||
# dry-run, stamps and guards. Sourced, never executed.
|
||||
# Shared plumbing for the site verification scripts: paths, config, logging.
|
||||
# Sourced, never executed.
|
||||
#
|
||||
# bash 3.2 compatible (/usr/bin/env bash on macOS is 3.2.57): no associative
|
||||
# arrays, no `mapfile`, no ${x,,}. Accumulators use temp files.
|
||||
|
||||
# Repo + site paths. Every script cd's to SITE_DIR before building or deploying:
|
||||
# `wrangler pages deploy` discovers Functions at $PWD/functions, so running from
|
||||
# the wrong directory silently ships a static-only site with /api/waitlist 404ing.
|
||||
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_SITE_DIR="$(dirname "$LIB_DIR")"
|
||||
PCBJAM_DIR="$(cd "$DEPLOY_SITE_DIR/../.." && pwd)"
|
||||
SITE_DIR="$PCBJAM_DIR/site"
|
||||
STATE_DIR="$SITE_DIR/.cf-migrate"
|
||||
|
||||
# Defaults, overridable by env.
|
||||
# Defaults, all overridable by env — which is how you point the sweep at a
|
||||
# preview deployment instead of production:
|
||||
# PROD_BASE=https://abc123.pcbjam-site.pages.dev deploy/site/verify.sh
|
||||
PAGES_PROJECT="${PAGES_PROJECT:-pcbjam-site}"
|
||||
PAGES_PROD_BRANCH="${PAGES_PROD_BRANCH:-production}"
|
||||
ZONE_NAME="${ZONE_NAME:-pcbjam.com}"
|
||||
PROD_BASE="${PROD_BASE:-https://www.pcbjam.com}"
|
||||
APEX_BASE="${APEX_BASE:-https://pcbjam.com}"
|
||||
VERCEL_PROJECT="${VERCEL_PROJECT:-pcbjam}"
|
||||
VERCEL_TEAM="${VERCEL_TEAM:-pcbj-am}"
|
||||
VERCEL_DNS_TARGET="${VERCEL_DNS_TARGET:-dcfb2907091b7240.vercel-dns-016.com}"
|
||||
# How the apex behaves. Two supported topologies:
|
||||
# serve (default) apex is a SECOND Pages custom domain and serves 200. No
|
||||
# redirect rule, no placeholder record — the same two clicks as any
|
||||
# other host. Pages still emit canonical=www so search consolidates.
|
||||
# redirect apex 308s to www via a zone Redirect Rule + proxied placeholder
|
||||
# record. This is what Vercel did, and needs zone DNS/rules scopes.
|
||||
APEX_MODE="${APEX_MODE:-serve}"
|
||||
WRANGLER="${WRANGLER_CMD:-npx --yes wrangler@4}"
|
||||
|
||||
mkdir -p "$STATE_DIR/state" "$STATE_DIR/stamps" "$STATE_DIR/baseline" "$STATE_DIR/logs"
|
||||
# How the apex behaves.
|
||||
# serve (current) the apex is a SECOND Pages custom domain and answers 200.
|
||||
# No redirect rule, no placeholder record. The pages emit
|
||||
# canonical=www, which is what consolidates the two hosts for search.
|
||||
# redirect the apex 308s to www. Kept because the assertion is cheap to keep
|
||||
# and the decision could be revisited; nothing sets it up any more.
|
||||
APEX_MODE="${APEX_MODE:-serve}"
|
||||
|
||||
section() { echo; echo "== $* =="; }
|
||||
warn() { echo "warn: $*" >&2; }
|
||||
die() { echo "cf-migrate: $*" >&2; exit 1; }
|
||||
die() { echo "verify: $*" >&2; exit 1; }
|
||||
|
||||
require_cmd() {
|
||||
for c in "$@"; do
|
||||
command -v "$c" >/dev/null 2>&1 || die "missing required command: $c"
|
||||
done
|
||||
}
|
||||
|
||||
# Refuse to run under `set -x` where secrets would land in the log.
|
||||
no_xtrace() {
|
||||
case "$-" in
|
||||
*x*) die "refusing to run under 'set -x' (secrets would be echoed)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Identity of "the thing that was verified" — the inputs that affect the built
|
||||
# output. Stamps are keyed on this so a stamp can never vouch for a later edit.
|
||||
ctx_hash() {
|
||||
( cd "$SITE_DIR" && \
|
||||
find src public functions astro.config.mjs package.json package-lock.json \
|
||||
wrangler.toml -type f 2>/dev/null \
|
||||
| LC_ALL=C sort | tr '\n' '\0' | xargs -0 shasum -a 256 2>/dev/null \
|
||||
| shasum -a 256 | cut -c1-16 )
|
||||
}
|
||||
|
||||
stamp_write() {
|
||||
_name="$1"; shift
|
||||
_f="$STATE_DIR/stamps/${_name}.$(ctx_hash)"
|
||||
{
|
||||
echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "ctx=$(ctx_hash)"
|
||||
echo "git=$(git -C "$PCBJAM_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown)"
|
||||
for kv in "$@"; do echo "$kv"; done
|
||||
} > "$_f"
|
||||
echo "stamped: ${_name} (ctx=$(ctx_hash))"
|
||||
}
|
||||
|
||||
# Dies unless a stamp for the CURRENT ctx_hash exists and is fresh.
|
||||
# CFM_FORCE=1 downgrades to a loud warning; it also requires CFM_I_UNDERSTAND=1.
|
||||
stamp_require() {
|
||||
_name="$1"; _max_h="${2:-${STAMP_MAX_AGE_H:-24}}"
|
||||
_f="$STATE_DIR/stamps/${_name}.$(ctx_hash)"
|
||||
if [ ! -f "$_f" ]; then
|
||||
if [ "${CFM_FORCE:-0}" = 1 ] && [ "${CFM_I_UNDERSTAND:-0}" = 1 ]; then
|
||||
warn "BYPASS: no '${_name}' stamp for the current tree, continuing anyway"
|
||||
return 0
|
||||
fi
|
||||
die "no '${_name}' stamp for the current tree (ctx=$(ctx_hash)).
|
||||
The site changed since it was last verified. Run the matching script first:
|
||||
${_name} -> see deploy/site/README.md
|
||||
To override (not advised): CFM_FORCE=1 CFM_I_UNDERSTAND=1 $0 ..."
|
||||
fi
|
||||
_age=$(( ( $(date +%s) - $(stat -f %m "$_f" 2>/dev/null || echo 0) ) / 3600 ))
|
||||
if [ "$_age" -ge "$_max_h" ]; then
|
||||
if [ "${CFM_FORCE:-0}" = 1 ] && [ "${CFM_I_UNDERSTAND:-0}" = 1 ]; then
|
||||
warn "BYPASS: '${_name}' stamp is ${_age}h old (max ${_max_h}h), continuing"
|
||||
return 0
|
||||
fi
|
||||
die "'${_name}' stamp is ${_age}h old (max ${_max_h}h) — re-verify."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- dry run ---------------------------------------------------------------
|
||||
# DRY_RUN=1 is the DEFAULT for every mutating script. `--apply` clears it.
|
||||
DRY_RUN="${DRY_RUN:-1}"
|
||||
|
||||
parse_common_flags() {
|
||||
CFM_ARGS=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--apply) DRY_RUN=0 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--yes) CFM_YES=1 ;;
|
||||
*) CFM_ARGS="$CFM_ARGS $a" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# dry "<label>" -- cmd... → prints WOULD in dry-run, else runs.
|
||||
dry() {
|
||||
_label="$1"; shift
|
||||
[ "$1" = "--" ] && shift
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "WOULD: $_label"
|
||||
echo " \$ $*"
|
||||
return 0
|
||||
fi
|
||||
echo "RUN: $_label"
|
||||
"$@"
|
||||
}
|
||||
|
||||
confirm_cutover() {
|
||||
[ "${CFM_YES:-0}" = 1 ] && return 0
|
||||
echo
|
||||
echo "This mutates PRODUCTION DNS for $ZONE_NAME."
|
||||
printf 'Type CUTOVER to proceed: '
|
||||
read -r _reply
|
||||
[ "$_reply" = "CUTOVER" ] || die "aborted (got '$_reply')"
|
||||
}
|
||||
|
||||
dry_banner() {
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "(dry run — nothing will be changed. re-run with --apply to act.)"
|
||||
fi
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
#!/usr/bin/env bash
|
||||
# The one HTTP parity sweep. Sourced by the verify scripts and run against four
|
||||
# bases, so "did anything change?" is always the same question:
|
||||
# The one HTTP parity sweep. Point it at any base URL and it asks the same
|
||||
# question, so a regression has nowhere to hide:
|
||||
#
|
||||
# https://www.pcbjam.com live Vercel (baseline)
|
||||
# http://127.0.0.1:8788 wrangler pages dev
|
||||
# https://<hash>.pcbjam-site.pages.dev the Pages deployment, pre-DNS
|
||||
# https://www.pcbjam.com after cutover
|
||||
# https://www.pcbjam.com production
|
||||
# https://<hash>.pcbjam-site.pages.dev a specific deployment, before promoting
|
||||
# http://127.0.0.1:8788 `wrangler pages dev` locally
|
||||
#
|
||||
# It was written for the Vercel -> Cloudflare Pages migration and several
|
||||
# assertions exist because that migration found real defects; the comments say
|
||||
# which, since the reason is the only thing stopping someone "simplifying" them
|
||||
# back out.
|
||||
#
|
||||
# Usage: assert_parity <base-url> [--scope local|preview|prod] [--live-post]
|
||||
# Exit: 0 if every hard assertion passed (warnings do not fail), else 1.
|
||||
|
|
@ -331,8 +335,8 @@ assert_apex_serves() {
|
|||
return $_rc
|
||||
}
|
||||
|
||||
# APEX_MODE=redirect: apex -> www, path + query preserved. Also used by
|
||||
# 00-baseline.sh, since that is what Vercel does today.
|
||||
# APEX_MODE=redirect: apex -> www, path + query preserved. Unused with the
|
||||
# current `serve` topology; kept in case that decision is revisited.
|
||||
assert_apex_redirect() {
|
||||
_apex="${1:-$APEX_BASE}"
|
||||
section "apex redirect: $_apex"
|
||||
|
|
|
|||
Loading…
Reference in a new issue