16 KiB
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:
- serves the registry JSON at
https://ui.suaveplan.com/r/*.jsonso consumers can runnpx shadcn@latest add @suaveplan/<component>against a real HTTPS URL, - serves the Storybook static build at
https://ui.suaveplan.com/storybook/under the same domain so prospective consumers can preview components before adding them, - wires a GitHub Actions deploy pipeline that rebuilds both
artifacts on every push to
genesisaffecting 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.comresolves to the OVH host but no service responds on 80 or 443 yet (TLS will be issued on firstcaddyboot).packages/react/react-ui/scripts/build-registry.tsdoes 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.ymlexists. - 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 inopenspec/changes/alongside package changes and target a path outsidepackages/.
Architecture decisions
- Static-only Caddy serving for v1. The registry is a tree of
JSON files; there is no runtime needed. A
caddy:2-alpineimage serves the files directly withfile_serverandencode gzip zstd. Live preview pages requiring a Bun runtime are explicitly Phase 2 and not in this change. - 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. - 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.
- 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. - CI builds + rsync deploy. GitHub Actions runs on push to
genesiswhenpackages/react/react-ui/registry/**,packages/react/react-ui/src/**, orpackages/react/react-ui/.storybook/**changes. The job builds the registry JSON viabun packages/react/react-ui/scripts/build-registry.ts, builds the Storybook static via the package's existingbuild-storybookscript, then rsyncs both into/srv/registry/r/and/srv/registry/storybook/respectively. Caddy picks up changes live (no restart needed for static files). - Scoped deploy SSH user, not
root. The user notedssh root@ovhworks for ad-hoc admin tasks. For CI we deploy as a dedicateddeploy-registryuser with a single sudoers rule permittingrsyncinto/srv/registry/only. This blast-radius reduction is the standard hardening; the user retainsroot@ovhfor all manual operations. - 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 indocker-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-alpinewith the Caddyfile copied to/etc/caddy/Caddyfile. No app code, no Bun.ops/registry/Caddyfile—ui.suaveplan.comsite block withroot * /srv/registry, twohandle_pathblocks for/r/*and/storybook/*,encode gzip zstd,headerblock setting HSTSX-Content-Type-Options+Referrer-Policy,tlsdirective pinned tomin_version 1.2, and a/healthendpoint returning200 OKfrom a staticresponddirective.
ops/registry/docker-compose.yml— singleregistryservice using the local Dockerfile, mounts/srv/registry/:/srv/registry/read-only, named volumescaddy_dataandcaddy_configfor cert state, exposes80:80and443:443, restart policyunless-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 forOVH_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 togenesiswith 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 callcaddy 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 inops/) because it consumes the package's own components and types; keeping it co-located means it builds correctly underbunx turbo build --filter=@suaveplan/react-uiand the registry shape stays in sync with the components it describes. Emits topackages/react/react-ui/registry/r/*.jsonmatching the shadcn registry schema (registry-itemv1).
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.
- 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 -issuershows a valid Let's Encrypt cert withsubject=CN=ui.suaveplan.comandnotAfter≥ 60 days out. - Registry JSON schema conformance. A Playwright check fetches
https://ui.suaveplan.com/r/index.jsonplus a sampled subset of per-component manifests and validates each against theregistry-itemschema from the shadcn project. - Storybook static loads cleanly. Playwright navigates to
https://ui.suaveplan.com/storybook/, asserts the Storybook root element renders, asserts zeroconsole.errorand zeroconsole.warnover a 5-second observation window, and asserts every loaded subresource returned 2xx (checked viapage.on('response', ...)capturing every request that fired during navigation). - End-to-end shadcn add. A Playwright test creates a
throwaway Vite + React project in a temp dir, points its
components.jsonat the public registry, runsnpx shadcn@latest add @suaveplan/button, and asserts the file landed insrc/components/ui/button.tsxwith the expected exportedButtonsymbol. This is the contract test that proves the registry actually works end-to-end. - 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). - 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).
- Rollback works in one command. The runbook documents
bash ops/registry/rollback.sh; the verification test triggers it on the OVH host and confirmshttps://ui.suaveplan.com/r/index.jsonserves the previous registry contents.
Each requirement in specs/registry-deployment/spec.md ends with a
**Verified by:** line naming the concrete check.
Security considerations
- 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 permitnpx shadcnfrom any origin (the JSON is public anyway). - Scoped deploy SSH user. CI logs in as
deploy-registry, notroot. 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.
- /srv/registry/
- TLS 1.2 minimum. Caddyfile pins
tls { protocols tls1.2 tls1.3 }. HSTS is set withmax-age=31536000; includeSubDomains; preloadand the domain is to be added to the HSTS preload list after a 90-day soak. - No secrets in committed files.
ops/registry/.env.exampleuses__PLACEHOLDER__strings only. The actual SSH private key, host, and user live in GitHub Actions secrets namedOVH_SSH_PRIVATE_KEY,OVH_SSH_HOST, andOVH_SSH_USER. The README documents these names but never their values. - No
.well-knownleakage. 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-knownis exposed). - Header hardening. Every response carries
X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer, andPermissions-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 .envand fill in real values;docker compose up -d; wait 30–60 s for Let's Encrypt to issue; verifycurl -fsSL https://ui.suaveplan.com/healthreturns200 OK; create thedeploy-registryuser 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>/. Theops/registry/rollback.shscript (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-uicuts 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 addwhen 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 addsscripts/build-registry.tsto 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).