Files
2026-05-27 23:29:53 +01:00

16 KiB
Raw Permalink Blame History

react-ui-registry-deployment

Field Value
Change ID react-ui-registry-deployment
Category ops
Target path ops/registry/
Status PROPOSED
Date 2026-05-05
Prereq (SHIPPED) enterprise-react-i18n — archived at openspec/archive/2026-05-05-enterprise-react-i18n/; capability spec at openspec/specs/react-i18n/spec.md. The registry hosts the source-eject manifests for @suaveplan/react-ui components, every one of which renders user-facing strings via TranslationKey-typed props the consumer resolves through the upstream TranslationProvider. The registry transform never strips i18n imports — source-ejected components retain the i18n integration.
Prereq (in-flight) enterprise-react-ui-v2 — owns the registry source. The Caddy + Docker setup in this change just serves the JSON manifests + the Storybook static; it depends on the package shipping its scripts/build-registry.ts (Wave 2) and per-component manifests (Wave 3a-3h).

Why

@suaveplan/react-ui is a hybrid runtime library + shadcn-compatible source-eject registry. The runtime library (bun add @suaveplan/react-ui) works in isolation, but the source-eject path (npx shadcn@latest add @suaveplan/<component>) requires a public HTTPS endpoint serving the per-component registry JSON. Without that endpoint, external consumers cannot ingest components directly into their own src/components/ui/ trees, and the entire shadcn-compatible half of the package is functionally inert outside this monorepo.

The user has already provisioned ui.suaveplan.com on OVH (DNS A record points to the OVH host; ssh root@ovh reaches it). This change scaffolds the Docker + Caddy deployment that:

  1. serves the registry JSON at https://ui.suaveplan.com/r/*.json so consumers can run npx shadcn@latest add @suaveplan/<component> against a real HTTPS URL,
  2. serves the Storybook static build at https://ui.suaveplan.com/storybook/ under the same domain so prospective consumers can preview components before adding them,
  3. wires a GitHub Actions deploy pipeline that rebuilds both artifacts on every push to genesis affecting the relevant sources and rsyncs them to OVH.

This is the §3.5 + §10.3 deliverable from the master cross-cutting design at openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md.

Current-state audit

  • ops/registry/ does not exist on the genesis branch.
  • ui.suaveplan.com resolves to the OVH host but no service responds on 80 or 443 yet (TLS will be issued on first caddy boot).
  • packages/react/react-ui/scripts/build-registry.ts does not exist yet — registry generation is a deliverable of this change because the deploy pipeline cannot ship until something can produce the artifact it ships.
  • No .github/workflows/registry-deploy.yml exists.
  • No prior ops-tier change has landed on genesis; openspec/changes/ has 100+ entries but they are all package-tier. This change establishes the convention that ops-tier changes live in openspec/changes/ alongside package changes and target a path outside packages/.

