Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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).