Initial Commit
This commit is contained in:
306
openspec/changes/react-ui-registry-deployment/proposal.md
Normal file
306
openspec/changes/react-ui-registry-deployment/proposal.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user