307 lines
16 KiB
Markdown
307 lines
16 KiB
Markdown
# 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
|
||
|
||
```text
|
||
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's`ssh 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).
|