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

View File

@@ -0,0 +1,135 @@
# registry-deployment spec — v1
## ADDED Requirements
### Requirement: Public HTTPS endpoint at `ui.suaveplan.com`
The deployment SHALL serve the registry on `https://ui.suaveplan.com`
with a valid X.509 certificate issued by Let's Encrypt. The
certificate SHALL chain to a trusted root, SHALL list
`ui.suaveplan.com` as the subject CN or as a SAN, and SHALL have a
`notAfter` value at least 30 days in the future at any observation
point during normal operation. Caddy 2's automatic ACME flow is
the issuance mechanism; manual cert provisioning is not used.
**Verified by:** `openssl s_client -connect
ui.suaveplan.com:443 -servername ui.suaveplan.com` issued from the
post-deploy verification job; cert validity, subject, and renewal
window are asserted.
### Requirement: Registry JSON conforms to shadcn `registry-item` schema
Every JSON file served under `https://ui.suaveplan.com/r/*.json`
SHALL conform to the shadcn `registry-item` v1 schema: required
fields are `$schema`, `name`, `type`, `files`; optional fields
include `dependencies`, `registryDependencies`, `tailwind`,
`cssVars`. The build step SHALL validate every emitted file
against a Zod schema before the deploy job uploads it; a schema
violation SHALL fail the build.
**Verified by:** the build script's pre-upload validation pass
plus the post-deploy Playwright check that fetches
`/r/index.json` and a sampled subset of per-component manifests
and re-validates each against the same schema.
### Requirement: `npx shadcn@latest add @suaveplan/<component>` works
A consumer running `npx shadcn@latest add @suaveplan/<component>`
in a project whose `components.json` references
`https://ui.suaveplan.com/r/{name}.json` SHALL successfully fetch
and write the component source into the consumer's
`src/components/ui/<component>.tsx` (or the path the consumer's
`components.json` `aliases` resolve to). The fetched file SHALL
be byte-identical to the file declared in the registry manifest.
**Verified by:** the end-to-end contract test that creates a
throwaway Vite project, points it at the public registry, runs
`npx shadcn@latest add @suaveplan/button`, and asserts the
output file exists and exports the `Button` symbol.
### Requirement: Storybook static loads without console errors
A request to `https://ui.suaveplan.com/storybook/` SHALL return
the Storybook static index page. A headless Chromium navigating
to that URL SHALL render the Storybook root element, SHALL log
zero `console.error` messages over a 5-second observation window,
and every subresource request fired during navigation SHALL
return a 2xx status.
**Verified by:** Playwright test in the post-deploy verification
job using `page.on('console', ...)` and `page.on('response', ...)`
to capture the full session.
### Requirement: Deploy is idempotent
Re-running the GitHub Actions deploy workflow against the same
input commit SHALL leave the served file tree byte-identical to
the prior deploy. Successive deploys with no source changes SHALL
NOT thrash the live tree. The workflow's `concurrency:` block
SHALL serialise simultaneous runs of the same workflow on the same
ref so back-to-back pushes never race.
**Verified by:** checksum comparison between the pre- and
post-deploy snapshots in the verification job, plus a manual
re-run of the workflow to confirm the second run is a no-op
beyond the snapshot copy.
### Requirement: Rollback is one command
The previous registry tree SHALL be retained at
`/srv/registry/.previous/<utc-timestamp>/` after every deploy.
A single command (`bash ops/registry/rollback.sh`) executed on
the OVH host SHALL atomically swap the live tree with the most
recent snapshot. A second invocation immediately after SHALL roll
forward to what was previously live.
**Verified by:** the runbook test in the verification gate, which
runs the rollback once, confirms the live registry now serves the
prior contents, then rolls forward and confirms current contents
are restored.
### Requirement: TLS 1.2 minimum, HSTS, security headers
The Caddy configuration SHALL pin `protocols tls1.2 tls1.3` —
TLS 1.0 and TLS 1.1 are rejected. Every response from the domain
SHALL carry the headers `Strict-Transport-Security: max-age=31536000;
includeSubDomains; preload`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy: no-referrer`, and `Permissions-Policy:
interest-cohort=()`. Storybook responses additionally carry a
restrictive Content-Security-Policy.
**Verified by:** `curl -sI https://ui.suaveplan.com/r/index.json`
and `curl -sI https://ui.suaveplan.com/storybook/` in the
post-deploy job; assertions on each required header. TLS protocol
floor verified by `openssl s_client -tls1_1` returning a handshake
failure.
### Requirement: No secrets in committed files
No file under `ops/registry/` or `.github/workflows/` SHALL
contain the value of any secret (SSH private key, host name, user
name, API token). Placeholders (`__OVH_HOST__`, `__SECRET__`)
appear in `ops/registry/.env.example`; real values live in GitHub
Actions secrets named `OVH_SSH_PRIVATE_KEY`, `OVH_SSH_HOST`,
`OVH_SSH_USER` and are documented in `ops/registry/README.md` by
name only. The runbook's secret-rotation section names the
secrets but never their values.
**Verified by:** `git grep -nE
'(BEGIN OPENSSH PRIVATE KEY|ssh-(rsa|ed25519) AAAA)'` returning
zero matches in the change set; manual review of every committed
file under `ops/registry/` and the workflow file.
### Requirement: CI deploys as a scoped non-root user
The GitHub Actions workflow SHALL connect to OVH as
`deploy-registry`, NOT as `root`. The `deploy-registry` user's
sudoers rule SHALL permit only `rsync` writes into
`/srv/registry/`; no shell, no general sudo. The user's
`ssh root@ovh` access remains available for ad-hoc admin tasks
and is intentionally not used by CI to keep the CI blast radius
minimal.
**Verified by:** review of the runbook's user-creation section
and the sudoers rule it documents; review of the workflow file
to confirm the SSH connection target is the scoped user.

