diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 0000000..d42e008 --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,120 @@ +name: deploy-site (marketing site) + +# Deploys the Astro marketing site + blog in site/ to Cloudflare Pages +# (www.pcbjam.com, Pages project `pcbjam-site`). +# +# push to main touching site/** ──▶ npm ci → npm test → astro build +# → wrangler pages deploy → www.pcbjam.com +# +# NOT tag-gated, deliberately. The site was previously deployed by Vercel's git +# integration on every push, and blog posts / copy fixes must not have to wait +# for a vX.Y.Z release. The tag-gated pipeline (release.yml) ships the WASM +# editor; this ships content, and the two are independent. +# +# The site is a STANDALONE npm project (its own package-lock.json, not the +# web/ pnpm workspace) and needs Node >= 22.12 for Astro 6 — hence npm ci and +# node-version 22 rather than the pnpm + node 20 used by the other workflows. +# +# The one dynamic route, /api/waitlist, ships as a Cloudflare Pages Function +# from site/functions/. Its secrets (RESEND_API_KEY, RESEND_SEGMENT_ID, +# WAITLIST_FROM_EMAIL) are NOT deploy inputs: set once with +# `wrangler pages secret put --project-name pcbjam-site`. +# See deploy/site/README.md for the full runbook. +# +# Secrets (Settings → Secrets → Actions) — already present for demo/editor: +# CLOUDFLARE_API_TOKEN Cloudflare Pages:Edit +# CLOUDFLARE_ACCOUNT_ID + +on: + push: + branches: ["main"] + paths: ["site/**", ".github/workflows/deploy-site.yml"] + workflow_dispatch: + +# Serialize site deploys so two pushes don't race the live host (don't cancel a +# half-finished deploy — let it complete). +concurrency: + group: deploy-site + cancel-in-progress: false + +env: + PAGES_PROJECT: pcbjam-site + # MUST be the Pages project's PRODUCTION branch — any other value makes + # `wrangler pages deploy` a PREVIEW deploy and www.pcbjam.com won't update. + # Direct-Upload projects default to "production". + PAGES_PROD_BRANCH: production + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # No submodules: the site shares no code with the WASM tools. A real + # checkout is still needed — Footer.astro resolves the GPLv3 + # corresponding-source SHA from GITHUB_SHA (with a `git rev-parse` + # fallback), and that value is user-visible in the footer. + - uses: actions/checkout@v4 + with: + submodules: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: site/package-lock.json + + - name: Install site deps + working-directory: site + run: npm ci + + # 1) Gate on the site's own vitest suite (waitlist Function hardening + + # the gerber-demo boot.js override gate). Nothing else runs it. + - name: Test + working-directory: site + run: npm test + + # 2) Static build. Emits dist/ only — no adapter, no server bundle. The + # Function comes from site/functions/, which wrangler bundles at deploy. + - name: Build + working-directory: site + run: npm run build + + # 3) Ensure the Pages project exists (first deploy creates it; no-op + # after). Its production branch must equal PAGES_PROD_BRANCH or deploys + # land as previews and www.pcbjam.com won't update. + - name: Ensure Pages project exists + working-directory: site + run: > + npx --yes wrangler@4 pages project create "$PAGES_PROJECT" + --production-branch "$PAGES_PROD_BRANCH" + --compatibility-date 2026-06-01 --compatibility-flags nodejs_compat + || echo "pages project create skipped (already exists)" + + # 4) Deploy. Run from site/ so wrangler picks up site/wrangler.toml (which + # sets pages_build_output_dir + nodejs_compat) AND discovers + # site/functions/ — deploying from the repo root would silently ship a + # static-only site with /api/waitlist 404ing. + - name: Deploy to Cloudflare Pages + working-directory: site + run: > + npx --yes wrangler@4 pages deploy + --project-name "$PAGES_PROJECT" + --branch "$PAGES_PROD_BRANCH" + --commit-dirty=true + + # 5) Smoke: the deployed Function answers a preflight for the demo origin. + # demo.pcbjam.com cross-posts the waitlist form here and a CORS + # preflight cannot follow a redirect, so this must be 204 directly on + # www — not after a hop. + - name: Smoke-check the waitlist endpoint + run: | + for i in $(seq 1 20); do + code=$(curl -s -o /dev/null -w '%{http_code}' -X OPTIONS \ + -H 'Origin: https://demo.pcbjam.com' \ + -H 'Access-Control-Request-Method: POST' \ + https://www.pcbjam.com/api/waitlist || true) + [ "$code" = "204" ] && break; sleep 3 + done + test "$code" = "204" || { echo "waitlist preflight returned $code, expected 204"; exit 1; } diff --git a/CLAUDE.md b/CLAUDE.md index e168eb4..74b66e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,4 +28,15 @@ Don't try to guess what's broken , use debug tools / symbols, supported by the b Feature docs/patches are in features//. Run scripts/create-feature-patches.sh to save patches for root, kicad, wxwidgets submodules. -The landing page / website is in /site (Astro, built and deployed by Vercel on push). The footer shows a build SHA that links to the pcbjam commit the site was built from; because it pins the kicad + wxwidgets submodule revisions implicitly, it is our GPLv3 corresponding-source pointer (see /licenses). It resolves automatically at build time in site/src/components/Footer.astro (VERCEL_GIT_COMMIT_SHA on Vercel, `git rev-parse` locally) — no manual bump needed. +The landing page / website is in /site (Astro, static, deployed to Cloudflare Pages +by .github/workflows/deploy-site.yml on every push to main touching site/**). +It has no Astro adapter; the one dynamic route (/api/waitlist) is a Cloudflare +Pages Function in site/functions/. Prod response headers come from +site/public/_headers (COOP/COEP for the embedded Gerber viewer — never widen +them to /*, the landing page must stay un-isolated for the YouTube embed). +The footer shows a build SHA that links to the pcbjam commit the site was built +from; because it pins the kicad + wxwidgets submodule revisions implicitly, it is +our GPLv3 corresponding-source pointer (see /licenses). It resolves automatically +at build time in site/src/components/Footer.astro (CF_PAGES_COMMIT_SHA / GITHUB_SHA +in CI, `git rev-parse` locally) — no manual bump needed. +The Cloudflare setup + cutover runbook is in pcbjam/deploy/site/README.md. diff --git a/deploy/site/00-baseline.sh b/deploy/site/00-baseline.sh new file mode 100755 index 0000000..171fc9b --- /dev/null +++ b/deploy/site/00-baseline.sh @@ -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" diff --git a/deploy/site/01-preflight.sh b/deploy/site/01-preflight.sh new file mode 100755 index 0000000..33c6bf8 --- /dev/null +++ b/deploy/site/01-preflight.sh @@ -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 "