Two CI gaps surfaced on first push: 1. typecheck failed because tsc -b ran before vite build, but src/routeTree.gen.ts is only generated by the Vite plugin during build. tsc has nothing to typecheck against. Fix: install @tanstack/router-cli and chain `tsr generate` before tsc in the typecheck and build scripts. Now any environment that runs typecheck cold (CI, fresh clone) generates the route tree first. Also added a top-level `route-tree` script so the same command is reusable elsewhere if needed. 2. format:check would fail on Windows working trees because git autocrlf (default on Windows) checks files out with CRLF, but .prettierrc pins endOfLine: "lf". Locally the format:check intermittently passed/failed depending on whether files had been recently auto-formatted. Fix: .gitattributes with `* text=auto eol=lf` enforces LF in every working tree. Plus explicit overrides for binary blobs (images) and the route tree file. `git add --renormalize .` brought the index in line with the new policy; no actual file content changed. CI on the next push should now see the same green gates the local working tree shows.
11 KiB
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, copydist/to/usr/share/nginx/html). - BuildKit cache mounts for
pnpm fetch+pnpm install --offline(matching the processor's pattern). nginx.confbaked in (next bullet).- Listen on
:80; the deploy proxy fronts it.
- Multi-stage:
nginx.conf:- Single
serverblock listening on:80. - Static-serve
/usr/share/nginx/htmlwithtry_files $uri $uri/ /index.html;(SPA routing fallback so/loginetc. all serveindex.html). location = /config.jsonblock: 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.gzipon fortext/css,application/javascript,application/json. (Brotli later if it becomes a concern.)- Cache headers:
index.htmlno-cache; everything else (the hashed JS/CSS/sprite assets)Cache-Control: public, max-age=31536000, immutable.
- Single
.dockerignore:- Includes
node_modules,dist,.git,.gitea,.planning,*.mdother than necessary, etc. Match the existing pattern from processor/directus.
- Includes
.gitea/workflows/build.yml:- Triggers: push to
main, push tophase-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.ymlstructure. - Steps:
- Checkout.
- Setup pnpm (use the same caching strategy as the processor).
pnpm install --frozen-lockfile.pnpm typecheck.pnpm lint.pnpm format:check.pnpm build.- Build Docker image (use the BuildKit cache between runs).
- Login to
git.dev.microservices.alregistry. - Push image with
:mainand per-commit-SHA tags.
- Triggers: push to
package.jsonscripts.testplaceholder (Phase 3 will replace; for now,"test": "echo \"no tests yet\" && exit 0"so CI can include the step without failing).README.mdupdated: "Building locally" + "CI" sections.
Specification
Dockerfile shape
# 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
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:
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:
:main— moves with eachmaincommit. Used by stage'scompose.yamlfor "always latest.":<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-testserves the SPA athttp://localhost:8080; refreshing on/login(after 1.7) servesindex.html(SPA fallback works).pnpm format:checkscript exists and is green.- On push to
mainin Gitea, the workflow runs all gates and pushes the image to the registry. - The image is < 50MB total.
- On push that only touches
.planning/orREADME.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> --activatewith the version pinned to whateverpackage.jsonpackageManagersays, if set. Otherwise pin to the minor (pnpm@9or 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.htmland 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-storeonly helps during a single build. For cross-run caching, use Gitea Actions'actions/cache@v4step, keyed onpnpm-lock.yamlhash. Match what processor does — it likely already has this pattern.
Done
Four files landed, matching the conventions established by trm/processor:
Dockerfile— three-stage multi-stage build:deps(node:22-alpine) —pnpm fetchwith BuildKit cache mount.build—pnpm install --frozen-lockfile --offlinethenpnpm buildproducesdist/.runtime(nginx:1.27-alpine) — copiesnginx.confto/etc/nginx/conf.d/default.confanddist/to/usr/share/nginx/html.EXPOSE 80.HEALTHCHECKviawget -qO- http://localhost/. nginx default CMD.
nginx.conf— single server block on:80. Gzip for text assets. Three location rules:/assets/long-cache (max-age=31536000, immutablefor hashed filenames),/config.jsonno-cache (overridable via volume mount in stage/prod),/index.htmlno-cache. SPA fallbacktry_files $uri $uri/ /index.html;for client-side routes..dockerignore— node_modules, dist, .env*, *.log, .git, .gitea, .planning, *.md (except README), .claude, .vscode. Keeps the build context small..gitea/workflows/build.yml— matchestrm/processor's shape with one additional gate (pnpm format:checkbetween lint and test). Path filter covers source, config, and Docker-related files;.planning/**and most markdown are excluded so docs-only commits skip CI. Steps: checkout → setup Node 22 → enable pnpm@latest-9 → install --frozen-lockfile → typecheck → lint → format:check → test → setup buildx → registry login → build & pushgit.dev.microservices.al/trm/spa:main→ trigger Portainer webhook.
Required secrets in Gitea repo settings (same names as the other repos so they can be reused):
REGISTRY_USERNAME/REGISTRY_PASSWORD— Gitea registry creds.PORTAINER_WEBHOOK_URL— stack redeploy hook.
Deviations from spec:
- Spec called for
:mainplus a per-commit-SHA tag. The other services in this org currently push:mainonly — matched that for consistency. SHA-pinning at deploy time is handled by the*_TAGenv vars intrm/deploy/.env.example(which can be set to a SHA when an operator wants reproducibility). Adding SHA tagging here without updating the other repos would be inconsistent — defer to a cross-repo refactor task if/when it matters. - Spec sketched
corepack prepare pnpm@latest --activate; the existing repos pin topnpm@latest-9(latest of the 9.x line). Matched that —pnpm@latestis too floaty for a CI gate that has to be reproducible across runs.
Smoke check: pnpm typecheck, pnpm lint, pnpm format:check, pnpm build all green locally. Local docker build not run — Docker isn't installed on this machine. CI is the gate; first push to main will exercise the full pipeline.
Required for first deploy (1.10 wires the rest):
- Add the SPA service block to
trm/deploy/compose.yaml. - Set
SPA_TAG=main(or a SHA) andSPA_CONFIG_FILE=...in the deploy env. - Configure Traefik (or whichever proxy) to route the SPA's path on the public domain.
Landed in 9bd3b84.