View File

@@ -0,0 +1,164 @@
# tasks — react-ui-registry-deployment
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Registry build script (in `@suaveplan/react-ui`)
- [ ] 1.1 Create `packages/react/react-ui/scripts/build-registry.ts`.
Walks the package's component source tree and emits one
`registry-item` JSON per component plus a top-level
`registry/r/index.json` listing every entry.
- [ ] 1.2 Match the shadcn `registry-item` v1 schema exactly:
`$schema`, `name`, `type`, `dependencies`, `registryDependencies`,
`files[]`, `tailwind`, `cssVars`. Validate the emitted JSON
against a Zod schema before writing — the build fails on shape
drift.
- [ ] 1.3 Emit deterministic output: stable key order, LF
line endings, trailing newline. The CI snapshot diff in the
package's existing test suite will catch accidental drift.
- [ ] 1.4 Co-locate `build-registry.md` per the genesis docs rule
(≥200 words, Purpose / Features / Basic Example / Advanced
Example / API Reference / Implementation Notes).
- [ ] 1.5 Add a smoke test
`packages/react/react-ui/scripts/build-registry.test.ts` that
invokes the script in a temp dir, parses every emitted JSON, and
asserts schema conformance.
## 2. Caddy + Docker scaffolding
- [ ] 2.1 Write `ops/registry/Dockerfile` — `FROM caddy:2-alpine`,
`COPY Caddyfile /etc/caddy/Caddyfile`, healthcheck on
`/health`.
- [ ] 2.2 Write `ops/registry/Caddyfile` — `ui.suaveplan.com` site
block with:
- `root * /srv/registry`
- `handle_path /r/*` → `file_server` with
`Access-Control-Allow-Origin: *`
- `handle_path /storybook/*` → `file_server` with restrictive
CSP
- `handle /health` → `respond 200 "ok"`
- `tls` block pinning `protocols tls1.2 tls1.3`
- `header` block with HSTS, `X-Content-Type-Options`,
`Referrer-Policy`, `Permissions-Policy`
- `encode gzip zstd`
- explicit deny on `/.well-known/*` outside ACME
- [ ] 2.3 Write `ops/registry/docker-compose.yml` — one service
`registry`, port `80:80` and `443:443`, named volumes
`caddy_data` and `caddy_config`, bind-mount
`/srv/registry:/srv/registry:ro`, `restart: unless-stopped`.
- [ ] 2.4 Write `ops/registry/.env.example` — placeholders
`OVH_SSH_HOST=__OVH_HOST__`, `OVH_SSH_USER=deploy-registry`,
`REGISTRY_DEPLOY_PATH=/srv/registry/r/`,
`STORYBOOK_DEPLOY_PATH=/srv/registry/storybook/`. No real
values.
## 3. Deployment runbook
- [ ] 3.1 Write `ops/registry/README.md` covering:
- Architecture overview (one Caddy container, two paths)
- First-time setup (clone, env, `docker compose up -d`, cert
issuance check)
- Creating the `deploy-registry` user with scoped sudoers rule
- Installing the CI public key on OVH
- The on-deploy flow (path-filtered GitHub Action, rsync,
no-reload-needed-for-static)
- Manual ad-hoc admin via `ssh root@ovh` (out-of-band; CI
intentionally does not use root)
- Rollback via `bash ops/registry/rollback.sh` (one-shot atomic
swap from `/srv/registry/.previous/<timestamp>/`)
- Cert renewal (Caddy handles automatically; how to verify and
how to force-renew if needed)
- GitHub Actions secret rotation steps
- [ ] 3.2 Write `ops/registry/rollback.sh` referenced from the
runbook. Idempotent, exits non-zero with a clear message if no
snapshot exists.
## 4. CI workflow
- [ ] 4.1 Write `.github/workflows/registry-deploy.yml` — triggers
on push to `genesis` with path filter
`packages/react/react-ui/**` plus `ops/registry/**`. Job steps:
- `actions/checkout@v4`
- Setup Bun via `oven-sh/setup-bun@v2`
- `bun install --frozen-lockfile`
- `bunx turbo build --filter=@suaveplan/react-ui`
- `bun packages/react/react-ui/scripts/build-registry.ts`
- `cd packages/react/react-ui && bun run build-storybook`
- `webfactory/ssh-agent@v0.9.0` with the `OVH_SSH_PRIVATE_KEY`
secret
- `rsync -azh --delete --backup
--backup-dir=/srv/registry/.previous/$(date -u
+%Y%m%dT%H%M%SZ)/r/ packages/react/react-ui/registry/r/
"$OVH_SSH_USER@$OVH_SSH_HOST:/srv/registry/r/"`
- same for storybook with its own `--backup-dir`
- post-deploy `curl -fsSL
https://ui.suaveplan.com/health` smoke test
- [ ] 4.2 Add a `concurrency:` block keyed on the workflow + ref
so back-to-back pushes serialise, never race.
- [ ] 4.3 Document the three required GitHub Actions secrets in
the runbook (`OVH_SSH_PRIVATE_KEY`, `OVH_SSH_HOST`,
`OVH_SSH_USER`). NEVER commit values.
## 5. Manual user steps (call out clearly)
The user MUST perform these one-time manual steps; they cannot be
scripted in this change because they involve secrets and OVH
host-side configuration.
- [ ] 5.1 **USER:** Generate a deploy SSH keypair locally
(`ssh-keygen -t ed25519 -C "registry-deploy" -f
~/.ssh/registry_deploy`).
- [ ] 5.2 **USER:** Copy the public key to OVH under the
`deploy-registry` user's `~/.ssh/authorized_keys` (after creating
the user per the runbook).
- [ ] 5.3 **USER:** Add the private key as the
`OVH_SSH_PRIVATE_KEY` secret in the GitHub repo settings.
- [ ] 5.4 **USER:** Add `OVH_SSH_HOST` and `OVH_SSH_USER` as
GitHub Actions secrets (host = the OVH hostname, user =
`deploy-registry`).
- [ ] 5.5 **USER:** Confirm `ui.suaveplan.com` A record points
to the OVH IP (already done per change context, but re-verify
with `dig +short ui.suaveplan.com` before first deploy).
## 6. Verification (Web App & Page Verification gate)
Per CLAUDE.md "Web App & Page Verification" — required even though
this is mostly static content.
- [ ] 6.1 Deploy from a feature branch first (NOT `genesis`)
against a staging path or a temporary subdomain to validate the
pipeline before pointing it at production.
- [ ] 6.2 Run a Playwright sweep: navigate to `/`, `/r/index.json`,
`/storybook/`, plus a sampled subset of per-component manifests.
Assert HTTPS, valid cert, expected DOM content, zero
`console.error`, zero failed subresource requests.
- [ ] 6.3 End-to-end shadcn-add test: spin up a clean Vite + React
project in a temp dir, point its `components.json` at the
public registry, run `npx shadcn@latest add @suaveplan/button`,
assert `src/components/ui/button.tsx` lands with the expected
exports. This is the contract test.
- [ ] 6.4 Capture browser console + network during the Playwright
sweep; any unexpected warning or 4xx/5xx fails the gate.
- [ ] 6.5 Run the rollback script once on the OVH host and verify
the previous registry contents serve. Roll forward again before
closing the task.
- [ ] 6.6 24 h after first production deploy, query the Caddy
access log and confirm 5xx count is zero.
## 7. Documentation + change archive
- [ ] 7.1 Refresh root docs per AGENTS §13b: `MANIFEST.md`,
`MISSING.yml`, `STATUS.md`, `SUMMARY.md`. The `STATUS.md` row
for ops infrastructure should reflect that the registry deploy
exists.
- [ ] 7.2 Run `bunx markdownlint-cli2 --fix` on every touched
Markdown file; resolve any unfixable violations by hand.
- [ ] 7.3 Fold `specs/registry-deployment/spec.md` into the
canonical `openspec/specs/registry-deployment/spec.md` (a new
capability spec — first ops-tier capability on genesis).
- [ ] 7.4 Move `openspec/changes/react-ui-registry-deployment/`
to `openspec/archive/<yyyy-mm-dd>-react-ui-registry-deployment/`.