Files
spa/.planning/phase-1-foundation/09-gitea-ci-and-dockerfile.md
T
julian 26e059fc20 feat: planning structure + task 1.2 stack rounding-out
Add .planning/ scaffolding:
- ROADMAP.md (4 phases, 8 non-negotiable design rules)
- phase-1-foundation/ README + 9 task files (1.2-1.10)
- phase-2-live-map / phase-3-dogfood-readiness / phase-4-future README placeholders

Task 1.2 — stack rounding-out:
- Tailwind 4 via @tailwindcss/vite + src/styles/globals.css
- shadcn/ui (slate, new-york) primitives in src/ui/primitives/:
  button, input, label, form, card, alert
- TanStack Router 1.169 + Query 5.100 (devtools + plugin in devDeps)
- Zustand 5, @directus/sdk 21, zod 4, react-hook-form 7 + resolvers
- Prettier 3 + eslint-config-prettier + eslint-plugin-prettier
- ESLint override disabling react-refresh/only-export-components for
  src/ui/primitives/** (intentional dual-exports in shadcn primitives)
- Path alias @/* -> ./src/* in tsconfig.json + tsconfig.app.json
  (TS 6 deprecates baseUrl; paths now resolve relative to config file).
  Pulled forward from 1.3 because shadcn add CLI needs it resolvable.
- Scripts: dev, build, preview, lint, typecheck, format, format:check,
  test (placeholder)
- App.tsx Tailwind smoke test (centred card + shadcn Button)
- README.md rewritten with stack/scripts/shadcn-add docs

All four gates green: typecheck, lint, format:check, build (222KB / 70KB gz).
2026-05-02 18:41:54 +02:00

193 lines
7.7 KiB
Markdown

# Task 1.9 — Gitea CI + Dockerfile + nginx static serve
**Phase:** 1 — Foundation
**Status:** ⬜ Not started
**Depends on:** 1.2 (need build to be working)
**Wiki refs:** `docs/wiki/entities/react-spa.md`; `trm/processor/.gitea/workflows/build.yml` and `trm/directus/.gitea/workflows/build.yml` for pattern alignment
## Goal
Build a Docker image of the SPA (static bundle served by nginx), publish to the Gitea container registry on every push to `main`, matching the CI conventions established by `trm/processor` and `trm/directus`. Include lint + typecheck + format-check + build as the gate before image publish.
After this task, pushing to `main` produces a `git.dev.microservices.al/trm/spa:main` image that `trm/deploy` can pull.
## Deliverables
- `Dockerfile`:
- Multi-stage: `builder` (node:22-alpine, install deps with pnpm, run build) → `runner` (nginx:alpine, copy `dist/` to `/usr/share/nginx/html`).
- BuildKit cache mounts for `pnpm fetch` + `pnpm install --offline` (matching the processor's pattern).
- `nginx.conf` baked in (next bullet).
- Listen on `:80`; the deploy proxy fronts it.
- `nginx.conf`:
- Single `server` block listening on `:80`.
- Static-serve `/usr/share/nginx/html` with `try_files $uri $uri/ /index.html;` (SPA routing fallback so `/login` etc. all serve `index.html`).
- `location = /config.json` block: serve from a separate path that can be volume-mounted in stage/prod (the override path described in 1.4). Default value is the baked-in dev defaults.
- `gzip` on for `text/css`, `application/javascript`, `application/json`. (Brotli later if it becomes a concern.)
- Cache headers: `index.html` no-cache; everything else (the hashed JS/CSS/sprite assets) `Cache-Control: public, max-age=31536000, immutable`.
- `.dockerignore`:
- Includes `node_modules`, `dist`, `.git`, `.gitea`, `.planning`, `*.md` other than necessary, etc. Match the existing pattern from processor/directus.
- `.gitea/workflows/build.yml`:
- Triggers: push to `main`, push to `phase-2-*` branches.
- Path filter: `src/**`, `public/**`, `*.json`, `*.ts`, `*.tsx`, `*.js`, `*.cjs`, `*.html`, `Dockerfile`, `nginx.conf`, `.gitea/workflows/build.yml`. Skip on docs-only changes.
- Single job; mirrors `trm/processor/.gitea/workflows/build.yml` structure.
- Steps:
1. Checkout.
2. Setup pnpm (use the same caching strategy as the processor).
3. `pnpm install --frozen-lockfile`.
4. `pnpm typecheck`.
5. `pnpm lint`.
6. `pnpm format:check`.
7. `pnpm build`.
8. Build Docker image (use the BuildKit cache between runs).
9. Login to `git.dev.microservices.al` registry.
10. Push image with `:main` and per-commit-SHA tags.
- `package.json` `scripts.test` placeholder (Phase 3 will replace; for now, `"test": "echo \"no tests yet\" && exit 0"` so CI can include the step without failing).
- `README.md` updated: "Building locally" + "CI" sections.
## Specification
### Dockerfile shape
```dockerfile
# syntax=docker/dockerfile:1.6
# ───────── builder ─────────
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /build
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm fetch
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --offline
RUN pnpm build # produces dist/
# ───────── runner ─────────
FROM nginx:1.27-alpine AS runner
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
EXPOSE 80
# nginx default CMD is correct; don't override.
```
Mirror the multi-stage + cache-mount pattern from `trm/processor/Dockerfile` precisely. Differences are SPA-specific (no runtime Node, just nginx).
### nginx.conf shape
```nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA routing fallback
location / {
try_files $uri $uri/ /index.html;
}
# Hashed assets — long cache
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# index.html — never cache
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires off;
}
# Runtime config — overridable via volume mount
location = /config.json {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
# Compression
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
```
The `location = /config.json` placement allows mounting a different file at `/usr/share/nginx/html/config.json` in stage/prod via Docker volume — that's the override mechanism for runtime config.
### Workflow path filter
Mirror processor's filter structure. Critical paths to include:
```yaml
on:
push:
branches:
- main
- 'phase-*-**'
paths:
- 'src/**'
- 'public/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'index.html'
- 'tsconfig*.json'
- 'vite.config.ts'
- 'tailwind.config.ts'
- 'eslint.config.js'
- '.prettierrc'
- 'Dockerfile'
- 'nginx.conf'
- '.dockerignore'
- '.gitea/workflows/build.yml'
```
Docs-only changes (`.planning/`, `README.md` alone) skip CI.
### Image tagging
Two tags per push to `main`:
1. `:main` — moves with each `main` commit. Used by stage's `compose.yaml` for "always latest."
2. `:<short-sha>` — pinned reference. Production pins to a specific SHA to avoid surprise rollouts.
This matches the processor's tagging scheme. `trm/deploy/compose.yaml` already documents the `*_TAG` env var pattern; Phase 1.10 adds `SPA_TAG`.
### Why nginx not a Node static server
A 5MB static bundle doesn't need a Node runtime. nginx is:
- Faster (compiled C, not JS).
- Smaller image (~20MB vs ~150MB for node:22-alpine + serve).
- Battle-tested for static-serving + SPA-routing.
Match the processor's "right tool for the job" discipline.
### CI step ordering
`typecheck` before `lint` before `format:check` before `build` is intentional — the cheapest checks first, so a broken typecheck fails the build before lint/format burn cycles. `build` is last because it's the most expensive and is what actually goes into the image.
## Acceptance criteria
- [ ] `docker build -t trm-spa-test .` succeeds locally.
- [ ] `docker run -p 8080:80 trm-spa-test` serves the SPA at `http://localhost:8080`; refreshing on `/login` (after 1.7) serves `index.html` (SPA fallback works).
- [ ] `pnpm format:check` script exists and is green.
- [ ] On push to `main` in Gitea, the workflow runs all gates and pushes the image to the registry.
- [ ] The image is < 50MB total.
- [ ] On push that _only_ touches `.planning/` or `README.md`, CI is skipped.
- [ ] Per-commit SHA tag is present in the registry alongside `:main`.
## Risks / open questions
- **Pinning pnpm version.** Use `corepack prepare pnpm@<version> --activate` with the version pinned to whatever `package.json` `packageManager` says, if set. Otherwise pin to the minor (`pnpm@9` or whatever the team is on) to avoid surprises.
- **Build-time env vars.** None should bleed in. Verify by `docker run trm-spa-test cat /usr/share/nginx/html/index.html` and grep for any hardcoded localhost URL — if found, that's a runtime-config violation to chase.
- **Caching the pnpm store across CI runs.** Gitea Actions runners differ; the `--mount=type=cache,id=pnpm-store` only helps during a single build. For cross-run caching, use Gitea Actions' `actions/cache@v4` step, keyed on `pnpm-lock.yaml` hash. Match what processor does — it likely already has this pattern.
## Done
(Filled in when the task lands.)