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:
Viktor Vaczi 2026-07-27 13:34:12 +02:00
commit 7edfade53c
41 changed files with 2443 additions and 910 deletions

120
.github/workflows/deploy-site.yml vendored Normal file
View file

@ -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 <NAME> --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; }