Architecture decisions

  1. Static-only Caddy serving for v1. The registry is a tree of JSON files; there is no runtime needed. A caddy:2-alpine image serves the files directly with file_server and encode gzip zstd. Live preview pages requiring a Bun runtime are explicitly Phase 2 and not in this change.
  2. Single domain, two paths. ui.suaveplan.com/r/* for the registry; ui.suaveplan.com/storybook/* for hosted Storybook. Both share the same Caddyfile, the same TLS cert, and the same /srv/registry/ filesystem root on the OVH host. Subdomains (registry.suaveplan.com, storybook.suaveplan.com) were considered and rejected — one cert, one Caddyfile, one rsync target is operationally simpler and the user provisioned exactly one subdomain.
  3. Caddy 2 alpine image, auto-TLS via Let's Encrypt. No manual cert management. HTTP/2, gzip + zstd encoding, and HSTS preload enabled. TLS 1.2+ enforced (TLS 1.3 preferred); legacy ciphers disabled.
  4. Docker Compose single-service deployment. One service (registry), volume-mounted /srv/registry/ for content, named volumes for Caddy data + config so cert state survives container restarts.
  5. CI builds + rsync deploy. GitHub Actions runs on push to genesis when packages/react/react-ui/registry/**, packages/react/react-ui/src/**, or packages/react/react-ui/.storybook/** changes. The job builds the registry JSON via bun packages/react/react-ui/scripts/build-registry.ts, builds the Storybook static via the package's existing build-storybook script, then rsyncs both into /srv/registry/r/ and /srv/registry/storybook/ respectively. Caddy picks up changes live (no restart needed for static files).
  6. Scoped deploy SSH user, not root. The user noted ssh root@ovh works for ad-hoc admin tasks. For CI we deploy as a dedicated deploy-registry user with a single sudoers rule permitting rsync into /srv/registry/ only. This blast-radius reduction is the standard hardening; the user retains root@ovh for all manual operations.
  7. Phase 2 deferred. A Bun container that renders live preview pages at https://ui.suaveplan.com/preview/<component> is explicitly out of scope. It would need its own image, its own service in docker-compose.yml, and a reverse-proxy block in the Caddyfile. Land it after one or two real consumers are using the registry.

File layout

ops/registry/
  Dockerfile                  # FROM caddy:2-alpine, copies Caddyfile in
  Caddyfile                   # ui.suaveplan.com { ... } block, two paths
  docker-compose.yml          # single registry service, named cert volumes
  README.md                   # full deployment runbook (lives here, not in this change)
  .env.example                # OVH_SSH_HOST / OVH_SSH_USER placeholders, no secrets

.github/workflows/
  registry-deploy.yml         # build registry + storybook, rsync, reload

packages/react/react-ui/scripts/
  build-registry.ts           # generates registry JSON from package source

The deployment runbook (ops/registry/README.md) is created as part of this change but its content is operational and long; this proposal references it without inlining it.

What changes

ADDED

  • ops/registry/Dockerfile — FROM caddy:2-alpine with the Caddyfile copied to /etc/caddy/Caddyfile. No app code, no Bun.
  • ops/registry/Caddyfile — ui.suaveplan.com site block with root * /srv/registry, two handle_path blocks for /r/* and /storybook/*, encode gzip zstd, header block setting HSTS
    • X-Content-Type-Options + Referrer-Policy, tls directive pinned to min_version 1.2, and a /health endpoint returning 200 OK from a static respond directive.
  • ops/registry/docker-compose.yml — single registry service using the local Dockerfile, mounts /srv/registry/:/srv/registry/ read-only, named volumes caddy_data and caddy_config for cert state, exposes 80:80 and 443:443, restart policy unless-stopped.
  • ops/registry/README.md — deployment runbook covering first-time setup, on-deploy flow, rollback, secret rotation, and the manual vs. CI access split (root for ad-hoc, scoped user for CI).
  • ops/registry/.env.example — placeholders for OVH_SSH_HOST, OVH_SSH_USER, REGISTRY_DEPLOY_PATH, STORYBOOK_DEPLOY_PATH. No secrets; instructions point to GitHub Actions secrets for the SSH private key.
  • .github/workflows/registry-deploy.yml — workflow triggered on push to genesis with path filters. Steps: checkout, setup Bun, install deps via the cached lockfile, build registry JSON, build Storybook static, set up SSH agent from secret, rsync both trees, optionally call caddy reload (no-op for pure static updates but harmless and documents the pattern for future config changes).
  • packages/react/react-ui/scripts/build-registry.ts — generates the per-component registry JSON tree from the package source. Lives in the package (not in ops/) because it consumes the package's own components and types; keeping it co-located means it builds correctly under bunx turbo build --filter=@suaveplan/react-ui and the registry shape stays in sync with the components it describes. Emits to packages/react/react-ui/registry/r/*.json matching the shadcn registry schema (registry-item v1).

Verification approach

Per CLAUDE.md "Web App & Page Verification" — a 200 response is not proof of correctness. Every requirement is tied to a verification step.

  1. TLS + cert validity. After first deploy, verify openssl s_client -connect ui.suaveplan.com:443 -servername ui.suaveplan.com </dev/null 2>/dev/null | openssl x509 -noout -dates -subject -issuer shows a valid Let's Encrypt cert with subject=CN=ui.suaveplan.com and notAfter ≥ 60 days out.
  2. Registry JSON schema conformance. A Playwright check fetches https://ui.suaveplan.com/r/index.json plus a sampled subset of per-component manifests and validates each against the registry-item schema from the shadcn project.
  3. Storybook static loads cleanly. Playwright navigates to https://ui.suaveplan.com/storybook/, asserts the Storybook root element renders, asserts zero console.error and zero console.warn over a 5-second observation window, and asserts every loaded subresource returned 2xx (checked via page.on('response', ...) capturing every request that fired during navigation).
  4. End-to-end shadcn add. A Playwright test creates a throwaway Vite + React project in a temp dir, points its components.json at the public registry, runs npx shadcn@latest add @suaveplan/button, and asserts the file landed in src/components/ui/button.tsx with the expected exported Button symbol. This is the contract test that proves the registry actually works end-to-end.
  5. Caddy access log sanity. After 24 h of live traffic, the Caddy access log SHALL show a 5xx rate of zero across the verification window (docker compose exec registry cat /var/log/caddy/access.log | grep -E ' 5[0-9]{2} ' | wc -l).
  6. Idempotent deploy. Re-running the GitHub Action with no source changes SHALL leave the live tree byte-identical (verified by checksum comparison against the previous deploy snapshot).
  7. Rollback works in one command. The runbook documents bash ops/registry/rollback.sh; the verification test triggers it on the OVH host and confirms https://ui.suaveplan.com/r/index.json serves the previous registry contents.

Each requirement in specs/registry-deployment/spec.md ends with a **Verified by:** line naming the concrete check.

Security considerations

  1. Read-only registry. Caddy serves files; there is no write endpoint. Consumers GET; nobody POSTs. CORS is closed by default except for the registry JSON path, which sets Access-Control-Allow-Origin: * to permit npx shadcn from any origin (the JSON is public anyway).
  2. Scoped deploy SSH user. CI logs in as deploy-registry, not root. The user has a single sudoers rule: `deploy-registry ALL=(root) NOPASSWD: /usr/bin/rsync --server
    • /srv/registry/. Even if the GitHub Actions secret leaks, the blast radius is "overwrite the public registry" — not "own the OVH host". The user'sssh root@ovh` access remains for ad-hoc admin work and is fine for that purpose; the recommendation is purely to scope CI down.
  3. TLS 1.2 minimum. Caddyfile pins tls { protocols tls1.2 tls1.3 }. HSTS is set with max-age=31536000; includeSubDomains; preload and the domain is to be added to the HSTS preload list after a 90-day soak.
  4. No secrets in committed files. ops/registry/.env.example uses __PLACEHOLDER__ strings only. The actual SSH private key, host, and user live in GitHub Actions secrets named OVH_SSH_PRIVATE_KEY, OVH_SSH_HOST, and OVH_SSH_USER. The README documents these names but never their values.
  5. No .well-known leakage. The Caddyfile explicitly does not serve /.well-known/* from the registry root (Caddy's ACME handler manages its own /.well-known/acme-challenge/ path internally; nothing else under .well-known is exposed).
  6. Header hardening. Every response carries X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, and Permissions-Policy: interest-cohort=(). Storybook responses additionally carry a Content-Security-Policy that permits inline styles (Storybook needs them) but blocks third-party scripts.

Operational runbook

The full runbook lives in ops/registry/README.md after implementation. Key flows:

  • First-time setup. SSH to OVH; clone genesis; cd ops/registry; cp .env.example .env and fill in real values; docker compose up -d; wait 30–60 s for Let's Encrypt to issue; verify curl -fsSL https://ui.suaveplan.com/health returns 200 OK; create the deploy-registry user and install the CI public key as documented.
  • On-deploy. Push to genesis. GitHub Action runs on path filter; rebuilds registry + Storybook; rsyncs to OVH. Caddy serves the new content immediately (static files; no reload needed).
  • Rollback. Each deploy snapshots the previous tree to /srv/registry/.previous/<utc-timestamp>/. The ops/registry/rollback.sh script (shipped in the runbook) atomic- swaps the current tree with the most recent snapshot. Documented as one-shot: bash ops/registry/rollback.sh.

Out of scope

  • Phase 2 live preview pages. Adding a Bun container that server-renders https://ui.suaveplan.com/preview/<component> is deferred until at least one consumer is using the registry. It needs its own image, service, and reverse-proxy block.
  • Multi-region CDN. Cloudflare in front of OVH would help on global read latency but is unjustified at zero traffic. Defer to first measured scale problem.
  • Authenticated registries. Private packs and paid themes need signed URLs and a token-issuance flow. Not in v1; the public registry is the only consumer of this domain.
  • Versioned registry trees (multiple major versions side-by-side). Not needed until @suaveplan/react-ui cuts a 1.0 and starts publishing 2.0 in parallel. The single-version registry covers every pre-1.0 state.
  • Webhooks or push-notification of upstream changes. The pull-on-demand model fits shadcn's CLI behavior; consumers run npx shadcn@latest add when they want updates.
  • A web UI for browsing the registry. Storybook IS the browse UI in v1.

Impact

Affected dependents

This change does not modify any genesis package source. It scaffolds ops infrastructure plus one new script inside @suaveplan/react-ui. The blast radius is therefore the new script and any future consumer of the public registry.

  • @suaveplan/react-ui — [x] tests run / [x] docs reviewed / [ ] no action needed (reason: this change adds scripts/build-registry.ts to the package; the package's own test suite must continue to pass after the script is added, and the registry-build smoke test is wired into the package's CI alongside its existing checks).
  • All future external consumers of npx shadcn@latest add @suaveplan/<component> — no in-repo dependents to test today; the contract test in step 4 of the verification approach is the binding compatibility check.

For all other genesis packages: no action needed (the ops change does not touch their source, dependencies, or build outputs).