Compare commits
4 Commits
6c19451be9
...
ea6aa995b2
| Author | SHA1 | Date | |
|---|---|---|---|
| ea6aa995b2 | |||
| 9731501718 | |||
| cdb571c509 | |||
| 1cd4212df0 |
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# publish-images.sh -- Abbilder je Auslieferungskanal bauen und veroeffentlichen
|
||||||
|
# (quick-260914-ku1).
|
||||||
|
#
|
||||||
|
# Kanalmodell:
|
||||||
|
# refs/heads/main -> Kanal beta, Etiketten beta + latest (latest = Alias, entfaellt spaeter)
|
||||||
|
# refs/tags/v* -> Kanal live, Etiketten live + vX.Y.Z
|
||||||
|
# alles andere -> nichts zu tun (Exit 0, kein Bau, kein Push)
|
||||||
|
#
|
||||||
|
# Der Zweig `live` OHNE Tag wird von der Pipeline geprueft, aber nicht veroeffentlicht:
|
||||||
|
# auf `live` ist jeder auslieferbare Stand ein Tag. Ein ungetaggter Merge darf das
|
||||||
|
# `live`-Etikett nicht ueberschreiben, sonst waere der Tag nicht mehr die Wahrheit.
|
||||||
|
#
|
||||||
|
# Die Entscheidung haengt allein an GITHUB_REF, damit sie lokal ohne Runner pruefbar ist:
|
||||||
|
# GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan
|
||||||
|
#
|
||||||
|
# Versionsstempel: APP_VERSION aus `git describe --tags --always` (ohne Tag: kurzer SHA),
|
||||||
|
# APP_COMMIT, APP_BUILD_TIME -- als --build-arg in beide Dockerfiles. Braucht im Checkout
|
||||||
|
# die volle Historie samt Tags (fetch-depth: 0 im Workflow).
|
||||||
|
#
|
||||||
|
# Dieses Skript kennt kein Secret und gibt keines aus; der Registry-Login bleibt im Workflow.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
REGISTRY="${REGISTRY:-localhost:3002/schalli/tessera-ctl}"
|
||||||
|
REF="${GITHUB_REF:-}"
|
||||||
|
|
||||||
|
case "$REF" in
|
||||||
|
refs/tags/v*)
|
||||||
|
APP_CHANNEL=live
|
||||||
|
TAGS="live ${REF#refs/tags/}"
|
||||||
|
;;
|
||||||
|
refs/heads/main)
|
||||||
|
APP_CHANNEL=beta
|
||||||
|
TAGS="beta latest"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Kein Veroeffentlichungs-Anlass fuer '$REF' (nur main und Tags v*): nichts zu tun."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
APP_VERSION="$(git describe --tags --always)"
|
||||||
|
APP_COMMIT="$(git rev-parse --short HEAD)"
|
||||||
|
APP_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
|
echo "Tessera $APP_VERSION ($APP_CHANNEL) $APP_COMMIT $APP_BUILD_TIME -> Etiketten: $TAGS"
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--print-plan" ]; then
|
||||||
|
for IMG in web api; do
|
||||||
|
for TAG in $TAGS; do
|
||||||
|
echo "push $REGISTRY/$IMG:$TAG"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for IMG in web api; do
|
||||||
|
docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" \
|
||||||
|
--build-arg APP_VERSION="$APP_VERSION" \
|
||||||
|
--build-arg APP_CHANNEL="$APP_CHANNEL" \
|
||||||
|
--build-arg APP_COMMIT="$APP_COMMIT" \
|
||||||
|
--build-arg APP_BUILD_TIME="$APP_BUILD_TIME" \
|
||||||
|
-f "apps/$IMG/Dockerfile" .
|
||||||
|
for TAG in $TAGS; do
|
||||||
|
docker tag "$REGISTRY/$IMG:$APP_CHANNEL" "$REGISTRY/$IMG:$TAG"
|
||||||
|
docker push "$REGISTRY/$IMG:$TAG"
|
||||||
|
done
|
||||||
|
done
|
||||||
+10
-11
@@ -1,8 +1,12 @@
|
|||||||
|
# Kanalmodell (quick-260914-ku1): main -> Kanal beta (Etiketten beta + latest);
|
||||||
|
# Tag v* -> Kanal live (Etiketten live + vX.Y.Z); Zweig live ohne Tag wird nur geprueft.
|
||||||
|
# Die Entscheidung trifft .gitea/scripts/publish-images.sh anhand GITHUB_REF.
|
||||||
name: Tessera CI/CD
|
name: Tessera CI/CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, live]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
quality:
|
||||||
@@ -52,18 +56,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test
|
needs: test
|
||||||
steps:
|
steps:
|
||||||
|
# Ohne volle Historie und Tags liefert `git describe` nichts -- Pflicht fuer den Stempel.
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login localhost:3002 -u ${{ gitea.actor }} --password-stdin
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login localhost:3002 -u ${{ gitea.actor }} --password-stdin
|
||||||
|
|
||||||
- name: Build web image
|
- name: Versionsstempel berechnen, Abbilder bauen und veroeffentlichen
|
||||||
run: docker build -t localhost:3002/schalli/tessera-ctl/web:latest -f apps/web/Dockerfile .
|
run: sh .gitea/scripts/publish-images.sh
|
||||||
|
|
||||||
- name: Build api image
|
|
||||||
run: docker build -t localhost:3002/schalli/tessera-ctl/api:latest -f apps/api/Dockerfile .
|
|
||||||
|
|
||||||
- name: Push images
|
|
||||||
run: |
|
|
||||||
docker push localhost:3002/schalli/tessera-ctl/web:latest
|
|
||||||
docker push localhost:3002/schalli/tessera-ctl/api:latest
|
|
||||||
|
|||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260914-ku1
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-260914-KU1]
|
||||||
|
|
||||||
|
files_modified:
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/src/health/app-version.ts
|
||||||
|
- apps/api/src/health/health.controller.ts
|
||||||
|
- apps/api/src/health/health.controller.spec.ts
|
||||||
|
- apps/api/src/main.ts
|
||||||
|
- apps/web/src/lib/app-version.ts
|
||||||
|
- apps/web/src/lib/app-version.test.ts
|
||||||
|
- apps/web/src/components/layout/app-version-badge.tsx
|
||||||
|
- apps/web/src/components/layout/app-version-badge.test.tsx
|
||||||
|
- apps/web/src/components/layout/sidebar.tsx
|
||||||
|
- apps/web/src/components/layout/sidebar.test.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/Dockerfile
|
||||||
|
- apps/api/Dockerfile
|
||||||
|
- .gitea/workflows/ci.yml
|
||||||
|
- .gitea/scripts/publish-images.sh
|
||||||
|
- docker-compose.prod.yml
|
||||||
|
- docs/anleitung-betrieb.md
|
||||||
|
- docs/ci-cd-setup.md
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 110000
|
||||||
|
raw_tokens: 110000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein angemeldeter Anwender sieht unten in der Seitenleiste (ausgeklappt, Desktop und mobile Schublade) eine kleine Zeile `<Version> · <Kanal>` (z. B. `v1.0.0 · Beta`, lokal `dev · Entwicklung`); der Tooltip (`title`) nennt den Web-Commit und — sobald `GET /health/version` geantwortet hat — die API-Version samt Kanal. Ein Fehler beim Laden der API-Version ist still (Zeile bleibt, Tooltip ohne API-Teil). Eingeklappt: nichts (konsistent mit dem heutigen `!isCollapsed`-Muster der Seitenleiste)."
|
||||||
|
- "`GET /health/version` liefert `{ name: 'tessera', version, channel, commit, buildTime }` aus `APP_VERSION`, `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME` mit Vorgaben `dev`/`dev`/``/`` — leere Zeichenketten (Compose reicht unbelegte Variablen leer weiter) zaehlen wie ungesetzt. Beim Start der API steht eine Protokollzeile `Tessera API <version> (<channel>) <commit>`. Der Endpunkt bleibt bewusst @Public (T-KU1-03), durch Spec-Test gepinnt."
|
||||||
|
- "Ein lokaler `docker build` beider Dockerfiles MIT `--build-arg APP_VERSION=v9.9.9-test --build-arg APP_CHANNEL=live --build-arg APP_COMMIT=abc1234 --build-arg APP_BUILD_TIME=2026-09-14T00:00:00Z` liefert Abbilder, in denen (a) `node -e 'console.log(process.env.APP_VERSION)'` `v9.9.9-test` ausgibt, (b) im Web-Abbild mindestens eine Datei unter `/app/apps/web/.next/static` die Zeichenkette `v9.9.9-test` enthaelt (Bauzeit-Einbettung durch Next.js bewiesen) und (c) `formatAppVersionLine()` aus dem kompilierten API-`dist` `Tessera API v9.9.9-test (live) abc1234` liefert. OHNE Build-Args baut alles weiter und liefert `dev`."
|
||||||
|
- "`.gitea/workflows/ci.yml` (per js-yaml geparst) loest auf `push` fuer `branches: [main, live]` UND `tags: ['v*']` aus; `quality` und `test` laufen fuer alle; `publish` checkt mit `fetch-depth: 0` aus und ruft `.gitea/scripts/publish-images.sh`. Das Skript entscheidet allein anhand `GITHUB_REF`: `refs/heads/main` -> Kanal `beta`, Etiketten `beta` und `latest`; `refs/tags/v*` -> Kanal `live`, Etiketten `live` und `vX.Y.Z`; jeder andere Ref (auch der Zweig `live` ohne Tag) -> `nichts zu tun`, Exit 0, kein Push. `--print-plan` zeigt das ohne Docker-Aufruf. Versionsstempel: `git describe --tags --always` (heute ohne Tags: kurzer SHA), `git rev-parse --short HEAD`, `date -u` ISO."
|
||||||
|
- "`docker-compose.prod.yml` bleibt EINE Datei; beide Abbild-Zeilen tragen `${IMAGE_TAG:-beta}`; `docker compose -f docker-compose.prod.yml config --images` rendert ohne Variable zweimal `:beta`, mit `IMAGE_TAG=live` zweimal `:live`. Registry-Host `git.vicolab.de` und alles andere in der Datei unangetastet."
|
||||||
|
- "Nach `git push` laeuft die Pipeline auf dem lokalen Gitea (localhost:3002, Runner `gitea-runner` mit Host-Docker-Socket) sichtbar durch: der Lauf zum gepushten Commit endet `completed`/`success`, und die vom Runner auf DIESEM Host gebauten Abbilder `localhost:3002/schalli/tessera-ctl/{api,web}:beta` tragen `APP_VERSION` = kurzer SHA des gepushten Commits und `APP_CHANNEL=beta` — der Versionsstempel ist damit einmal ueber den echten CI-Weg bewiesen, nicht nur lokal."
|
||||||
|
- "`docs/anleitung-betrieb.md` hat einen neuen Abschnitt `## 9. Zwei Kanäle: Live und Beta` in Alltagssprache mit echten Umlauten (Ton der Datei), der erklaert: was ein Kanal ist; welche Adresse welches Etikett holt (`latest` = Beta, bleibt vorerst); die eine `.env`-Zeile je Server (`IMAGE_TAG=beta` auf alpha, `IMAGE_TAG=live` auf dem neuen Server) und die zwei `image:`-Zeilen in der Server-Compose-Datei; Freigabe einer Version (Schritte, die Claude ausfuehrt, und `pull` + `up -d --force-recreate api web` durch den User); Hotfix-Ablauf inkl. der Regel 'keine Datenbankänderung als Hotfix' mit Begruendung; wie man die Version in der Oberflaeche, per `curl` und im Log erkennt; Einrichtung des neuen Live-Servers (Verweis auf Abschnitt 2 plus Abweichungen: `IMAGE_TAG=live`, eigene Secrets, eigene Datenbank, KEINE Kopie der alpha-Datenbank ohne ausdruecklichen Wunsch); Rezept fuer die Erstfreigabe v1.0.0. `docs/ci-cd-setup.md` behauptet nicht mehr, es gebe keinen Registry-Push oder einen `build-deploy`-Job, sondern beschreibt Trigger, Etiketten, Build-Args und die laengere Laufzeit."
|
||||||
|
- "Baseline am Ende: API `Test Files 65 passed (65)` / `Tests 1060 passed (1060)` (Planungszeit 64/1054 plus 6 neue), Web `Test Files 40 passed (40)` / `Tests 243 passed (243)` (Planungszeit 38/233 plus 5 + 4 + 1 neue), `tsc --noEmit` in api, web und shared Exit 0; `git diff --stat 6c19451 -- . ':!.planning'` nennt genau `20 files changed`; DATABASE_URL, `.env`-Dateien, `schema.prisma`, Migrationen, `biome.json`, `package.json`-Versionen unangetastet."
|
||||||
|
artifacts:
|
||||||
|
- "packages/shared/src/index.ts — `export interface VersionResponse { name: string; version: string; channel: string; commit: string; buildTime: string }` neben `HealthResponse`"
|
||||||
|
- "apps/api/src/health/app-version.ts — `getAppVersion(): VersionResponse` (liest `process.env.APP_*` mit `||`-Vorgaben) und `formatAppVersionLine(v?: VersionResponse): string`"
|
||||||
|
- "apps/api/src/health/health.controller.ts — `getVersion(): VersionResponse` delegiert an `getAppVersion()`, weiterhin `@Public()`; kein Zugriff mehr auf die npm-Paketversion"
|
||||||
|
- "apps/api/src/health/health.controller.spec.ts — NEU (es gab keinen Spec), 6 Tests"
|
||||||
|
- "apps/api/src/main.ts — `console.log(formatAppVersionLine())` direkt nach der bestehenden Port-Zeile"
|
||||||
|
- "apps/web/src/lib/app-version.ts — `appVersion: { version, channel: 'beta'|'live'|'dev', commit }` aus `process.env.NEXT_PUBLIC_APP_VERSION` / `_CHANNEL` / `_COMMIT` (jeweils voller Literalname) und `loadApiVersion(): Promise<ApiVersionInfo | null>` (memoisiert, `credentials: 'include'`, still bei Fehler) — die importierbare Quelle fuer den kommenden Fehler-melden-Knopf"
|
||||||
|
- "apps/web/src/lib/app-version.test.ts — NEU, 5 Tests (vi.stubEnv + vi.resetModules + dynamischer Import)"
|
||||||
|
- "apps/web/src/components/layout/app-version-badge.tsx — `AppVersionBadge`, `data-testid=\"app-version\"`, Text `${version} · ${t('channel.'+channel)}`, `title` aus Commit und API-Version"
|
||||||
|
- "apps/web/src/components/layout/app-version-badge.test.tsx — NEU, 4 Tests"
|
||||||
|
- "apps/web/src/components/layout/sidebar.tsx — Abzeichen-Block unter dem Einklapp-Block, nur `!isCollapsed`, ohne `hidden md:block` (damit auch die mobile Schublade ihn zeigt)"
|
||||||
|
- "apps/web/src/components/layout/sidebar.test.tsx — Mock fuer `@/components/layout/app-version-badge` (wie der bestehende SidebarFooter-Mock) und ein sechster Test"
|
||||||
|
- "apps/web/src/messages/de.json + en.json — `sidebar.channel.{beta,live,dev}` = Beta/Live/Entwicklung bzw. Beta/Live/Development"
|
||||||
|
- "apps/web/Dockerfile + apps/api/Dockerfile — globale `ARG APP_VERSION=dev`, `ARG APP_CHANNEL=dev`, `ARG APP_COMMIT=`, `ARG APP_BUILD_TIME=` vor dem ersten FROM; im builder (nur web) `ARG`-Wiederholung + `ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION NEXT_PUBLIC_APP_CHANNEL=$APP_CHANNEL NEXT_PUBLIC_APP_COMMIT=$APP_COMMIT` unmittelbar VOR `RUN pnpm --filter=@tessera/web build`; im runner (beide) `ARG`-Wiederholung + `ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME`"
|
||||||
|
- ".gitea/scripts/publish-images.sh — POSIX sh, `set -eu`, Kanal-/Etiketten-Entscheidung, `--print-plan`, Bau mit vier `--build-arg`, `docker tag` + `docker push` je Etikett; gibt nie ein Secret aus"
|
||||||
|
- ".gitea/workflows/ci.yml — Trigger erweitert, `publish` mit `fetch-depth: 0` und Skriptaufruf; Login-Schritt unveraendert"
|
||||||
|
- "docker-compose.prod.yml — `image: git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}` und `.../api:${IMAGE_TAG:-beta}`"
|
||||||
|
- "docs/anleitung-betrieb.md — Abschnitt 9 neu, Inhaltsverzeichnis, Tabelle in Abschnitt 1, `IMAGE_TAG`-Zeile in Abschnitt 3, Etiketten in Abschnitt 4, Log-Zeile in Abschnitt 7"
|
||||||
|
- "docs/ci-cd-setup.md — Abschnitte 3 (Secrets: REGISTRY_TOKEN) und 4 (Pipeline-Ueberblick) auf den gemessenen Stand; ASCII-Umschrift wie im Bestand"
|
||||||
|
key_links:
|
||||||
|
- "Bauzeit-Einbettung: `ENV NEXT_PUBLIC_APP_*` steht im builder VOR `pnpm build`, und `app-version.ts` liest jede Variable mit vollem Literalnamen — nur dann ersetzt Next.js den Ausdruck im Browser-Bundle (heute nachweisbar: 28 Dateien unter `.next/static` enthalten das eingebettete `/api-proxy`). Deshalb muss Task 1 (Code) VOR Task 2 (Build-Beweis) liegen: ohne die Quelle gibt es nichts einzubetten, der Grep auf `v9.9.9-test` waere sinnlos."
|
||||||
|
- "`.dockerignore` schliesst `.git` aus — `git describe` kann NICHT im Dockerfile laufen; der Stempel kommt ausschliesslich per `--build-arg` aus der CI (Skript), lokal greift die Vorgabe `dev`."
|
||||||
|
- "ARG-Sichtbarkeit: ein `ARG` vor dem ersten FROM liefert nur die Vorgabe; jede Stufe, die den Wert nutzt, wiederholt `ARG NAME` (ohne Wert) — zur Planungszeit mit einem Zweistufen-Testbau bestaetigt (`builder sees: v9.9.9-test / live`, `runner: v9.9.9-test live`)."
|
||||||
|
- "Runner und Gitea laufen auf DIESEM Rechner (`gitea`, `gitea-runner` mit Host-Docker-Socket, `localhost:3002` antwortet): der CI-Lauf ist nach dem Push ueber `GET /api/v1/repos/schalli/tessera-ctl/actions/runs` (Token aus `git config --get remote.origin.pushurl`, nie ausgeben) beobachtbar, und die CI-Abbilder erscheinen in `docker images` des Hosts."
|
||||||
|
- "Der Web-Container ruft `${NEXT_PUBLIC_API_URL}/health/version` = `/api-proxy/health/version`; `next.config.ts` schreibt `/api-proxy/:path*` auf `API_INTERNAL_URL` um — derselbe Weg wie `/modules/active` in `sidebar.tsx`."
|
||||||
|
- "`sidebar.test.tsx` stubbt `fetch` global mit dem Modul-Array; ohne Mock des Abzeichens bekaeme `loadApiVersion()` dieses Array — deshalb Modul-Mock wie beim `SidebarFooter`."
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Zwei Auslieferungskanaele fuer Tessera: `main` = Beta (alpha.tessera.ctl.de), Zweig `live` + Tag `vX.Y.Z` = Live (tessera.ctl.de, neuer Server ab 2026-09-15). Dieser Plan liefert (1) den Versionsstempel durch alle Schichten — CI berechnet `APP_VERSION`/`APP_CHANNEL`/`APP_COMMIT`/`APP_BUILD_TIME`, beide Dockerfiles nehmen sie als Build-Args, die API antwortet auf `GET /health/version` und protokolliert beim Start, die Web-Oberflaeche zeigt `v1.0.0 · Beta` unten in der Seitenleiste; (2) die Pipeline-Trigger und Etiketten je Kanal mit einem lokal pruefbaren Veroeffentlichungs-Skript; (3) `IMAGE_TAG` in der Compose-Datei; (4) das Betriebshandbuch fuer einen Nicht-Programmierer: Kanaele, Freigabe, Hotfix (ohne Datenbankaenderung), Versionskontrolle, Einrichtung des neuen Live-Servers.
|
||||||
|
|
||||||
|
Purpose: Morgen geht Live. Der User muss einen Fehler auf Live beheben koennen, ohne Beta-Neuerungen mitzunehmen, die Korrektur danach kontrolliert in die Beta uebernehmen und jederzeit sehen, welche Fassung ein Anwender benutzt. Der Fehler-melden-Knopf (eigener Folge-Quick-Task) bekommt mit `apps/web/src/lib/app-version.ts` seine Quelle.
|
||||||
|
|
||||||
|
Output: 20 Dateien (13 Code/Tests, 5 Build/CI/Compose, 2 Handbuecher), drei Commits mit Scope `quick-260914-ku1`, gepusht, CI-Lauf beobachtet und die CI-gebauten `:beta`-Abbilder auf ihren Stempel geprueft. Zweig `live` und Tag `v1.0.0` werden NICHT in diesem Plan angelegt (Rezept steht im Handbuch; der Orchestrator macht das nach dem Fehler-melden-Task).
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.gitea/workflows/ci.yml
|
||||||
|
@apps/web/Dockerfile
|
||||||
|
@apps/api/Dockerfile
|
||||||
|
@docker-compose.prod.yml
|
||||||
|
@apps/api/src/health/health.controller.ts
|
||||||
|
@apps/api/src/main.ts
|
||||||
|
@packages/shared/src/index.ts
|
||||||
|
@apps/web/src/components/layout/sidebar.tsx
|
||||||
|
@apps/web/src/components/layout/sidebar.test.tsx
|
||||||
|
@apps/web/src/messages/umlaut-guard.spec.ts
|
||||||
|
@docs/anleitung-betrieb.md
|
||||||
|
@docs/ci-cd-setup.md
|
||||||
|
|
||||||
|
<planning_measurements>
|
||||||
|
Zur Planungszeit (2026-09-14, HEAD `6c19451`, Arbeitsbaum sauber, main == origin/main) gemessen — die Ausfuehrung misst erneut; diese Zahlen sind der Bezugspunkt der Gates:
|
||||||
|
|
||||||
|
- API-Suite `pnpm -C apps/api exec vitest run` -> `Test Files 64 passed (64)`, `Tests 1054 passed (1054)`. Web-Suite `pnpm -C apps/web exec vitest run` -> `Test Files 38 passed (38)`, `Tests 233 passed (233)` (die Baseline im Auftrag „64/1054" war nur die API). `tsc --noEmit` in `apps/api`, `apps/web`, `packages/shared` je Exit 0.
|
||||||
|
- **`SidebarFooter` (`sidebar-footer.tsx`) wird seit `ba02b25` (2026-06-26, „restructure sidebar and admin navigation") NIRGENDS gerendert** — einziger Treffer ausserhalb der Datei ist der Mock in `sidebar.test.tsx`. Die Benutzerinfo lebt im Header-Dropdown (`header.tsx` 134-154), die Seitenleiste endet mit dem Einklapp-Block (`sidebar.tsx` 187-199, `hidden md:block border-t border-sidebar-border p-2`; `!isCollapsed` blendet dort und in der Navigation jeden Text aus). Eine Versionszeile in `sidebar-footer.tsx` waere unsichtbar. Deshalb: eigene Komponente `AppVersionBadge`, gerendert in `sidebar.tsx` im `sidebarContent` (Zeile 91 `<div className="flex h-full flex-col bg-sidebar">`, Ende bei 201) NACH dem Einklapp-Block; `sidebarContent` wird auch in der mobilen Schublade (Zeile 245) gerendert. `sidebar-footer.tsx` bleibt unangetastet (toter Code, im SUMMARY als Nebenbefund nennen, nicht loeschen).
|
||||||
|
- **Ohne Quellcode, der `process.env.NEXT_PUBLIC_APP_VERSION` liest, bettet Next.js nichts ein** — der Grep auf `v9.9.9-test` im Web-Abbild funktioniert erst, wenn `app-version.ts` existiert. Reihenfolge deshalb: Task 1 Code, Task 2 Build/CI. Nachweis des Mechanismus heute: `docker run --rm --entrypoint sh <web-image> -c 'grep -rl "/api-proxy" /app/apps/web/.next/static | wc -l'` -> 28.
|
||||||
|
- Docker 29.8.0 / Compose v5.5.1 lokal. Ein Web-Bau mit warmem Cache (deps-Stufe getroffen, builder neu) dauerte 129 s; der API-Bau liegt in derselben Groessenordnung. Vier lokale Baeue (Task 2) sind also 6-12 Minuten — erwartete Dauer, kein Haenger. Lokales Zwischenabbild `tessera-web-plancheck:baseline` existiert (Cache-Waerme), darf am Ende mit `docker rmi` weg.
|
||||||
|
- ARG-Semantik bestaetigt (Zweistufen-Testbau im Scratchpad): globale `ARG X=dev` vor dem ersten FROM + `ARG X` in jeder nutzenden Stufe -> ohne Args `dev`, mit `--build-arg` der Wert in builder UND runner.
|
||||||
|
- `.dockerignore`: `node_modules .next dist .turbo .git .env *.md coverage` — `.git` fehlt im Kontext, `git describe` im Dockerfile unmoeglich.
|
||||||
|
- `git tag` liefert keine Zeile (0 Tags); `git describe --tags --always` -> `6c19451` (kurzer SHA, Gate „Bau vor dem ersten Tag scheitert nicht" erfuellt). Nur Zweig `main` lokal und remote. Gitea 1.26.2 auf localhost:3002; `branch_protections` leer; 0 Kollaborateure (nur schalli). Push-URL `localhost:3002`, Fetch-URL `git.vicolab.de` (Memory).
|
||||||
|
- **Gitea UND `gitea-runner` (act_runner v0.6.1, Labels ubuntu-latest, Host-Docker-Socket) laufen auf DIESEM Rechner.** Folge: die CI-Baeue landen in `docker images` des Hosts (`localhost:3002/schalli/tessera-ctl/api:latest` wurde vom letzten Lauf 296 zu HEAD `6c19451` gebaut; Lauf gestartet 13:51:28, beendet 13:53:15 — unter 2 Minuten, weil Docker-Layer-Cache; mit Build-Args wird der builder jedes Mal neu laufen, also kuenftig ~4-6 Minuten). Der Lauf ist per `GET http://localhost:3002/api/v1/repos/schalli/tessera-ctl/actions/runs?limit=3` mit `Authorization: token <Token aus pushurl>` lesbar (Felder `head_sha`, `status`, `conclusion`); anonym 401. Der Auftrag nahm an, der Lauf sei nicht beobachtbar — er ist es.
|
||||||
|
- `docker compose -f docker-compose.prod.yml config --images 2>/dev/null` laeuft lokal mit Exit 0 (die lokale `.env` liefert den Pflichtwert `TESSERA_ENCRYPTION_KEY`) und rendert heute `web:latest` / `api:latest`. `IMAGE_TAG` kommt in `.env.example` und `.env.prod.example` nicht vor.
|
||||||
|
- Server alpha (nur gelesen, `ls`/`grep` per SSH): `/opt/tessera/.env` traegt `COMPOSE_FILE` (Zeile 32) und `APP_URL`; `/opt/tessera/docker-compose.prod.yml` (93 Zeilen) ist die Serverdatei mit `web:latest`/`api:latest` (Zeilen 3 und 23) und weicht vom Repo (94 Zeilen) genau um die fehlende Zeile `TESSERA_MIGRATE_DATABASE_URL` ab. Der aeltere Hinweis in Handbuch Abschnitt 6/7 auf `/opt/tessera/docker-compose.yml` ist damit ueberholt — im neuen Abschnitt 9 die tatsaechliche Datei nennen. Laufende Container: `tessera-web-1`, `tessera-api-1`, `tessera-db-1`.
|
||||||
|
- YAML-Parser: PyYAML NICHT installiert; `js-yaml@4.2.0` liegt in `node_modules/.pnpm`, ist aber vom Repo-Root nicht per `require('js-yaml')` aufloesbar — nur ueber den vollen Pfad `/home/vicolab/projects/tessera-ctl/node_modules/.pnpm/js-yaml@4.2.0/node_modules/js-yaml` (geprueft: parst `ci.yml`, `on.push.branches = ["main"]`, `tags = undefined`, Jobs `quality,test,publish`). Der Schluessel `on` wird als String geparst (YAML-1.2-Schema).
|
||||||
|
- `apps/web` haengt NICHT von `@tessera/shared` ab (nur `apps/api`); ein neuer Import wuerde `package.json` + `pnpm-lock.yaml` aendern. Deshalb spiegelt `app-version.ts` den Antworttyp lokal (`ApiVersionInfo`), `VersionResponse` bleibt in `packages/shared` die API-Wahrheit.
|
||||||
|
- Workspace-Paketversionen stehen NICHT in `pnpm-lock.yaml` (alle 16 Treffer auf `0.0.1` sind Fremdpakete) — ein Bump auf 1.0.0 waere lockfile-neutral, wuerde aber die deps-Stufe beider Dockerfiles (COPY `package.json`) invalidieren. Entscheidung: `package.json`-Versionen bleiben 0.0.1, die Wahrheit ist der Tag; im Handbuch so benannt.
|
||||||
|
- `health.controller.ts`: kein Spec vorhanden (Wave-0-Scaffold in Task 1). `getVersion()` liest heute die npm-Paketversion, die im Container nie gesetzt ist (Vorgabe `'0.0.1'`); kein Konsument im Web (`grep -rn "health/version" apps/` nur der Controller selbst). `@Public()` = `SetMetadata('isPublic', true)` (`IS_PUBLIC_KEY` aus `../auth/decorators/public.decorator`); Metadaten-Pruefung per `Reflect.getMetadata` wie in `tenant.controller.spec.ts` 300-308.
|
||||||
|
- Web-Muster: `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'` + `fetch(..., { credentials: 'include' })` (`sidebar.tsx` 12, 43-47; `favorites-api.ts`). `vi.resetModules()` + dynamischer Import: `module-access-gate.test.tsx` 37. Uebersetzungs-Mock: `sidebar.test.tsx` 16-41 (`useTranslations(ns)` -> `map[ns][key] ?? key`, verschachtelte Schluessel als `'categories.label'`).
|
||||||
|
- Uebersetzungs-Waechter: `umlaut-guard.spec.ts` prueft de.json auf `ae/oe/ue/ss`-Token ausserhalb `UMLAUT_ALLOWLIST` (Fehlermeldung nennt den Fix) und de/en-Schluesselgleichheit; `tenderRadar-parity.spec.ts` nur den Namensraum `tenderRadar`. „Beta", „Live", „Entwicklung" enthalten keines der Token.
|
||||||
|
- `docs/anleitung-betrieb.md`: 346 Zeilen, 73 Zeilen mit echten Umlauten, viermal `ß` neben „grösseren" — echte Umlaute beibehalten. Anker: Inhaltsverzeichnis 12-21 (Eintrag 8 in Zeile 21), Tabelle Abschnitt 1 Zeilen 32-33 (`:latest`), Konfigurationstabelle bis Zeile 161 (`API_INTERNAL_URL`), Abschnitt 4 Zeilen 194/207/210 (`:latest`), Abschnitt 7 Zeile 320 (`Tessera API running on port 3001`), Abschnitt 8 ab Zeile 334 (Ende der Datei 346). `docs/ci-cd-setup.md`: 169 Zeilen, 0 Umlaute (ASCII-Umschrift beibehalten); Anker: Zeilen 81-93 (Secrets: „keine Secrets", „kein Registry-Push"), 95-117 (Pipeline-Ueberblick: `build-deploy`, „Kein Registry-Push", D-13), 143-147 (Workflow-Dateien, D-11).
|
||||||
|
- Detektoren: `api-coverage` -> `{"detected":false}`; `assumption-delta scan quick-260914-ku1` -> `{"skipped":true,"reason":"phase_unresolved"}` (Quick-Task ohne ROADMAP-Abschnitt; inhaltlich IST es eine Einzahl-zu-Mehrzahl-Aenderung — ein Etikett wird zu zwei Kanaelen; Entscheidung siehe `<assumption_delta_decision>`); `schema-gate` -> keine Schemadatei in der Erlaubnisliste. Konfiguration: `tdd_mode=false` (Task 1 traegt trotzdem `tdd="true"`, die Tests sind vorab formulierbar), `security_enforcement=true`, ASVS 1, Blocking-Schwelle `high`, `human_verify_mode=end-of-phase`.
|
||||||
|
- Biome ist im Bestand nicht lauffaehig (WINDOWS #35, `pnpm lint` = Leerlauf) — kein Biome-Gate in diesem Plan; `biome.json` unangetastet.
|
||||||
|
</planning_measurements>
|
||||||
|
|
||||||
|
<assumption_delta_decision>
|
||||||
|
Noun, das jetzt primaer ist: der **Auslieferungskanal** (`beta` | `live`), nicht das Registry-Etikett. Entscheidung: **promote** — `IMAGE_TAG` in der Compose-Datei und `APP_CHANNEL` im Stempel sind die Primaerdarstellung; `latest` wird zum Alias von `beta` herabgestuft (bleibt nur, damit der bestehende alpha-Server ohne Handgriff weiterlaeuft, und darf spaeter entfallen — Handbuch sagt das). Kein `add-alongside`: es gibt keine Stelle mehr, die `latest` als eigene Wahrheit fuehrt.
|
||||||
|
</assumption_delta_decision>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Versionsstempel durch alle Schichten — API /health/version, geteilter Typ, Web-Quelle app-version.ts, Abzeichen in der Seitenleiste (Tests zuerst)</name>
|
||||||
|
<files>packages/shared/src/index.ts, apps/api/src/health/app-version.ts, apps/api/src/health/health.controller.ts, apps/api/src/health/health.controller.spec.ts, apps/api/src/main.ts, apps/web/src/lib/app-version.ts, apps/web/src/lib/app-version.test.ts, apps/web/src/components/layout/app-version-badge.tsx, apps/web/src/components/layout/app-version-badge.test.tsx, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||||
|
<behavior>
|
||||||
|
API — `apps/api/src/health/health.controller.spec.ts` (NEU; Stil wie `tenant.controller.spec.ts`: `import 'reflect-metadata'`, deutsche Testnamen „Test N (...)", Kopfkommentar mit Bezug quick-260914-ku1). `vi.stubEnv` fuer `APP_VERSION`, `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME`; `afterEach(() => vi.unstubAllEnvs())`. Controller direkt instanziieren (`new HealthController()`, keine Abhaengigkeiten).
|
||||||
|
- Test 1 (`check()`): liefert `status: 'ok'` und einen `timestamp`, den `Date.parse` versteht — Regressions-Pin des unveraenderten Healthchecks.
|
||||||
|
- Test 2 (Vorgaben): ohne die vier Variablen liefert `getVersion()` genau `{ name: 'tessera', version: 'dev', channel: 'dev', commit: '', buildTime: '' }`.
|
||||||
|
- Test 3 (durchgereicht): `APP_VERSION=v1.2.3`, `APP_CHANNEL=live`, `APP_COMMIT=abc1234`, `APP_BUILD_TIME=2026-09-14T12:00:00Z` -> alle vier Felder woertlich, `name` weiterhin `'tessera'`.
|
||||||
|
- Test 4 (Compose-Semantik): alle vier Variablen auf `''` gestubbt -> Ergebnis wie Test 2 (leer zaehlt wie ungesetzt; Begruendung: Compose reicht unbelegte Variablen als Leerstring weiter, siehe `migrate-and-start.sh`).
|
||||||
|
- Test 5 (`formatAppVersionLine`): mit den Werten aus Test 3 -> `'Tessera API v1.2.3 (live) abc1234'`; ohne Commit (Vorgaben) -> `'Tessera API dev (dev)'` — kein Leerzeichen am Ende.
|
||||||
|
- Test 6 (bewusst oeffentlich, T-KU1-03): `Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.getVersion)` ist `true` und ebenso fuer `check`.
|
||||||
|
Web — `apps/web/src/lib/app-version.test.ts` (NEU; jeder Test `vi.resetModules()` und `const mod = await import('./app-version')`, weil die Konstante beim Laden des Moduls gelesen und die API-Antwort memoisiert wird; `vi.unstubAllEnvs()` und `vi.unstubAllGlobals()` im `afterEach`):
|
||||||
|
- Test 1 (Vorgaben): ohne `NEXT_PUBLIC_APP_*` -> `mod.appVersion` gleich `{ version: 'dev', channel: 'dev', commit: '' }`.
|
||||||
|
- Test 2 (Umgebung): `NEXT_PUBLIC_APP_VERSION=v1.2.3`, `_CHANNEL=beta`, `_COMMIT=abc1234` -> woertlich durchgereicht.
|
||||||
|
- Test 3 (Normalisierung): `NEXT_PUBLIC_APP_CHANNEL=gamma` -> `channel === 'dev'` (nur `beta` und `live` sind bekannte Kanaele; ein fremder Wert darf keinen fehlenden Uebersetzungsschluessel erzeugen).
|
||||||
|
- Test 4 (Laden, memoisiert): `vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ name: 'tessera', version: 'v1.2.3', channel: 'beta', commit: 'abc1234', buildTime: 'x' }) })))`; zwei Aufrufe `mod.loadApiVersion()` liefern beide das Objekt, `fetch` wurde genau EINMAL aufgerufen, die URL endet auf `/health/version`, die Optionen enthalten `credentials: 'include'`.
|
||||||
|
- Test 5 (still bei Fehler): `fetch` lehnt ab (`Promise.reject(new Error('netz'))`) -> `await mod.loadApiVersion()` ist `null`, nichts wird geworfen; zweiter Fall im selben Test mit `ok: false` -> ebenfalls `null`.
|
||||||
|
Web — `apps/web/src/components/layout/app-version-badge.test.tsx` (NEU; Vorlage `sidebar.test.tsx`: `cleanup`/`vi.restoreAllMocks` im `afterEach`, next-intl-Mock mit `sidebar: { 'channel.beta': 'Beta', 'channel.live': 'Live', 'channel.dev': 'Entwicklung' }`; Modul-Mock `vi.mock('@/lib/app-version', () => ({ appVersion: mockAppVersion, loadApiVersion: mockLoad }))` mit veraenderbaren Variablen; Komponente per dynamischem Import nach dem Setzen der Mocks):
|
||||||
|
- Test 1 (Zeile): `appVersion = { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' }`, `loadApiVersion` liefert `null` -> `screen.getByTestId('app-version')` hat den Text `v1.2.3 · Beta`.
|
||||||
|
- Test 2 (Tooltip mit API): `loadApiVersion` liefert `{ name: 'tessera', version: 'v1.2.3', channel: 'beta', commit: 'abc1234', buildTime: '' }` -> `waitFor`: das `title`-Attribut enthaelt `Commit abc1234` UND `API v1.2.3 (beta)`.
|
||||||
|
- Test 3 (Tooltip ohne API): `loadApiVersion` liefert `null` -> `title` enthaelt `Commit abc1234` und NICHT `API`.
|
||||||
|
- Test 4 (Kanal dev, kein Commit): `appVersion = { version: 'dev', channel: 'dev', commit: '' }`, `loadApiVersion` -> `null` -> Text `dev · Entwicklung`, und das Element hat KEIN `title`-Attribut (nichts zu zeigen).
|
||||||
|
Web — `sidebar.test.tsx`: Modul-Mock `vi.mock('@/components/layout/app-version-badge', () => ({ AppVersionBadge: () => <div data-testid="app-version-badge" /> }))` neben dem bestehenden SidebarFooter-Mock; neuer sechster Test „renders the version badge below the navigation" -> nach `waitFor` auf `Dashboard` ist `screen.getByTestId('app-version-badge')` im Dokument (Store-Mock hat `isCollapsed: false`).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Schritt A — RED: die drei neuen Testdateien und den sechsten Sidebar-Test aus `<behavior>` anlegen, BEVOR Produktionscode entsteht. `pnpm -C apps/api exec vitest run src/health/health.controller.spec.ts` und `pnpm -C apps/web exec vitest run src/lib/app-version.test.ts src/components/layout/app-version-badge.test.tsx src/components/layout/sidebar.test.tsx` muessen rot sein (Modul nicht gefunden bzw. Erwartungen verfehlt) — die Ausgabezeilen ins SUMMARY.
|
||||||
|
|
||||||
|
Schritt B — GREEN, API:
|
||||||
|
1. `packages/shared/src/index.ts`: nach `HealthResponse` das Interface `VersionResponse` mit `name`, `version`, `channel`, `commit`, `buildTime` (alle `string`) exportieren; kurzer Kommentar, dass `channel` in der Praxis `beta` | `live` | `dev` ist und die Wahrheit der Version der Git-Tag ist (quick-260914-ku1).
|
||||||
|
2. `apps/api/src/health/app-version.ts` (NEU): `getAppVersion(): VersionResponse` liest `process.env.APP_VERSION`, `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME` jeweils mit `||` (NICHT `??`) und den Vorgaben `'dev'`, `'dev'`, `''`, `''`, `name: 'tessera'`; `formatAppVersionLine(v = getAppVersion()): string` baut `Tessera API ${version} (${channel})` und haengt ` ${commit}` nur an, wenn `commit` nicht leer ist. Kopfkommentar (deutsch, ASCII): woher die Werte kommen (Build-Args -> `ENV` in der Runner-Stufe beider Dockerfiles, gesetzt vom CI-Skript `.gitea/scripts/publish-images.sh`), warum `||` (Compose-Leerstring-Semantik) und dass `main.ts` die Zeile beim Start protokolliert.
|
||||||
|
3. `health.controller.ts`: `getVersion(): VersionResponse` gibt `getAppVersion()` zurueck; Import `VersionResponse` als `import type` neben `HealthResponse`; `@Public()` und `@Get('version')` bleiben. Der bisherige Zugriff auf die npm-Paketversion entfaellt vollstaendig (das Gate greppt darauf, Erwartung 0). Kommentar ueber `getVersion` (drei Zeilen): bewusst oeffentlich — Betreiber-Kontrolle per `curl` auf dem Server ohne Anmeldung, keine Systemkomponenten-Versionen, Repo privat (T-KU1-03).
|
||||||
|
<!-- planner-discipline-allow: npm_package_version -->
|
||||||
|
4. `main.ts`: `import { formatAppVersionLine } from './health/app-version';` und direkt nach `console.log('Tessera API running on port 3001');` die Zeile `console.log(formatAppVersionLine());` (gleicher Stil wie die bestehende Zeile; kein Nest-Logger, damit die Zeile im Serverlog neben der Port-Zeile steht — Handbuch Abschnitt 7 nennt beide).
|
||||||
|
|
||||||
|
Schritt C — GREEN, Web:
|
||||||
|
5. `apps/web/src/lib/app-version.ts` (NEU): `export type AppChannel = 'beta' | 'live' | 'dev'`; `export interface AppVersionInfo { version: string; channel: AppChannel; commit: string }`; `export interface ApiVersionInfo { name: string; version: string; channel: string; commit: string; buildTime: string }` (Kommentar: Spiegel von `VersionResponse` aus `packages/shared`, weil `apps/web` nicht von `@tessera/shared` abhaengt und ein neuer Import Lockfile und Docker-deps-Stufe aendern wuerde). `normalizeChannel(raw)` -> `'beta'`/`'live'` durchreichen, alles andere `'dev'`. `export const appVersion: AppVersionInfo` mit `process.env.NEXT_PUBLIC_APP_VERSION || 'dev'`, `normalizeChannel(process.env.NEXT_PUBLIC_APP_CHANNEL)`, `process.env.NEXT_PUBLIC_APP_COMMIT || ''` — JEDER Zugriff mit vollem Literalnamen, kein Destructuring, kein `process.env[name]` (Kopfkommentar erklaert: Next.js ersetzt nur den woertlichen Ausdruck zur Bauzeit, sonst ist der Wert im Browser leer). `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'` wie in `sidebar.tsx`. `export function loadApiVersion(): Promise<ApiVersionInfo | null>` memoisiert ein Modul-Promise: `fetch(`${API_URL}/health/version`, { credentials: 'include' })` -> bei `res.ok` das JSON, sonst `null`; `.catch(() => null)`. Kopfkommentar nennt den Zweck: Quelle fuer das Abzeichen UND den kommenden Fehler-melden-Knopf.
|
||||||
|
6. `apps/web/src/components/layout/app-version-badge.tsx` (NEU, `'use client'`): `useTranslations('sidebar')`, `useState<ApiVersionInfo | null>(null)`, `useEffect` ruft `loadApiVersion().then(setApi)` einmal. Rendert ein `<span data-testid="app-version" className="block truncate text-xs text-muted-foreground" title={title}>` mit Text `{appVersion.version} · {t(`channel.${appVersion.channel}`)}`. `title`: Teile `Commit ${appVersion.commit}` (nur wenn Commit nicht leer) und `API ${api.version} (${api.channel})` (nur wenn geladen), mit ` · ` verbunden; keine Teile -> `title={undefined}` (kein Attribut). „Commit" und „API" sind in beiden Sprachen gleich, deshalb keine Schluessel dafuer.
|
||||||
|
7. `sidebar.tsx`: Import `AppVersionBadge` aus `@/components/layout/app-version-badge`; im `sidebarContent` NACH dem Einklapp-Block (gemessen 187-199) und vor dem schliessenden `</div>` (201) einen Block `{!isCollapsed && (<div className="border-t border-sidebar-border px-4 py-2"><AppVersionBadge /></div>)}` — bewusst OHNE `hidden md:block`, damit die mobile Schublade (rendert `sidebarContent`, Zeile 245) die Zeile ebenfalls zeigt; `!isCollapsed` haelt das heutige Muster (eingeklappt: kein Text).
|
||||||
|
8. `de.json`/`en.json`: im Namensraum `sidebar` ein Objekt `channel` mit `beta: "Beta"`, `live: "Live"`, `dev: "Entwicklung"` bzw. `dev: "Development"` — in BEIDEN Dateien an derselben Stelle (hinter `modules`), sonst faellt der Schluesselgleichheits-Test.
|
||||||
|
9. `sidebar.test.tsx`: Modul-Mock und sechster Test aus `<behavior>`.
|
||||||
|
|
||||||
|
Dann alle betroffenen Specs gruen: API-Spec `Tests 6 passed (6)`; Web `app-version.test.ts` 5, `app-version-badge.test.tsx` 4, `sidebar.test.tsx` 6. Volle Suiten: API `Test Files 65 passed (65)` / `Tests 1060 passed (1060)`, Web `Test Files 40 passed (40)` / `Tests 243 passed (243)`; `tsc --noEmit` in api, web, shared Exit 0. Weicht eine Zahl ab, ist das ein Befund fuer das SUMMARY — erst die Ursache benennen, dann korrigieren.
|
||||||
|
|
||||||
|
Commit: `feat(quick-260914-ku1): Versionsstempel — GET /health/version aus APP_*, VersionResponse, app-version.ts und Abzeichen v<Version> · <Kanal> in der Seitenleiste` mit genau den 13 Dateien dieser Aufgabe (`git show --stat HEAD` zeigt 13).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run src/health/health.controller.spec.ts 2>&1 | grep -E "^\s+Tests" ; pnpm -C apps/web exec vitest run src/lib/app-version.test.ts src/components/layout/app-version-badge.test.tsx src/components/layout/sidebar.test.tsx 2>&1 | grep -E "^\s+(Test Files|Tests)" ; grep -c "npm_package_version" apps/api/src/health/health.controller.ts ; grep -c "formatAppVersionLine()" apps/api/src/main.ts ; grep -c "process.env.NEXT_PUBLIC_APP_VERSION" apps/web/src/lib/app-version.ts ; grep -c "<AppVersionBadge />" apps/web/src/components/layout/sidebar.tsx ; node -e "const d=require('./apps/web/src/messages/de.json'),e=require('./apps/web/src/messages/en.json');console.log(d.sidebar.channel.beta,d.sidebar.channel.live,d.sidebar.channel.dev,'|',e.sidebar.channel.dev)" ; for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit; echo "TSC_$p=$?"; done</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
API-Spec-Zeile `Tests 6 passed (6)`; Web-Ausgabe `Test Files 3 passed (3)` und `Tests 15 passed (15)`; Greps liefern `0` (npm-Paketversion), `1` (main.ts), `1` (Literalzugriff), `1` (Sidebar); die Node-Zeile lautet `Beta Live Entwicklung | Development`; alle drei `TSC_...=0`. Der RED-Lauf aus Schritt A steht mit seinen Ausgabezeilen im SUMMARY. Commit existiert mit genau 13 Dateien.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Build-Args in beide Dockerfiles, Veroeffentlichungs-Skript und CI-Trigger je Kanal, IMAGE_TAG in der Compose-Datei — Falsifizierung durch lokale Baeue</name>
|
||||||
|
<files>apps/web/Dockerfile, apps/api/Dockerfile, .gitea/scripts/publish-images.sh, .gitea/workflows/ci.yml, docker-compose.prod.yml</files>
|
||||||
|
<action>
|
||||||
|
Schritt A — Dockerfiles (beide): vor dem ersten `FROM` vier globale Args `ARG APP_VERSION=dev`, `ARG APP_CHANNEL=dev`, `ARG APP_COMMIT=`, `ARG APP_BUILD_TIME=` mit einem zweizeiligen Kommentar (ASCII): Werte kommen aus `.gitea/scripts/publish-images.sh`; lokal greifen die Vorgaben; jede nutzende Stufe wiederholt `ARG NAME`, weil ein globales ARG nur die Vorgabe liefert.
|
||||||
|
- `apps/web/Dockerfile`, Stufe `builder`: NACH den vier `COPY`-Zeilen und der bestehenden Zeile `ENV NEXT_PUBLIC_API_URL=/api-proxy`, unmittelbar VOR `RUN pnpm --filter=@tessera/web build`: `ARG APP_VERSION`, `ARG APP_CHANNEL`, `ARG APP_COMMIT`, dann `ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION NEXT_PUBLIC_APP_CHANNEL=$APP_CHANNEL NEXT_PUBLIC_APP_COMMIT=$APP_COMMIT` (Kommentar: muss VOR dem Build stehen, Next.js bettet zur Bauzeit ein; so spaet wie moeglich, damit die COPY-Schichten im Cache bleiben). Stufe `runner`: nach `ENV NODE_ENV=production` die vier `ARG`-Wiederholungen und `ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME` (Laufzeit-Umgebung, schadet nicht, gleiche Form wie die API).
|
||||||
|
- `apps/api/Dockerfile`, Stufe `runner`: nach `ENV NODE_ENV=production` dieselben vier `ARG`-Wiederholungen und dieselbe `ENV`-Zeile. Die builder-Stufe der API braucht nichts (Nest liest zur Laufzeit).
|
||||||
|
Sonst nichts an den Dockerfiles aendern (Nutzer, Ports, CMD, Prisma-Kopierpfade bleiben).
|
||||||
|
|
||||||
|
Schritt B — `.gitea/scripts/publish-images.sh` (NEU, POSIX `sh`, `set -eu`, ausfuehrbar `chmod +x`, Kopfkommentar ASCII mit Bezug quick-260914-ku1 und dem Kanalmodell):
|
||||||
|
- `REGISTRY="${REGISTRY:-localhost:3002/schalli/tessera-ctl}"`, `REF="${GITHUB_REF:-}"`.
|
||||||
|
- `case "$REF" in refs/tags/v*) APP_CHANNEL=live; TAGS="live ${REF#refs/tags/}" ;; refs/heads/main) APP_CHANNEL=beta; TAGS="beta latest" ;; *) echo "Kein Veroeffentlichungs-Anlass fuer '$REF' (nur main und Tags v*): nichts zu tun."; exit 0 ;; esac` — der Zweig `live` OHNE Tag wird damit gepreuft, aber nicht veroeffentlicht (Begruendung im Kommentar: auf `live` ist jeder auslieferbare Stand ein Tag; ein ungetaggter Merge darf das `live`-Etikett nicht ueberschreiben, sonst waere der Tag nicht mehr die Wahrheit).
|
||||||
|
- `APP_VERSION="$(git describe --tags --always)"`, `APP_COMMIT="$(git rev-parse --short HEAD)"`, `APP_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"`; eine Ausgabezeile `Tessera $APP_VERSION ($APP_CHANNEL) $APP_COMMIT $APP_BUILD_TIME -> Etiketten: $TAGS`.
|
||||||
|
- `if [ "${1:-}" = "--print-plan" ]`: fuer `IMG in web api` und `TAG in $TAGS` je eine Zeile `push $REGISTRY/$IMG:$TAG` ausgeben, `exit 0` — kein Docker-Aufruf (fuer lokale Gates und die CI-Fehlersuche).
|
||||||
|
- Sonst fuer `IMG in web api`: `docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" --build-arg APP_VERSION="$APP_VERSION" --build-arg APP_CHANNEL="$APP_CHANNEL" --build-arg APP_COMMIT="$APP_COMMIT" --build-arg APP_BUILD_TIME="$APP_BUILD_TIME" -f "apps/$IMG/Dockerfile" .`; dann fuer jedes `TAG in $TAGS`: `docker tag "$REGISTRY/$IMG:$APP_CHANNEL" "$REGISTRY/$IMG:$TAG"` und `docker push "$REGISTRY/$IMG:$TAG"`.
|
||||||
|
- Das Skript gibt niemals ein Secret aus (es kennt keins; der Login bleibt im Workflow).
|
||||||
|
|
||||||
|
Schritt C — `.gitea/workflows/ci.yml`: `on.push.branches: [main, live]` und `on.push.tags: ['v*']`. `quality` und `test` unveraendert. `publish`: `actions/checkout@v4` bekommt `with: fetch-depth: 0` (Kommentar: ohne volle Historie und Tags liefert `git describe` nichts — Pflicht fuer den Stempel); der Login-Schritt bleibt byte-identisch; die drei bisherigen Build-/Push-Schritte werden durch EINEN Schritt `Versionsstempel berechnen, Abbilder bauen und veroeffentlichen` mit `run: sh .gitea/scripts/publish-images.sh` ersetzt. Kein `if:` auf Job-Ebene — die Entscheidung liegt im Skript, damit sie lokal mit `--print-plan` pruefbar ist und nicht vom Ausdrucks-Auswerter des Runners abhaengt. Kommentar oben in der Datei (ASCII): Kanalmodell in drei Zeilen.
|
||||||
|
|
||||||
|
Schritt D — `docker-compose.prod.yml`: nur die beiden `image:`-Zeilen auf `git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}` bzw. `.../api:${IMAGE_TAG:-beta}` mit einem Kommentar darueber (Ton der Datei, englisch wie die Nachbarkommentare oder deutsch — an den vorhandenen Kommentaren orientieren): `IMAGE_TAG` in `.env` = `beta` oder `live`, Vorgabe `beta`. Registry-Host, Ports, Umgebung, Healthchecks unangetastet. Danach darf kein `:latest` mehr in der Datei stehen (Gate).
|
||||||
|
<!-- planner-discipline-allow: :latest -->
|
||||||
|
|
||||||
|
Schritt E — Falsifizierung (erwartete Dauer 6-12 Minuten fuer vier Baeue, KEIN Haenger; Layer-Cache der deps-Stufe ist warm):
|
||||||
|
1. `docker build -t tessera-ku1-api:args --build-arg APP_VERSION=v9.9.9-test --build-arg APP_CHANNEL=live --build-arg APP_COMMIT=abc1234 --build-arg APP_BUILD_TIME=2026-09-14T00:00:00Z -f apps/api/Dockerfile .` und dasselbe fuer web (`tessera-ku1-web:args`, `-f apps/web/Dockerfile`).
|
||||||
|
2. `docker build -t tessera-ku1-api:noargs -f apps/api/Dockerfile .` und `tessera-ku1-web:noargs` — ohne Args.
|
||||||
|
3. Beweise (Ausgaben ins SUMMARY): `docker run --rm --entrypoint node tessera-ku1-api:args -e 'console.log(process.env.APP_VERSION, process.env.APP_CHANNEL, process.env.APP_COMMIT, process.env.APP_BUILD_TIME)'` -> `v9.9.9-test live abc1234 2026-09-14T00:00:00Z`; `docker run --rm --entrypoint node tessera-ku1-api:args -e 'console.log(require("/app/apps/api/dist/health/app-version").formatAppVersionLine())'` -> `Tessera API v9.9.9-test (live) abc1234` (kompilierter Code liest die Laufzeit-Umgebung); `docker run --rm --entrypoint sh tessera-ku1-web:args -c 'grep -rl "v9.9.9-test" /app/apps/web/.next/static | wc -l'` -> mindestens `1` (Bauzeit-Einbettung im Browser-Bundle); `docker run --rm --entrypoint node tessera-ku1-web:args -e 'console.log(process.env.APP_VERSION)'` -> `v9.9.9-test`; beide `:noargs`-Abbilder -> `dev` bei derselben Node-Zeile, und im Web-Bundle ohne Args ist `v9.9.9-test` NICHT enthalten (`wc -l` -> `0`).
|
||||||
|
4. Aufraeumen: `docker rmi tessera-ku1-api:args tessera-ku1-web:args tessera-ku1-api:noargs tessera-ku1-web:noargs tessera-web-plancheck:baseline` (die Etiketten; Layer bleiben im Cache).
|
||||||
|
|
||||||
|
Schritt F — Gates ohne Docker: js-yaml-Struktur (siehe verify), `--print-plan` in drei Lagen, `docker compose config --images` mit und ohne `IMAGE_TAG`, `git describe --tags --always` liefert einen 7-stelligen Hex-SHA (kein Tag vorhanden, Bau vor dem ersten Tag scheitert nicht).
|
||||||
|
|
||||||
|
Commit: `ci(quick-260914-ku1): zwei Kanaele — main -> beta+latest, Tag v* -> live+vX.Y.Z, Versionsstempel als Build-Args in beide Dockerfiles, IMAGE_TAG in docker-compose.prod.yml` mit genau den 5 Dateien dieser Aufgabe.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const y=require('/home/vicolab/projects/tessera-ctl/node_modules/.pnpm/js-yaml@4.2.0/node_modules/js-yaml');const d=y.load(require('fs').readFileSync('.gitea/workflows/ci.yml','utf8'));const p=d.jobs.publish.steps;const co=p.find(s=>(s.uses||'').startsWith('actions/checkout'));console.log('branches='+JSON.stringify(d.on.push.branches),'tags='+JSON.stringify(d.on.push.tags),'jobs='+Object.keys(d.jobs).join(','),'fetchDepth='+(co&&co.with&&co.with['fetch-depth']),'script='+p.some(s=>(s.run||'').includes('publish-images.sh')),'needs='+d.jobs.publish.needs)" ; GITHUB_REF=refs/heads/main sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push .*:\(beta\|latest\)$" ; GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push .*:\(live\|v1.2.3\)$" ; GITHUB_REF=refs/heads/live sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push " ; GITHUB_REF=refs/heads/main sh .gitea/scripts/publish-images.sh --print-plan | grep -cE "^Tessera [0-9a-f]{7} \(beta\) [0-9a-f]{7} [0-9]{4}-" ; docker compose -f docker-compose.prod.yml config --images 2>/dev/null | grep -c ':beta$' ; IMAGE_TAG=live docker compose -f docker-compose.prod.yml config --images 2>/dev/null | grep -c ':live$' ; grep -c ':latest' docker-compose.prod.yml ; grep -c '^ARG APP_VERSION=dev' apps/web/Dockerfile apps/api/Dockerfile ; grep -c 'NEXT_PUBLIC_APP_VERSION=\$APP_VERSION' apps/web/Dockerfile ; grep -c 'ENV APP_VERSION=\$APP_VERSION' apps/web/Dockerfile apps/api/Dockerfile ; test -x .gitea/scripts/publish-images.sh; echo EXEC=$?</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
Node-Zeile lautet `branches=["main","live"] tags=["v*"] jobs=quality,test,publish fetchDepth=0 script=true needs=test`; die vier `--print-plan`-Greps liefern `4`, `4`, `0`, `1`; Compose-Greps `2` und `2`; `:latest`-Grep `0`; `ARG`-Grep je Datei `1`; `NEXT_PUBLIC_APP_VERSION`-Grep `1`; `ENV APP_VERSION`-Grep je Datei `1`; `EXEC=0`.
|
||||||
|
Das SUMMARY traegt unter „Falsifizierung Bauzeit-Einbettung" die sechs Docker-Ausgaben aus Schritt E (`v9.9.9-test live abc1234 ...`, die `Tessera API`-Zeile, die Trefferzahl im Web-Bundle >= 1, `v9.9.9-test` Web-Laufzeit, `dev`/`dev` ohne Args, `0` Treffer ohne Args) und die gemessene Baudauer. Commit existiert mit genau 5 Dateien.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Betriebshandbuch „Zwei Kanäle: Live und Beta", ci-cd-setup auf gemessenen Stand, Push und Beobachtung des echten CI-Laufs</name>
|
||||||
|
<files>docs/anleitung-betrieb.md, docs/ci-cd-setup.md</files>
|
||||||
|
<precondition>Gitea antwortet lokal: `curl -s --max-time 5 http://localhost:3002/api/v1/version` liefert `{"version":"1.26.2"}`, und `docker ps --format '{{.Names}}' | grep -c '^gitea-runner$'` liefert `1` (sonst ist der CI-Lauf nicht beobachtbar — dann Push trotzdem, Beobachtung als offenen Punkt ins SUMMARY).</precondition>
|
||||||
|
<action>
|
||||||
|
Schritt A — `docs/anleitung-betrieb.md` (echte Umlaute, Alltagssprache, Sie-Form wie im Bestand, ohne Fachjargon, jeder Befehl als Codeblock):
|
||||||
|
1. Inhaltsverzeichnis (Zeilen 12-21): Eintrag `9. [Zwei Kanäle: Live und Beta](#9-zwei-kanäle-live-und-beta)` hinter Eintrag 8.
|
||||||
|
2. Abschnitt 1, Tabelle (Zeilen 32-33): `web:latest`/`api:latest` durch `web:${IMAGE_TAG}` bzw. `api:${IMAGE_TAG}` ersetzen und in Klammern „(`beta` oder `live`, siehe Kapitel 9)".
|
||||||
|
3. Abschnitt 3, Konfigurationstabelle: neue Zeile nach `API_INTERNAL_URL` (Zeile 161): `IMAGE_TAG` | empfohlen | `beta` | Welcher Kanal auf diesem Server läuft: `beta` (alle Neuerungen, alpha) oder `live` (nur freigegebene Versionen, tessera.ctl.de). Siehe Kapitel 9.
|
||||||
|
4. Abschnitt 4 (Zeilen 194, 207, 210): `:latest` im Text durch „demselben Etikett (`beta` bzw. `live`)" und in den zwei `docker image inspect`-Befehlen durch `:beta` (mit Hinweis „auf dem Live-Server `:live`") ersetzen.
|
||||||
|
5. Abschnitt 7 (Zeile 320): nach `Tessera API running on port 3001` die neue Zeile `Tessera API v1.0.0 (live) abc1234` als zweite Erwartung nennen (Version, Kanal, Kurzkennung des Standes).
|
||||||
|
6. Neuer Abschnitt `## 9. Zwei Kanäle: Live und Beta` NACH Abschnitt 8 (Dateiende), mit diesen Unterabschnitten (`###`), jeder in drei bis acht Sätzen plus Befehle:
|
||||||
|
- „Was ein Kanal ist": Beta = alles Neue, sofort nach jeder Änderung (Adresse alpha.tessera.ctl.de, Etikett `beta`; `latest` ist nur ein zweiter Name für `beta`, bleibt vorerst und kann später wegfallen). Live = nur freigegebene Versionen mit Nummer (tessera.ctl.de, Etikett `live` und zusätzlich `v1.0.0`, `v1.0.1`, …). Die Versionsnummer kommt aus der Freigabe (Git-Tag), nicht aus einer Datei im Code; zwischen zwei Freigaben zeigt die Beta „v1.0.0-12-abc1234" (12 Änderungen nach 1.0.0), vor der allerersten Freigabe nur eine Kurzkennung.
|
||||||
|
- „Die eine Zeile je Server": `IMAGE_TAG=beta` in `/opt/tessera/.env` auf alpha, `IMAGE_TAG=live` auf dem neuen Server; ohne die Zeile nimmt die Compose-Datei `beta`. Dazu, weil `/opt/tessera` keine Arbeitskopie ist (Kapitel 3, Drift): die zwei `image:`-Zeilen in `/opt/tessera/docker-compose.prod.yml` (das ist die auf alpha benutzte Datei; `.env` setzt `COMPOSE_FILE`) von Hand auf die Form `git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}` und `.../api:${IMAGE_TAG:-beta}` bringen (vorher `cp docker-compose.prod.yml docker-compose.prod.yml.bak.$(date +%Y%m%d)`), danach `pull` und `up -d --force-recreate api web`. Hinweis: die Vorlage `.env.prod.example` enthält die Zeile noch nicht — beim Anlegen einer neuen `.env` von Hand ergänzen.
|
||||||
|
- „Eine Version freigeben" (was Claude tut; der User sagt nur „Version X freigeben"): `git checkout live`, `git merge --ff-only main` (geht das nicht, ist eine Korrektur noch nicht zurück in `main` — erst Hotfix-Schritt 5 nachholen), `git tag -a vX.Y.Z -m "Tessera X.Y.Z"`, `git push origin live vX.Y.Z`; die Pipeline baut den Stand mit dem Tag und legt `live` + `vX.Y.Z` ab (zwei Läufe: Zweig prüft nur, Tag veröffentlicht). Danach der User auf dem Live-Server: `docker compose -f docker-compose.prod.yml pull` und `docker compose -f docker-compose.prod.yml up -d --force-recreate api web` (Kapitel 4 gilt unverändert). Erstfreigabe v1.0.0 als eigener kleiner Absatz: `git checkout -b live main`, Tag `v1.0.0`, Push — erfolgt nach dem Fehler-melden-Knopf, nicht in diesem Durchlauf.
|
||||||
|
- „Einen Fehler auf Live beheben (Hotfix)": 1. `git checkout live && git pull`; 2. Korrekturzweig `hotfix/<kurzer-name>` von `live`; 3. Korrektur + Tests; 4. nach `live` mergen, Tag `vX.Y.(Z+1)`, `git push origin live vX.Y.(Z+1)`, User spielt auf Live ein; 5. Korrektur in die Beta: `git checkout main && git merge live` — vorher prüfen, ob sie dort zusammenpasst (Konflikte, Tests), dann `git push` — die Beta bekommt sie mit dem nächsten Lauf. Regel in eigener Fettschrift: **Keine Datenbankänderung als Hotfix.** Begründung in Alltagssprache: Datenbankänderungen (Migrationen) werden nach ihrem Zeitstempel im Namen sortiert und in dieser Reihenfolge ausgeführt; die Beta hat womöglich schon neuere Änderungen eingespielt; eine Hotfix-Änderung mit noch späterem Stempel landet beim Zusammenführen hinter Änderungen, die sie eigentlich nicht kennt — das ist der eine Fall, der beim Übernehmen in die Beta still kaputtgehen kann. Braucht eine Korrektur eine Datenbankänderung, wird sie als reguläre Version über `main` freigegeben.
|
||||||
|
- „Woran Sie erkennen, welche Version läuft": (a) unten in der Seitenleiste steht `v1.0.0 · Live` bzw. `· Beta`; Maus darüber zeigt die Kurzkennung und die Version des Servers — weichen Oberfläche und Server ab, wurde nur einer der beiden Container neu erstellt (Kapitel 4, `--force-recreate api web`); (b) auf dem Server `curl -s http://localhost:3001/health/version` (Antwortfelder `version`, `channel`, `commit`, `buildTime`); (c) `docker compose -f docker-compose.prod.yml logs api | grep "Tessera API"`.
|
||||||
|
- „Den neuen Live-Server einrichten": Kapitel 2 gilt vollständig; Abweichungen: `IMAGE_TAG=live` in der `.env`; eigene, neu erzeugte Geheimnisse (`JWT_SECRET`, `TESSERA_ENCRYPTION_KEY`, `DB_PASSWORD`, Admin-Passwort) — nichts von alpha übernehmen; eigene, leere Datenbank (die API legt den ersten Admin an) — die alpha-Datenbank wird NICHT kopiert, es sei denn, das wird ausdrücklich gewünscht (dann Kapitel 6 Wiederherstellung UND derselbe `TESSERA_ENCRYPTION_KEY`, sonst sind gespeicherte Zugangsdaten unbrauchbar); `APP_URL=https://tessera.ctl.de`; erster `pull` holt `:live` — vor der Erstfreigabe v1.0.0 gibt es dieses Etikett noch nicht, deshalb erst freigeben, dann installieren (oder für den Probelauf `IMAGE_TAG=beta`, danach umstellen).
|
||||||
|
7. Abschnitt 8 bleibt; nur der Verweis „Kapitel 4" um „und 9" ergänzen, falls dort vom Einspielen die Rede ist (Zeile 344-345).
|
||||||
|
|
||||||
|
Schritt B — `docs/ci-cd-setup.md` (ASCII-Umschrift wie im Bestand, keine Umlaute):
|
||||||
|
1. Abschnitt 3 „Gitea Secrets" (Zeilen 81-93): die Aussage, es wuerden keine Secrets gebraucht und es gebe keinen Registry-Push, durch den Stand ersetzen: Secret `REGISTRY_TOKEN` (Gitea-Zugangstoken mit Paket-Schreibrecht), verwendet im Login `docker login localhost:3002 --password-stdin` (Token nie im Log; Gitea maskiert Secrets); Push geht ueber `localhost:3002`, weil der Nginx Proxy Manager vor `git.vicolab.de` grosse Blobs blockt — das Pullen auf den Servern laeuft ueber `git.vicolab.de`.
|
||||||
|
2. Abschnitt 4 „Pipeline-Ueberblick" (Zeilen 95-117): Trigger `push` auf `main` und `live` sowie Tags `v*`; Jobs `quality` (Lint ist derzeit ein Leerlauf, WINDOWS #35, Type-Check echt) -> `test` -> `publish`; `publish` = `actions/checkout@v4` mit `fetch-depth: 0` (Tags fuer `git describe`), Login, `.gitea/scripts/publish-images.sh`. Etiketten-Tabelle: `main` -> `beta` + `latest` (Alias, entfaellt spaeter); Tag `vX.Y.Z` -> `live` + `vX.Y.Z`; Zweig `live` ohne Tag -> nur pruefen. Stempel: `APP_VERSION` (`git describe --tags --always`), `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME` als `--build-arg` in beide Dockerfiles; Web bettet `NEXT_PUBLIC_APP_*` zur Bauzeit ein, deshalb baut der Web-`builder` jetzt bei jedem Lauf neu (Laufzeit eher 4-6 statt 2 Minuten). Lokale Probe: `GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan`. Der Unterabschnitt „Kein Registry-Push" und das Wort `build-deploy` verschwinden; D-13 als ueberholt kennzeichnen.
|
||||||
|
<!-- planner-discipline-allow: Kein Registry-Push, build-deploy -->
|
||||||
|
3. Abschnitt 5 „Workflow-Dateien" (143-147): einen Satz ergaenzen — ein Tag-Push ist der Freigabe-Hebel fuer Live; heute hat nur das Konto `schalli` Schreibrecht (0 Kollaborateure, keine Branch-Regeln); kommen weitere Konten dazu, in Gitea eine Tag-Schutzregel fuer `v*` und Branch-Schutz fuer `live` anlegen (T-KU1-04).
|
||||||
|
4. Abschnitt 6 „Fehlerbehebung": Punkt „Stempel zeigt `dev` oder nur eine Kurzkennung statt des Tags" -> `fetch-depth: 0` im Checkout pruefen und ob der Tag gepusht wurde (`git ls-remote --tags origin`).
|
||||||
|
|
||||||
|
Schritt C — Gates, Commit, Push, Beobachtung:
|
||||||
|
1. `grep -c "^## 9. Zwei Kanäle: Live und Beta" docs/anleitung-betrieb.md` -> 1; `grep -c "IMAGE_TAG" docs/anleitung-betrieb.md` -> mindestens 6; `grep -c "Keine Datenbankänderung als Hotfix" docs/anleitung-betrieb.md` -> 1; `grep -c "Kein Registry-Push\|build-deploy" docs/ci-cd-setup.md` -> 0; `grep -c "publish-images.sh" docs/ci-cd-setup.md` -> mindestens 2; `grep -c '[äöüÄÖÜß]' docs/ci-cd-setup.md` -> 0 (ASCII-Konvention der Datei gehalten).
|
||||||
|
2. `D=$(git diff --stat 6c19451 -- . ':!.planning'); echo GIT_EXIT=$?; tail -n1 <<< "$D"` -> `GIT_EXIT=0` und `20 files changed`; Stichprobe unangetastet: `git diff --stat 6c19451 -- apps/api/prisma biome.json '.env*' apps/web/package.json apps/api/package.json pnpm-lock.yaml` -> leer.
|
||||||
|
3. Commit: `docs(quick-260914-ku1): Betriebshandbuch — Zwei Kanäle Live und Beta, Freigabe, Hotfix ohne Datenbankänderung, neuer Live-Server; ci-cd-setup auf gemessenen Stand` (nur die 2 Dateien). Danach `git push` (schlichter Aufruf, die Push-URL zeigt auf localhost:3002); `S=$(git status -sb); echo GIT_EXIT=$?; head -n1 <<< "$S"` -> `GIT_EXIT=0`, kein `[ahead`.
|
||||||
|
4. Beobachtung des echten CI-Laufs (Token NIE ausgeben — nur in der Variablen verwenden): `PUSHED=$(git rev-parse HEAD); PUSHURL=$(git config --get remote.origin.pushurl); TOK=$(printf '%s' "$PUSHURL" | sed -E 's#.*schalli:([^@]+)@.*#\1#')`; dann bis zu 12 Minuten alle 20 s `curl -s -H "Authorization: token $TOK" "http://localhost:3002/api/v1/repos/schalli/tessera-ctl/actions/runs?limit=5"` abfragen, den Eintrag mit `head_sha == PUSHED` nehmen und auf `status == completed` warten (als Hintergrundbefehl starten, falls `sleep` im Vordergrund blockiert ist). Erwartung `conclusion == success`. Danach auf dem Host: `docker run --rm --entrypoint node localhost:3002/schalli/tessera-ctl/api:beta -e 'console.log(process.env.APP_VERSION, process.env.APP_CHANNEL)'` -> `<kurzer SHA von PUSHED> beta` (Vergleich mit `git rev-parse --short $PUSHED`), und `docker image inspect -f '{{.Created}}' localhost:3002/schalli/tessera-ctl/web:beta localhost:3002/schalli/tessera-ctl/web:latest` zeigt zwei gleiche Zeitstempel NACH dem Push (beide Etiketten aus demselben Bau). Ergebnis (Lauf-ID, Dauer `started_at`/`completed_at`, Stempel-Ausgabe) ins SUMMARY. Ist `conclusion` nicht `success`: Job-Log ueber `.../actions/runs/<id>/jobs` lesen, Ursache im SUMMARY benennen, Korrektur als eigener `fix(quick-260914-ku1)`-Commit, erneut pushen und beobachten — die haeufigste Ursache waere ein Runner-Umgebungsdetail (Git im Job-Container, `GITHUB_REF` nicht gesetzt); das Skript-Design mit `--print-plan` grenzt das ein.
|
||||||
|
5. Wird das SUMMARY erst nach dem Push committet, den Push danach wiederholen (ein weiterer CI-Lauf ist erwartet und in Ordnung).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "^## 9. Zwei Kanäle: Live und Beta" docs/anleitung-betrieb.md ; grep -c "IMAGE_TAG" docs/anleitung-betrieb.md ; grep -c "Keine Datenbankänderung als Hotfix" docs/anleitung-betrieb.md ; grep -c "Kein Registry-Push\|build-deploy" docs/ci-cd-setup.md ; grep -c "publish-images.sh" docs/ci-cd-setup.md ; grep -c '[äöüÄÖÜß]' docs/ci-cd-setup.md ; D=$(git diff --stat 6c19451 -- . ':!.planning'); echo GIT_EXIT=$? ; tail -n1 <<< "$D" ; U=$(git diff --stat 6c19451 -- apps/api/prisma biome.json '.env*' apps/web/package.json apps/api/package.json pnpm-lock.yaml); echo U_EXIT=$? ; test -z "$U"; echo U_EMPTY=$? ; S=$(git status -sb); head -n1 <<< "$S"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
Greps liefern `1`, `>= 6`, `1`, `0`, `>= 2`, `0`; `GIT_EXIT=0` und die Summenzeile nennt `20 files changed`; die Unangetastet-Stichprobe liefert `U_EXIT=0` und `U_EMPTY=0`; die Status-Zeile enthaelt kein `[ahead`. Das SUMMARY traegt unter „CI-Lauf nach dem Push" Lauf-ID, `conclusion`, Dauer und die Stempel-Zeile `<sha> beta` der vom Runner gebauten Abbilder (oder, falls Gitea/Runner nicht erreichbar waren, den Grund und den offenen Punkt).
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Gitea-Repository -> CI-Runner -> Registry | Ein Push auf `main` oder ein Tag `v*` loest Bau und Veroeffentlichung aus; der Runner haelt das Registry-Token als Secret und den Host-Docker-Socket |
|
||||||
|
| Registry -> Server (alpha, Live) | `docker compose pull` holt das Etikett aus `IMAGE_TAG`; welches Etikett, entscheidet eine Zeile in `.env` auf dem Server |
|
||||||
|
| Internet -> `GET /health/version` (@Public) | Unauthentifizierter Aufrufer erfaehrt Name, Version, Kanal, Commit-Kurzkennung, Bauzeit |
|
||||||
|
| Angemeldeter Nutzer -> Seitenleiste | Sieht Version, Kanal, Web-Commit und API-Version im Tooltip |
|
||||||
|
| Entwicklungsablauf (Hotfix) -> Datenbank der Beta | Ein Merge `live -> main` bringt Aenderungen in eine Umgebung mit moeglicherweise neueren Migrationen |
|
||||||
|
|
||||||
|
## STRIDE Threat Register (ASVS Level 1, Blocking-Schwelle `high`)
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-KU1-01 | Information Disclosure | `REGISTRY_TOKEN` im CI-Log (`ci.yml` Login-Schritt, neues Skript) | medium | mitigate | Login bleibt `--password-stdin` aus `${{ secrets.REGISTRY_TOKEN }}` (Gitea maskiert Secrets im Log); das Skript kennt das Token nicht und gibt nur Versions-/Etikettenzeilen aus; Task 3 Schritt C4 verwendet das Push-Token nur in einer Shell-Variablen, nie in einer Ausgabe |
|
||||||
|
| T-KU1-02 | Information Disclosure | Web-Commit-Kurzkennung im Tooltip fuer angemeldete Nutzer (`app-version-badge.tsx`) | low | accept | Repository ist privat (Gitea, Fetch nur mit Konto); ein 7-stelliger SHA ist ohne Repo-Zugang nicht verwertbar; die Beta-Versionszeichenkette `v1.0.0-12-gabc1234` enthaelt ihn ohnehin; Nutzen fuer Fehlermeldungen ueberwiegt |
|
||||||
|
| T-KU1-03 | Information Disclosure | `GET /health/version` bleibt `@Public()` mit allen Feldern (`health.controller.ts`) | low | accept | Bewusste Entscheidung, durch Spec-Test 6 gepinnt: Hauptzweck ist die Betreiber-Kontrolle per `curl` auf dem Server ohne Anmeldung (Handbuch Abschnitt 9); es werden keine Versionen von Systemkomponenten (Node, Nest, Postgres) preisgegeben — ASVS V14.3.3 zielt auf solche; Repo privat, Tessera laeuft hinter dem Nginx Proxy Manager fuer interne Nutzer. Wird Tessera spaeter extern verkauft, `commit`/`buildTime` hinter die Anmeldung ziehen (eine Zeile: `@Public()` entfernen, Abzeichen ruft ohnehin mit Cookie) |
|
||||||
|
| T-KU1-04 | Elevation of Privilege | Tag-Push `v*` als Freigabe-Hebel fuer Live (`ci.yml`, Skript) | medium | accept | Gemessen: 0 Kollaborateure, nur `schalli` hat Schreibrecht, Claude ist einziger Committer (D-11); kein Fremdcode. Empfehlung in `ci-cd-setup.md` Abschnitt 5: bei weiteren Konten Tag-Schutz `v*` und Branch-Schutz `live` in Gitea. Gitea-Konfiguration liegt ausserhalb der Erlaubnisliste |
|
||||||
|
| T-KU1-05 | Tampering | Falscher Kanal auf einem Server (`IMAGE_TAG` fehlt/falsch, Live zieht Beta) | medium | mitigate | Vorgabe `beta` ist fuer den bestehenden alpha-Server der richtige und fuer den Live-Server der auffaellige Fall (Seitenleiste zeigt `· Beta`, `/health/version` `channel: beta`); Handbuch nennt die Zeile je Server und die drei Kontrollwege; `docker compose config`-Gate beweist die Aufloesung beider Werte |
|
||||||
|
| T-KU1-06 | Tampering | Hotfix mit Migration bricht beim Merge `live -> main` die Beta-Datenbank (Prisma sortiert nach Zeitstempel) | medium | mitigate | Regel „Keine Datenbankaenderung als Hotfix" im Handbuch mit Begruendung in Alltagssprache; Korrekturen mit Migration gehen nur als regulaere Version ueber `main`; Prisma-Schema und Migrationen in diesem Plan unangetastet |
|
||||||
|
| T-KU1-07 | Denial of Service | Ungetaggter Push auf `live` ueberschreibt das `live`-Etikett mit einem ungepruefte Stand | medium | mitigate | Skript veroeffentlicht NUR fuer `refs/heads/main` und `refs/tags/v*`; jeder andere Ref endet mit „nichts zu tun" — durch `--print-plan`-Gate (Task 2, dritter Grep = 0) gepinnt |
|
||||||
|
| T-KU1-08 | Repudiation | Welcher Stand laeuft, ist ohne Stempel nicht nachvollziehbar (heute immer `0.0.1`) | low | mitigate | Genau der Gegenstand des Plans: Stempel im Abbild, in der Antwort, im Startlog und in der Oberflaeche; CI-Lauf nach dem Push beweist den echten Weg (Task 3 Schritt C4) |
|
||||||
|
| T-KU1-SC | Tampering | npm/pip/cargo installs | low | accept | Dieser Plan installiert KEIN Paket (keine neue Abhaengigkeit, `pnpm-lock.yaml` unangetastet — Gate in Task 3); Paketlegitimitaets-Gate nicht ausgeloest |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Nach Task 3, alles aus `/home/vicolab/projects/tessera-ctl`:
|
||||||
|
- `pnpm -C apps/api exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)"` -> `Test Files 65 passed (65)` / `Tests 1060 passed (1060)`
|
||||||
|
- `pnpm -C apps/web exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)"` -> `Test Files 40 passed (40)` / `Tests 243 passed (243)`
|
||||||
|
- `for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit; echo "TSC_$p=$?"; done` -> dreimal `=0`
|
||||||
|
- `D=$(git diff --stat 6c19451 -- . ':!.planning'); echo GIT_EXIT=$?; tail -n1 <<< "$D"` -> `GIT_EXIT=0` und `20 files changed`
|
||||||
|
- `U=$(git diff --stat 6c19451 -- apps/api/prisma biome.json '.env*' apps/web/package.json apps/api/package.json pnpm-lock.yaml); echo U_EXIT=$?; test -z "$U"; echo U_EMPTY=$?` -> `U_EXIT=0` und `U_EMPTY=0`
|
||||||
|
- `GITHUB_REF=refs/heads/live sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push "` -> `0`; `GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push "` -> `4`
|
||||||
|
- `IMAGE_TAG=live docker compose -f docker-compose.prod.yml config --images 2>/dev/null | grep -c ':live$'` -> `2`
|
||||||
|
- `docker run --rm --entrypoint node localhost:3002/schalli/tessera-ctl/api:beta -e 'console.log(process.env.APP_VERSION, process.env.APP_CHANNEL)'` -> `<kurzer SHA des gepushten Commits> beta` (nach abgeschlossenem CI-Lauf)
|
||||||
|
- SUMMARY enthaelt: RED-Laeufe (Task 1), „Falsifizierung Bauzeit-Einbettung" mit sechs Docker-Ausgaben und Baudauer (Task 2), „CI-Lauf nach dem Push" mit Lauf-ID/Dauer/Stempel (Task 3), Nebenbefund „`sidebar-footer.tsx` ist seit ba02b25 toter Code" und den Hinweis, dass `.env.prod.example` bewusst nicht angefasst wurde (Regel `.env`-Dateien) — die `IMAGE_TAG`-Zeile steht nur im Handbuch.
|
||||||
|
- Human-Check (end-of-phase, nicht blockierend): lokal `docker compose up -d --build web api` und im Browser unten in der Seitenleiste `dev · Entwicklung` sehen, Tooltip `API dev (dev)`; eingeklappt verschwindet die Zeile.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Versionsstempel fliesst CI -> Build-Args -> Abbilder -> `GET /health/version` / Startlog -> Seitenleiste; lokal ohne Args bleibt alles `dev`; die Bauzeit-Einbettung im Browser-Bundle ist mit `v9.9.9-test` bewiesen; der echte CI-Lauf nach dem Push hat `:beta`-Abbilder mit dem SHA des gepushten Commits gebaut.
|
||||||
|
- Pipeline: `main` -> `beta` + `latest`; Tag `v*` -> `live` + `vX.Y.Z`; `live` ohne Tag prueft nur; `fetch-depth: 0`; Entscheidung im Skript, lokal per `--print-plan` gepinnt.
|
||||||
|
- `docker-compose.prod.yml` mit `${IMAGE_TAG:-beta}`, beide Werte per `config --images` bewiesen; Registry-Host unangetastet.
|
||||||
|
- Handbuch Abschnitt 9 in Alltagssprache mit echten Umlauten (Kanal, `.env`-Zeile, Freigabe, Hotfix ohne Datenbankaenderung, Versionskontrolle, neuer Live-Server, Erstfreigabe-Rezept); `ci-cd-setup.md` ohne die veralteten Aussagen.
|
||||||
|
- API 1060/65, Web 243/40, `tsc` dreimal 0, genau 20 Dateien ausserhalb `.planning`, drei Commits mit Scope `quick-260914-ku1`, gepusht; `live`-Zweig und `v1.0.0` NICHT angelegt.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/260914-ku1-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
# Versionsstempel (quick-260914-ku1): die Werte setzt .gitea/scripts/publish-images.sh
|
||||||
|
# per --build-arg; lokal greifen die Vorgaben (dev). Ein globales ARG liefert nur die
|
||||||
|
# Vorgabe -- jede nutzende Stufe wiederholt deshalb `ARG NAME` ohne Wert.
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ARG APP_CHANNEL=dev
|
||||||
|
ARG APP_COMMIT=
|
||||||
|
ARG APP_BUILD_TIME=
|
||||||
|
|
||||||
FROM node:24-alpine AS base
|
FROM node:24-alpine AS base
|
||||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
|
|
||||||
@@ -20,6 +28,11 @@ RUN pnpm --filter=@tessera/api build
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ARG APP_VERSION
|
||||||
|
ARG APP_CHANNEL
|
||||||
|
ARG APP_COMMIT
|
||||||
|
ARG APP_BUILD_TIME
|
||||||
|
ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME
|
||||||
RUN addgroup --system --gid 1001 nestjs && \
|
RUN addgroup --system --gid 1001 nestjs && \
|
||||||
adduser --system --uid 1001 nestjs && \
|
adduser --system --uid 1001 nestjs && \
|
||||||
mkdir -p /app/user-files && \
|
mkdir -p /app/user-files && \
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { VersionResponse } from '@tessera/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versionsstempel der API (quick-260914-ku1).
|
||||||
|
*
|
||||||
|
* Woher die Werte kommen: das CI-Skript `.gitea/scripts/publish-images.sh`
|
||||||
|
* berechnet `APP_VERSION` (`git describe --tags --always`), `APP_CHANNEL`
|
||||||
|
* (`beta` fuer main, `live` fuer Tags v*), `APP_COMMIT` und `APP_BUILD_TIME`
|
||||||
|
* und uebergibt sie als `--build-arg`; die Runner-Stufe beider Dockerfiles
|
||||||
|
* setzt sie als `ENV`, sodass sie hier zur Laufzeit lesbar sind. Lokal ohne
|
||||||
|
* Build-Args greifen die Vorgaben `dev`/`dev`/``/``.
|
||||||
|
*
|
||||||
|
* Warum `||` statt `??`: Docker Compose reicht unbelegte Variablen als
|
||||||
|
* Leerstring weiter — ein leerer Wert muss wie ein fehlender zaehlen.
|
||||||
|
*
|
||||||
|
* `main.ts` protokolliert `formatAppVersionLine()` beim Start direkt nach der
|
||||||
|
* Port-Zeile, `HealthController.getVersion()` liefert `getAppVersion()`.
|
||||||
|
*/
|
||||||
|
export function getAppVersion(): VersionResponse {
|
||||||
|
return {
|
||||||
|
name: 'tessera',
|
||||||
|
version: process.env.APP_VERSION || 'dev',
|
||||||
|
channel: process.env.APP_CHANNEL || 'dev',
|
||||||
|
commit: process.env.APP_COMMIT || '',
|
||||||
|
buildTime: process.env.APP_BUILD_TIME || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAppVersionLine(v: VersionResponse = getAppVersion()): string {
|
||||||
|
const base = `Tessera API ${v.version} (${v.channel})`;
|
||||||
|
return v.commit ? `${base} ${v.commit}` : base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator';
|
||||||
|
import { formatAppVersionLine } from './app-version';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HealthController.spec — legt die Testlage fuer den Health-Bereich aus dem
|
||||||
|
* Nichts an (quick-260914-ku1, Aufgabe 1: vorher gab es keinen Spec).
|
||||||
|
*
|
||||||
|
* Gegenstand ist der Versionsstempel: `GET /health/version` liest
|
||||||
|
* `APP_VERSION`, `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME` aus der
|
||||||
|
* Laufzeit-Umgebung (gesetzt als `ENV` in der Runner-Stufe beider
|
||||||
|
* Dockerfiles, befuellt vom CI-Skript `.gitea/scripts/publish-images.sh`).
|
||||||
|
* Leere Zeichenketten zaehlen wie ungesetzt, weil Docker Compose unbelegte
|
||||||
|
* Variablen als Leerstring weiterreicht (siehe `migrate-and-start.sh`).
|
||||||
|
*
|
||||||
|
* Der Controller hat keine Abhaengigkeiten und wird direkt instanziiert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALL_VARS = ['APP_VERSION', 'APP_CHANNEL', 'APP_COMMIT', 'APP_BUILD_TIME'] as const;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubAll(value: string) {
|
||||||
|
for (const name of ALL_VARS) {
|
||||||
|
vi.stubEnv(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubSample() {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v1.2.3');
|
||||||
|
vi.stubEnv('APP_CHANNEL', 'live');
|
||||||
|
vi.stubEnv('APP_COMMIT', 'abc1234');
|
||||||
|
vi.stubEnv('APP_BUILD_TIME', '2026-09-14T12:00:00Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HealthController — Versionsstempel (quick-260914-ku1)', () => {
|
||||||
|
it('Test 1 (check): liefert status ok und einen Zeitstempel, den Date.parse versteht', () => {
|
||||||
|
const controller = new HealthController();
|
||||||
|
const result = controller.check();
|
||||||
|
expect(result.status).toBe('ok');
|
||||||
|
expect(Number.isNaN(Date.parse(result.timestamp))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2 (Vorgaben): ohne die vier Variablen liefert getVersion() dev/dev und leere Kennungen', () => {
|
||||||
|
for (const name of ALL_VARS) {
|
||||||
|
vi.stubEnv(name, undefined);
|
||||||
|
}
|
||||||
|
const controller = new HealthController();
|
||||||
|
expect(controller.getVersion()).toEqual({
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'dev',
|
||||||
|
channel: 'dev',
|
||||||
|
commit: '',
|
||||||
|
buildTime: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3 (durchgereicht): die vier Variablen kommen woertlich an, name bleibt tessera', () => {
|
||||||
|
stubSample();
|
||||||
|
const controller = new HealthController();
|
||||||
|
expect(controller.getVersion()).toEqual({
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'v1.2.3',
|
||||||
|
channel: 'live',
|
||||||
|
commit: 'abc1234',
|
||||||
|
buildTime: '2026-09-14T12:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4 (Compose-Semantik): leere Zeichenketten zaehlen wie ungesetzt', () => {
|
||||||
|
stubAll('');
|
||||||
|
const controller = new HealthController();
|
||||||
|
expect(controller.getVersion()).toEqual({
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'dev',
|
||||||
|
channel: 'dev',
|
||||||
|
commit: '',
|
||||||
|
buildTime: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5 (formatAppVersionLine): mit Commit angehaengt, ohne Commit kein Leerzeichen am Ende', () => {
|
||||||
|
stubSample();
|
||||||
|
expect(formatAppVersionLine()).toBe('Tessera API v1.2.3 (live) abc1234');
|
||||||
|
|
||||||
|
stubAll('');
|
||||||
|
expect(formatAppVersionLine()).toBe('Tessera API dev (dev)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 6 (bewusst oeffentlich, T-KU1-03): getVersion und check tragen @Public()', () => {
|
||||||
|
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.getVersion)).toBe(true);
|
||||||
|
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.check)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import type { HealthResponse } from '@tessera/shared';
|
import type { HealthResponse, VersionResponse } from '@tessera/shared';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
|
import { getAppVersion } from './app-version';
|
||||||
|
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
@@ -13,12 +14,12 @@ export class HealthController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bewusst oeffentlich (T-KU1-03): Betreiber-Kontrolle per `curl` auf dem
|
||||||
|
// Server ohne Anmeldung. Es werden keine Versionen von Systemkomponenten
|
||||||
|
// preisgegeben; das Repository ist privat. Gepinnt durch Spec-Test 6.
|
||||||
@Public()
|
@Public()
|
||||||
@Get('version')
|
@Get('version')
|
||||||
getVersion() {
|
getVersion(): VersionResponse {
|
||||||
return {
|
return getAppVersion();
|
||||||
version: process.env.npm_package_version ?? '0.0.1',
|
|
||||||
name: 'tessera',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { formatAppVersionLine } from './health/app-version';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@@ -31,6 +32,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
await app.listen(3001);
|
await app.listen(3001);
|
||||||
console.log('Tessera API running on port 3001');
|
console.log('Tessera API running on port 3001');
|
||||||
|
console.log(formatAppVersionLine());
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
# Versionsstempel (quick-260914-ku1): die Werte setzt .gitea/scripts/publish-images.sh
|
||||||
|
# per --build-arg; lokal greifen die Vorgaben (dev). Ein globales ARG liefert nur die
|
||||||
|
# Vorgabe -- jede nutzende Stufe wiederholt deshalb `ARG NAME` ohne Wert.
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ARG APP_CHANNEL=dev
|
||||||
|
ARG APP_COMMIT=
|
||||||
|
ARG APP_BUILD_TIME=
|
||||||
|
|
||||||
FROM node:24-alpine AS base
|
FROM node:24-alpine AS base
|
||||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
|
|
||||||
@@ -15,11 +23,22 @@ COPY apps/web/ ./apps/web/
|
|||||||
COPY packages/shared/ ./packages/shared/
|
COPY packages/shared/ ./packages/shared/
|
||||||
COPY tsconfig.base.json ./
|
COPY tsconfig.base.json ./
|
||||||
ENV NEXT_PUBLIC_API_URL=/api-proxy
|
ENV NEXT_PUBLIC_API_URL=/api-proxy
|
||||||
|
# Muss VOR dem Build stehen: Next.js bettet NEXT_PUBLIC_* zur Bauzeit ins Bundle ein.
|
||||||
|
# So spaet wie moeglich, damit die COPY-Schichten darueber im Cache bleiben.
|
||||||
|
ARG APP_VERSION
|
||||||
|
ARG APP_CHANNEL
|
||||||
|
ARG APP_COMMIT
|
||||||
|
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION NEXT_PUBLIC_APP_CHANNEL=$APP_CHANNEL NEXT_PUBLIC_APP_COMMIT=$APP_COMMIT
|
||||||
RUN pnpm --filter=@tessera/web build
|
RUN pnpm --filter=@tessera/web build
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ARG APP_VERSION
|
||||||
|
ARG APP_CHANNEL
|
||||||
|
ARG APP_COMMIT
|
||||||
|
ARG APP_BUILD_TIME
|
||||||
|
ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppVersionBadge.test — die Versionszeile unten in der Seitenleiste
|
||||||
|
* (quick-260914-ku1). Vorlage: sidebar.test.tsx (next-intl-Mock, cleanup,
|
||||||
|
* dynamischer Import nach dem Setzen der Mocks).
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: (ns: string) => (key: string) => {
|
||||||
|
const map: Record<string, Record<string, string>> = {
|
||||||
|
sidebar: {
|
||||||
|
'channel.beta': 'Beta',
|
||||||
|
'channel.live': 'Live',
|
||||||
|
'channel.dev': 'Entwicklung',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return map[ns]?.[key] ?? key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockAppVersion = { version: 'v1.2.3', channel: 'beta' as 'beta' | 'live' | 'dev', commit: 'abc1234' };
|
||||||
|
const mockLoad = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/app-version', () => ({
|
||||||
|
get appVersion() {
|
||||||
|
return mockAppVersion;
|
||||||
|
},
|
||||||
|
loadApiVersion: () => mockLoad(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
mockLoad.mockReset();
|
||||||
|
mockAppVersion = { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importBadge() {
|
||||||
|
const mod = await import('./app-version-badge');
|
||||||
|
return mod.AppVersionBadge;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppVersionBadge (quick-260914-ku1)', () => {
|
||||||
|
it('Test 1 (Zeile): zeigt Version und uebersetzten Kanal', async () => {
|
||||||
|
mockLoad.mockResolvedValue(null);
|
||||||
|
const AppVersionBadge = await importBadge();
|
||||||
|
render(<AppVersionBadge />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('app-version')).toHaveTextContent('v1.2.3 · Beta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2 (Tooltip mit API): title nennt Commit und API-Version samt Kanal', async () => {
|
||||||
|
mockLoad.mockResolvedValue({
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'v1.2.3',
|
||||||
|
channel: 'beta',
|
||||||
|
commit: 'abc1234',
|
||||||
|
buildTime: '',
|
||||||
|
});
|
||||||
|
const AppVersionBadge = await importBadge();
|
||||||
|
render(<AppVersionBadge />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const title = screen.getByTestId('app-version').getAttribute('title') ?? '';
|
||||||
|
expect(title).toContain('Commit abc1234');
|
||||||
|
expect(title).toContain('API v1.2.3 (beta)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3 (Tooltip ohne API): title nennt den Commit, aber keinen API-Teil', async () => {
|
||||||
|
mockLoad.mockResolvedValue(null);
|
||||||
|
const AppVersionBadge = await importBadge();
|
||||||
|
render(<AppVersionBadge />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoad).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
const title = screen.getByTestId('app-version').getAttribute('title') ?? '';
|
||||||
|
expect(title).toContain('Commit abc1234');
|
||||||
|
expect(title).not.toContain('API');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4 (Kanal dev, kein Commit): zeigt dev · Entwicklung und hat kein title-Attribut', async () => {
|
||||||
|
mockAppVersion = { version: 'dev', channel: 'dev', commit: '' };
|
||||||
|
mockLoad.mockResolvedValue(null);
|
||||||
|
const AppVersionBadge = await importBadge();
|
||||||
|
render(<AppVersionBadge />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoad).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
const el = screen.getByTestId('app-version');
|
||||||
|
expect(el).toHaveTextContent('dev · Entwicklung');
|
||||||
|
expect(el.hasAttribute('title')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { type ApiVersionInfo, appVersion, loadApiVersion } from '@/lib/app-version';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versionszeile unten in der Seitenleiste: `<Version> · <Kanal>`, z. B.
|
||||||
|
* `v1.0.0 · Beta` oder lokal `dev · Entwicklung` (quick-260914-ku1).
|
||||||
|
* Der Tooltip nennt den Web-Commit und, sobald geladen, die API-Version
|
||||||
|
* samt Kanal. "Commit" und "API" sind in beiden Sprachen gleich.
|
||||||
|
*/
|
||||||
|
export function AppVersionBadge() {
|
||||||
|
const t = useTranslations('sidebar');
|
||||||
|
const [api, setApi] = useState<ApiVersionInfo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
loadApiVersion().then((info) => {
|
||||||
|
if (active) setApi(info);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (appVersion.commit) parts.push(`Commit ${appVersion.commit}`);
|
||||||
|
if (api) parts.push(`API ${api.version} (${api.channel})`);
|
||||||
|
const title = parts.length > 0 ? parts.join(' · ') : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span data-testid="app-version" className="block truncate text-xs text-muted-foreground" title={title}>
|
||||||
|
{appVersion.version} · {t(`channel.${appVersion.channel}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,12 @@ vi.mock('@/components/layout/sidebar-footer', () => ({
|
|||||||
SidebarFooter: () => <div data-testid="sidebar-footer" />,
|
SidebarFooter: () => <div data-testid="sidebar-footer" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Das Abzeichen ruft `loadApiVersion()` und damit `fetch` — ohne Modul-Mock
|
||||||
|
// bekaeme es das Modul-Array aus `stubFetch()` (quick-260914-ku1).
|
||||||
|
vi.mock('@/components/layout/app-version-badge', () => ({
|
||||||
|
AppVersionBadge: () => <div data-testid="app-version-badge" />,
|
||||||
|
}));
|
||||||
|
|
||||||
const mockActiveModules = [
|
const mockActiveModules = [
|
||||||
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
||||||
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
||||||
@@ -178,4 +184,15 @@ describe('Sidebar', () => {
|
|||||||
expect(newCallCount).toBeGreaterThan(initialCallCount);
|
expect(newCallCount).toBeGreaterThan(initialCallCount);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the version badge below the navigation', async () => {
|
||||||
|
const Sidebar = await importSidebar();
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('app-version-badge')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TesseraLogo } from '@/components/brand/tessera-logo';
|
|||||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||||
|
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
@@ -198,6 +199,13 @@ export function Sidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
|
||||||
|
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="border-t border-sidebar-border px-4 py-2">
|
||||||
|
<AppVersionBadge />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app-version.test — Versionsstempel der Web-Oberflaeche (quick-260914-ku1).
|
||||||
|
*
|
||||||
|
* `appVersion` wird beim Laden des Moduls aus `NEXT_PUBLIC_APP_*` gelesen und
|
||||||
|
* `loadApiVersion()` memoisiert die Antwort von `GET /health/version` —
|
||||||
|
* deshalb setzt jeder Test die Module zurueck und importiert dynamisch,
|
||||||
|
* nachdem Umgebung und `fetch` gestubbt sind (Muster: module-access-gate.test.tsx).
|
||||||
|
*/
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importFresh() {
|
||||||
|
vi.resetModules();
|
||||||
|
return import('./app-version');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('app-version (quick-260914-ku1)', () => {
|
||||||
|
it('Test 1 (Vorgaben): ohne NEXT_PUBLIC_APP_* ist appVersion dev/dev ohne Commit', async () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', undefined);
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_CHANNEL', undefined);
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_COMMIT', undefined);
|
||||||
|
const mod = await importFresh();
|
||||||
|
expect(mod.appVersion).toEqual({ version: 'dev', channel: 'dev', commit: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2 (Umgebung): NEXT_PUBLIC_APP_VERSION/_CHANNEL/_COMMIT werden woertlich durchgereicht', async () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', 'v1.2.3');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_CHANNEL', 'beta');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_COMMIT', 'abc1234');
|
||||||
|
const mod = await importFresh();
|
||||||
|
expect(mod.appVersion).toEqual({ version: 'v1.2.3', channel: 'beta', commit: 'abc1234' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3 (Normalisierung): ein unbekannter Kanal wird zu dev', async () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', 'v1.2.3');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_CHANNEL', 'gamma');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_COMMIT', 'abc1234');
|
||||||
|
const mod = await importFresh();
|
||||||
|
expect(mod.appVersion.channel).toBe('dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4 (Laden, memoisiert): zwei Aufrufe liefern das Objekt, fetch laeuft genau einmal mit Cookie', async () => {
|
||||||
|
const payload = {
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'v1.2.3',
|
||||||
|
channel: 'beta',
|
||||||
|
commit: 'abc1234',
|
||||||
|
buildTime: 'x',
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn(() =>
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve(payload) }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const mod = await importFresh();
|
||||||
|
|
||||||
|
const first = await mod.loadApiVersion();
|
||||||
|
const second = await mod.loadApiVersion();
|
||||||
|
|
||||||
|
expect(first).toEqual(payload);
|
||||||
|
expect(second).toEqual(payload);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(url.endsWith('/health/version')).toBe(true);
|
||||||
|
expect(options).toMatchObject({ credentials: 'include' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5 (still bei Fehler): Netzfehler und ok=false liefern null, nichts wird geworfen', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz'))));
|
||||||
|
const rejected = await importFresh();
|
||||||
|
await expect(rejected.loadApiVersion()).resolves.toBeNull();
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
|
||||||
|
);
|
||||||
|
const notOk = await importFresh();
|
||||||
|
await expect(notOk.loadApiVersion()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Versionsstempel der Web-Oberflaeche (quick-260914-ku1).
|
||||||
|
*
|
||||||
|
* Quelle fuer das Abzeichen unten in der Seitenleiste (`AppVersionBadge`)
|
||||||
|
* UND fuer den kommenden Fehler-melden-Knopf: beide brauchen die Fassung,
|
||||||
|
* die der Anwender gerade benutzt.
|
||||||
|
*
|
||||||
|
* Wichtig: Next.js ersetzt `process.env.NEXT_PUBLIC_*` nur dann zur Bauzeit
|
||||||
|
* im Browser-Bundle, wenn der Ausdruck woertlich mit vollem Namen im Code
|
||||||
|
* steht — kein Destructuring, kein `process.env[name]`. Sonst ist der Wert
|
||||||
|
* im Browser leer. Die Werte setzt `apps/web/Dockerfile` (builder-Stufe)
|
||||||
|
* aus den Build-Args des CI-Skripts `.gitea/scripts/publish-images.sh`;
|
||||||
|
* lokal greifen die Vorgaben `dev`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AppChannel = 'beta' | 'live' | 'dev';
|
||||||
|
|
||||||
|
export interface AppVersionInfo {
|
||||||
|
version: string;
|
||||||
|
channel: AppChannel;
|
||||||
|
commit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spiegel von `VersionResponse` aus `packages/shared`: `apps/web` haengt
|
||||||
|
* nicht von `@tessera/shared` ab, und ein neuer Import wuerde Lockfile und
|
||||||
|
* die deps-Stufe des Dockerfiles aendern. Die API-Wahrheit bleibt dort.
|
||||||
|
*/
|
||||||
|
export interface ApiVersionInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
channel: string;
|
||||||
|
commit: string;
|
||||||
|
buildTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
function normalizeChannel(raw: string | undefined): AppChannel {
|
||||||
|
if (raw === 'beta' || raw === 'live') return raw;
|
||||||
|
return 'dev';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appVersion: AppVersionInfo = {
|
||||||
|
version: process.env.NEXT_PUBLIC_APP_VERSION || 'dev',
|
||||||
|
channel: normalizeChannel(process.env.NEXT_PUBLIC_APP_CHANNEL),
|
||||||
|
commit: process.env.NEXT_PUBLIC_APP_COMMIT || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt `GET /health/version` genau einmal je Seitenladung (memoisiert);
|
||||||
|
* jeder Fehler (Netz, Nicht-2xx) ist still und liefert `null`.
|
||||||
|
*/
|
||||||
|
export function loadApiVersion(): Promise<ApiVersionInfo | null> {
|
||||||
|
if (!apiVersionPromise) {
|
||||||
|
apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
|
||||||
|
.then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : null))
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
return apiVersionPromise;
|
||||||
|
}
|
||||||
@@ -104,7 +104,12 @@
|
|||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"tenants": "Mandanten",
|
"tenants": "Mandanten",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"modules": "Module"
|
"modules": "Module",
|
||||||
|
"channel": {
|
||||||
|
"beta": "Beta",
|
||||||
|
"live": "Live",
|
||||||
|
"dev": "Entwicklung"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
@@ -104,7 +104,12 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"tenants": "Tenants",
|
"tenants": "Tenants",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"modules": "Modules"
|
"modules": "Modules",
|
||||||
|
"channel": {
|
||||||
|
"beta": "Beta",
|
||||||
|
"live": "Live",
|
||||||
|
"dev": "Development"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: git.vicolab.de/schalli/tessera-ctl/web:latest
|
# IMAGE_TAG in .env selects the delivery channel: beta (alpha, all new
|
||||||
|
# changes) or live (released versions only). Defaults to beta.
|
||||||
|
image: git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -20,7 +22,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
api:
|
api:
|
||||||
image: git.vicolab.de/schalli/tessera-ctl/api:latest
|
# Same channel as web, see IMAGE_TAG above.
|
||||||
|
image: git.vicolab.de/schalli/tessera-ctl/api:${IMAGE_TAG:-beta}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
|||||||
+184
-9
@@ -19,6 +19,7 @@ Betrieb der bereits laufenden Installation, nicht deren automatisierten Build.
|
|||||||
6. [Sicherung und Wiederherstellung](#6-sicherung-und-wiederherstellung)
|
6. [Sicherung und Wiederherstellung](#6-sicherung-und-wiederherstellung)
|
||||||
7. [Protokolle und Fehlersuche](#7-protokolle-und-fehlersuche)
|
7. [Protokolle und Fehlersuche](#7-protokolle-und-fehlersuche)
|
||||||
8. [Abgrenzung zur CI/CD-Pipeline](#8-abgrenzung-zur-cicd-pipeline)
|
8. [Abgrenzung zur CI/CD-Pipeline](#8-abgrenzung-zur-cicd-pipeline)
|
||||||
|
9. [Zwei Kanäle: Live und Beta](#9-zwei-kanäle-live-und-beta)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,8 +30,8 @@ Tessera besteht aus drei Containern, definiert in `docker-compose.yml` (Basis) u
|
|||||||
|
|
||||||
| Dienst | Image / Build | Host-Port | Zweck |
|
| Dienst | Image / Build | Host-Port | Zweck |
|
||||||
|--------|---------------|-----------|-------|
|
|--------|---------------|-----------|-------|
|
||||||
| `web` | `apps/web/Dockerfile` (Basis) bzw. `git.vicolab.de/schalli/tessera-ctl/web:latest` (Prod) | 3000 | Next.js-Frontend (Portal, Dashboard) |
|
| `web` | `apps/web/Dockerfile` (Basis) bzw. `git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG}` (Prod; `beta` oder `live`, siehe Kapitel 9) | 3000 | Next.js-Frontend (Portal, Dashboard) |
|
||||||
| `api` | `apps/api/Dockerfile` (Basis) bzw. `git.vicolab.de/schalli/tessera-ctl/api:latest` (Prod) | 3001 | NestJS-Backend (REST-API, Prisma/PostgreSQL-Zugriff) |
|
| `api` | `apps/api/Dockerfile` (Basis) bzw. `git.vicolab.de/schalli/tessera-ctl/api:${IMAGE_TAG}` (Prod; `beta` oder `live`, siehe Kapitel 9) | 3001 | NestJS-Backend (REST-API, Prisma/PostgreSQL-Zugriff) |
|
||||||
| `db` | `postgres:16-alpine` | kein Host-Port | PostgreSQL-Datenbank |
|
| `db` | `postgres:16-alpine` | kein Host-Port | PostgreSQL-Datenbank |
|
||||||
|
|
||||||
Zusätzlich existiert `docker-compose.dev.yml` (Bind-Mounts für Live-Reload,
|
Zusätzlich existiert `docker-compose.dev.yml` (Bind-Mounts für Live-Reload,
|
||||||
@@ -159,6 +160,7 @@ Zugangsdaten.
|
|||||||
| `TESSERA_SMTP_HOST` / `_PORT` / `_SECURE` / `_USER` / `_PASSWORD` / `_FROM` | nein (aber ohne Host kein Mailversand) | Host leer, Port `587`, `_SECURE=false` | SMTP-Relay für ausgehende Mails (Passwort-Reset, Benachrichtigungen). |
|
| `TESSERA_SMTP_HOST` / `_PORT` / `_SECURE` / `_USER` / `_PASSWORD` / `_FROM` | nein (aber ohne Host kein Mailversand) | Host leer, Port `587`, `_SECURE=false` | SMTP-Relay für ausgehende Mails (Passwort-Reset, Benachrichtigungen). |
|
||||||
| `APP_URL` | empfohlen | `http://localhost:3001` (für `NEXT_PUBLIC_API_URL`) / `http://localhost:3000` (für `TESSERA_APP_URL`) | Öffentliche Basis-URL der Web-Oberfläche. Wird serverseitig u. a. für in E-Mails generierte Links verwendet (`TESSERA_APP_URL`). |
|
| `APP_URL` | empfohlen | `http://localhost:3001` (für `NEXT_PUBLIC_API_URL`) / `http://localhost:3000` (für `TESSERA_APP_URL`) | Öffentliche Basis-URL der Web-Oberfläche. Wird serverseitig u. a. für in E-Mails generierte Links verwendet (`TESSERA_APP_URL`). |
|
||||||
| `API_INTERNAL_URL` | fest verdrahtet | `http://api:3001` | Adresse, unter der `web` die API **innerhalb** des Docker-Netzes erreicht; dorthin schreibt Next.js die `/api-proxy/*`-Rewrites um. In der Regel nicht ändern. |
|
| `API_INTERNAL_URL` | fest verdrahtet | `http://api:3001` | Adresse, unter der `web` die API **innerhalb** des Docker-Netzes erreicht; dorthin schreibt Next.js die `/api-proxy/*`-Rewrites um. In der Regel nicht ändern. |
|
||||||
|
| `IMAGE_TAG` | empfohlen | `beta` | Welcher Kanal auf diesem Server läuft: `beta` (alle Neuerungen, alpha) oder `live` (nur freigegebene Versionen, tessera.ctl.de). Siehe Kapitel 9. |
|
||||||
|
|
||||||
Hinweis zu `NEXT_PUBLIC_API_URL`: Diese Variable wird beim Image-Build bereits fest
|
Hinweis zu `NEXT_PUBLIC_API_URL`: Diese Variable wird beim Image-Build bereits fest
|
||||||
auf `/api-proxy` gesetzt (`ENV NEXT_PUBLIC_API_URL=/api-proxy` in
|
auf `/api-proxy` gesetzt (`ENV NEXT_PUBLIC_API_URL=/api-proxy` in
|
||||||
@@ -191,7 +193,7 @@ docker compose -f docker-compose.prod.yml up -d --force-recreate api web
|
|||||||
**Verifizierte Falle:** `docker compose up -d` **ohne** `--force-recreate` ersetzt
|
**Verifizierte Falle:** `docker compose up -d` **ohne** `--force-recreate` ersetzt
|
||||||
einen bereits laufenden Container **nicht**, wenn Compose der Meinung ist, an der
|
einen bereits laufenden Container **nicht**, wenn Compose der Meinung ist, an der
|
||||||
Service-Definition habe sich nichts geändert – auch wenn `pull` gerade ein neues
|
Service-Definition habe sich nichts geändert – auch wenn `pull` gerade ein neues
|
||||||
Image unter demselben Tag (`:latest`) heruntergeladen hat. Der alte Container läuft
|
Image unter demselben Etikett (`beta` bzw. `live`) heruntergeladen hat. Der alte Container läuft
|
||||||
dann unverändert mit dem alten Code weiter, ohne Fehlermeldung. Das Deployment
|
dann unverändert mit dem alten Code weiter, ohne Fehlermeldung. Das Deployment
|
||||||
wirkt erfolgreich, ist es aber nicht. Dieser Fehler ist dem Team schon mehrfach
|
wirkt erfolgreich, ist es aber nicht. Dieser Fehler ist dem Team schon mehrfach
|
||||||
passiert. Deshalb: nach jedem `pull` **immer** `--force-recreate` verwenden (nur
|
passiert. Deshalb: nach jedem `pull` **immer** `--force-recreate` verwenden (nur
|
||||||
@@ -204,12 +206,15 @@ Erstellungszeitpunkt des zugehörigen Images vergleichen. Der Container muss
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker inspect -f '{{.State.StartedAt}}' tessera-api-1
|
docker inspect -f '{{.State.StartedAt}}' tessera-api-1
|
||||||
docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/api:latest
|
docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/api:beta
|
||||||
|
|
||||||
docker inspect -f '{{.State.StartedAt}}' tessera-web-1
|
docker inspect -f '{{.State.StartedAt}}' tessera-web-1
|
||||||
docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/web:latest
|
docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/web:beta
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(Auf dem Live-Server statt `:beta` jeweils `:live` einsetzen – das Etikett, das in
|
||||||
|
der `.env` als `IMAGE_TAG` steht, siehe Kapitel 9.)
|
||||||
|
|
||||||
(Container-Namen mit `docker compose -f docker-compose.prod.yml ps` prüfen, falls
|
(Container-Namen mit `docker compose -f docker-compose.prod.yml ps` prüfen, falls
|
||||||
sie auf dem Server abweichen.) Liegt `StartedAt` **vor** `Created` des Images, läuft
|
sie auf dem Server abweichen.) Liegt `StartedAt` **vor** `Created` des Images, läuft
|
||||||
noch die alte Version – dann `--force-recreate` nachholen.
|
noch die alte Version – dann `--force-recreate` nachholen.
|
||||||
@@ -317,8 +322,10 @@ docker compose -f docker-compose.prod.yml logs -f db
|
|||||||
|
|
||||||
**Gesunder Start sieht so aus:** `db` wird `healthy`, danach startet `api` und
|
**Gesunder Start sieht so aus:** `db` wird `healthy`, danach startet `api` und
|
||||||
protokolliert die durchgelaufenen Prisma-Migrationen sowie zuletzt
|
protokolliert die durchgelaufenen Prisma-Migrationen sowie zuletzt
|
||||||
`Tessera API running on port 3001` (aus `apps/api/src/main.ts`). Erst danach startet
|
`Tessera API running on port 3001` und direkt darunter eine Zeile wie
|
||||||
`web`, weil `depends_on: api: condition: service_healthy` das erzwingt.
|
`Tessera API v1.0.0 (live) abc1234` (beides aus `apps/api/src/main.ts`) – Version,
|
||||||
|
Kanal und Kurzkennung des Standes, der gerade läuft (siehe Kapitel 9). Erst danach
|
||||||
|
startet `web`, weil `depends_on: api: condition: service_healthy` das erzwingt.
|
||||||
|
|
||||||
| Symptom | Wahrscheinliche Ursache | Prüfen / Beheben |
|
| Symptom | Wahrscheinliche Ursache | Prüfen / Beheben |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -342,5 +349,173 @@ Berechtigungen des `act_runner`-Docker-Socket-Mounts) aufgesetzt wird, ist bewus
|
|||||||
nicht Teil dieses Dokuments – das steht vollständig in
|
nicht Teil dieses Dokuments – das steht vollständig in
|
||||||
[`docs/ci-cd-setup.md`](./ci-cd-setup.md). Die Grenze zwischen beiden Dokumenten:
|
[`docs/ci-cd-setup.md`](./ci-cd-setup.md). Die Grenze zwischen beiden Dokumenten:
|
||||||
Sobald ein neues Image lokal vorliegt oder in einer Registry verfügbar ist, beginnt
|
Sobald ein neues Image lokal vorliegt oder in einer Registry verfügbar ist, beginnt
|
||||||
dieses Betriebshandbuch (Kapitel 4); alles davor – wie das Image entsteht – gehört
|
dieses Betriebshandbuch (Kapitel 4 und 9); alles davor – wie das Image entsteht –
|
||||||
in das CI/CD-Runbook.
|
gehört in das CI/CD-Runbook.
|
||||||
|
|
||||||
|
## 9. Zwei Kanäle: Live und Beta
|
||||||
|
|
||||||
|
Seit September 2026 gibt es Tessera in zwei Ausgaben, die getrennt voneinander
|
||||||
|
laufen. Dieses Kapitel erklärt, was das bedeutet, welche Zeile auf welchem Server
|
||||||
|
stehen muss, wie eine Version freigegeben wird, wie ein dringender Fehler auf Live
|
||||||
|
behoben wird, und wie Sie jederzeit sehen, welche Fassung gerade läuft.
|
||||||
|
|
||||||
|
### Was ein Kanal ist
|
||||||
|
|
||||||
|
Ein Kanal ist eine Ausgabe von Tessera, die auf einem bestimmten Server läuft und
|
||||||
|
nach eigenen Regeln neue Stände bekommt. Es gibt zwei:
|
||||||
|
|
||||||
|
- **Beta** – alles Neue, sofort nach jeder Änderung. Läuft unter
|
||||||
|
`alpha.tessera.ctl.de`. Das Etikett (die Kennzeichnung des Docker-Images in der
|
||||||
|
Registry) heißt `beta`. Das ältere Etikett `latest` ist nur ein zweiter Name für
|
||||||
|
genau dasselbe Beta-Image; es bleibt vorerst bestehen, damit nichts kaputtgeht,
|
||||||
|
und kann später wegfallen.
|
||||||
|
- **Live** – nur freigegebene Versionen mit einer Nummer. Läuft unter
|
||||||
|
`tessera.ctl.de` auf dem neuen Server. Das Etikett heißt `live`; zusätzlich trägt
|
||||||
|
jede freigegebene Version ihre Nummer als eigenes Etikett (`v1.0.0`, `v1.0.1`, …),
|
||||||
|
damit man jederzeit auch einen älteren Stand gezielt holen kann.
|
||||||
|
|
||||||
|
Die Versionsnummer kommt aus der Freigabe (in Git heißt das „Tag“ – eine Markierung
|
||||||
|
an einem bestimmten Stand), nicht aus einer Datei im Code. Zwischen zwei Freigaben
|
||||||
|
zeigt die Beta eine Kennung wie `v1.0.0-12-gabc1234`: das bedeutet „12 Änderungen
|
||||||
|
nach Version 1.0.0, Stand abc1234“. Vor der allerersten Freigabe steht dort nur die
|
||||||
|
Kurzkennung des Standes (sieben Zeichen, z. B. `abc1234`).
|
||||||
|
|
||||||
|
### Die eine Zeile je Server
|
||||||
|
|
||||||
|
Welchen Kanal ein Server bekommt, entscheidet **eine einzige Zeile** in der Datei
|
||||||
|
`/opt/tessera/.env`:
|
||||||
|
|
||||||
|
- auf **alpha** (Beta): `IMAGE_TAG=beta`
|
||||||
|
- auf dem **neuen Live-Server**: `IMAGE_TAG=live`
|
||||||
|
|
||||||
|
Fehlt die Zeile ganz, nimmt die Compose-Datei von selbst `beta`. Für den Live-Server
|
||||||
|
ist die Zeile also Pflicht, sonst zieht er die Beta.
|
||||||
|
|
||||||
|
Weil `/opt/tessera` keine Arbeitskopie des Repositorys ist (siehe Kapitel 3,
|
||||||
|
„Konfigurationsdrift“), muss die Compose-Datei auf dem Server einmal von Hand
|
||||||
|
angepasst werden. Auf alpha ist das die Datei `/opt/tessera/docker-compose.prod.yml`
|
||||||
|
(die `.env` dort verweist mit `COMPOSE_FILE` auf sie). Zuerst eine Sicherung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/tessera
|
||||||
|
cp docker-compose.prod.yml docker-compose.prod.yml.bak.$(date +%Y%m%d)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann die zwei `image:`-Zeilen (eine beim Dienst `web`, eine beim Dienst `api`) auf
|
||||||
|
diese Form bringen – der einzige Unterschied zu heute ist das Ende der Zeile:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image: git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image: git.vicolab.de/schalli/tessera-ctl/api:${IMAGE_TAG:-beta}
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach wie in Kapitel 4:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --force-recreate api web
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweis: Die Vorlage `.env.prod.example` im Repository enthält die Zeile
|
||||||
|
`IMAGE_TAG` noch nicht. Wer eine neue `.env` aus der Vorlage anlegt, ergänzt die
|
||||||
|
Zeile von Hand.
|
||||||
|
|
||||||
|
### Eine Version freigeben
|
||||||
|
|
||||||
|
Das Freigeben erledigt Claude; Sie sagen nur „Version X freigeben“. Zur Einordnung,
|
||||||
|
was dabei passiert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout live
|
||||||
|
git merge --ff-only main
|
||||||
|
git tag -a vX.Y.Z -m "Tessera X.Y.Z"
|
||||||
|
git push origin live vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Der zweite Befehl übernimmt den Stand der Beta in den Live-Zweig. Wenn er sich
|
||||||
|
weigert, ist eine frühere Korrektur (siehe Hotfix, Schritt 5) noch nicht zurück in
|
||||||
|
`main` – dann wird erst das nachgeholt. Der Push löst die Pipeline zweimal aus: der
|
||||||
|
Zweig `live` wird nur geprüft, der Tag `vX.Y.Z` wird gebaut und als `live` und
|
||||||
|
`vX.Y.Z` abgelegt. Das dauert etwa vier bis sechs Minuten.
|
||||||
|
|
||||||
|
Danach spielen Sie die Version auf dem Live-Server ein – Kapitel 4 gilt unverändert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --force-recreate api web
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erstfreigabe v1.0.0:** Den Zweig `live` gibt es noch nicht. Er entsteht beim
|
||||||
|
ersten Mal aus `main` (`git checkout -b live main`), bekommt den Tag `v1.0.0` und
|
||||||
|
wird zusammen mit dem Tag gepusht. Das erfolgt, sobald der Knopf „Fehler melden“
|
||||||
|
eingebaut ist – nicht in diesem Durchlauf.
|
||||||
|
|
||||||
|
### Einen Fehler auf Live beheben (Hotfix)
|
||||||
|
|
||||||
|
Ein Hotfix ist eine kleine Korrektur, die auf Live landen muss, **ohne** die
|
||||||
|
Neuerungen der Beta mitzunehmen. Ablauf (Claude führt die Git-Schritte aus, Sie
|
||||||
|
spielen ein):
|
||||||
|
|
||||||
|
1. Den Live-Stand holen: `git checkout live && git pull`.
|
||||||
|
2. Einen Korrekturzweig `hotfix/<kurzer-name>` von `live` anlegen.
|
||||||
|
3. Die Korrektur machen und die Tests laufen lassen.
|
||||||
|
4. Nach `live` mergen, die nächste Nummer vergeben (`vX.Y.(Z+1)`, also z. B.
|
||||||
|
`v1.0.1` nach `v1.0.0`) und beides pushen: `git push origin live vX.Y.(Z+1)`.
|
||||||
|
Danach spielen Sie die Version auf dem Live-Server ein (Befehle wie oben).
|
||||||
|
5. Die Korrektur in die Beta übernehmen: `git checkout main && git merge live`.
|
||||||
|
Vorher wird geprüft, ob die Korrektur dort noch zusammenpasst (Konflikte, Tests),
|
||||||
|
dann `git push` – die Beta bekommt sie mit dem nächsten Pipeline-Lauf.
|
||||||
|
|
||||||
|
**Keine Datenbankänderung als Hotfix.** Der Grund in Alltagssprache:
|
||||||
|
Datenbankänderungen (Migrationen) tragen einen Zeitstempel im Namen und werden in
|
||||||
|
dieser Reihenfolge ausgeführt. Die Beta hat womöglich schon neuere Änderungen
|
||||||
|
eingespielt. Eine Hotfix-Änderung mit noch späterem Zeitstempel landet beim
|
||||||
|
Übernehmen in die Beta hinter Änderungen, die sie eigentlich nicht kennt – das ist
|
||||||
|
der eine Fall, der beim Zusammenführen still kaputtgehen kann. Braucht eine Korrektur
|
||||||
|
eine Datenbankänderung, wird sie als reguläre Version über `main` freigegeben.
|
||||||
|
|
||||||
|
### Woran Sie erkennen, welche Version läuft
|
||||||
|
|
||||||
|
Drei Wege, vom einfachsten zum genauesten:
|
||||||
|
|
||||||
|
1. **In der Oberfläche:** Unten in der Seitenleiste steht `v1.0.0 · Live` bzw.
|
||||||
|
`v1.0.0-12-gabc1234 · Beta`. Wenn Sie die Maus darüber halten, erscheinen die
|
||||||
|
Kurzkennung des Standes und die Version, die der Server meldet. Weichen
|
||||||
|
Oberfläche und Server voneinander ab, wurde nur einer der beiden Container neu
|
||||||
|
erstellt – dann Kapitel 4 anwenden (`--force-recreate api web`).
|
||||||
|
2. **Auf dem Server per Abfrage:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:3001/health/version
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Antwort enthält die Felder `version`, `channel` (`beta` oder `live`),
|
||||||
|
`commit` (Kurzkennung) und `buildTime` (wann das Image gebaut wurde).
|
||||||
|
3. **Im Protokoll:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml logs api | grep "Tessera API"
|
||||||
|
```
|
||||||
|
|
||||||
|
Zeigt die Startzeile `Tessera API v1.0.0 (live) abc1234` (siehe Kapitel 7).
|
||||||
|
|
||||||
|
### Den neuen Live-Server einrichten
|
||||||
|
|
||||||
|
Kapitel 2 gilt vollständig. Die Abweichungen gegenüber alpha:
|
||||||
|
|
||||||
|
- In der `.env` steht `IMAGE_TAG=live`.
|
||||||
|
- Eigene, neu erzeugte Geheimnisse: `JWT_SECRET`, `TESSERA_ENCRYPTION_KEY`,
|
||||||
|
`DB_PASSWORD` und das Admin-Passwort. Nichts davon von alpha übernehmen.
|
||||||
|
- Eine eigene, leere Datenbank. Die API legt beim ersten Start den ersten Admin an
|
||||||
|
(Kapitel 2, Schritt 4). Die alpha-Datenbank wird **nicht** kopiert – es sei denn,
|
||||||
|
das wird ausdrücklich gewünscht. In diesem Fall gilt Kapitel 6 (Wiederherstellung)
|
||||||
|
**und** der Live-Server muss denselben `TESSERA_ENCRYPTION_KEY` wie alpha
|
||||||
|
bekommen, sonst sind alle gespeicherten Zugangsdaten unbrauchbar.
|
||||||
|
- `APP_URL=https://tessera.ctl.de`.
|
||||||
|
- Der erste `pull` holt das Etikett `live`. Vor der Erstfreigabe v1.0.0 gibt es
|
||||||
|
dieses Etikett noch nicht – deshalb erst freigeben, dann installieren. Für einen
|
||||||
|
Probelauf davor kann vorübergehend `IMAGE_TAG=beta` stehen; danach auf `live`
|
||||||
|
umstellen und `pull` + `up -d --force-recreate api web` wiederholen.
|
||||||
|
|||||||
+81
-19
@@ -80,11 +80,24 @@ docker ps --filter name=gitea-runner
|
|||||||
|
|
||||||
### Gitea Secrets (fuer die CI-Pipeline)
|
### Gitea Secrets (fuer die CI-Pipeline)
|
||||||
|
|
||||||
In Gitea unter **Repository > Settings > Actions > Secrets** koennen Secrets
|
In Gitea unter **Repository > Settings > Actions > Secrets** werden die Secrets
|
||||||
fuer die Pipeline konfiguriert werden. Aktuell werden keine Secrets in der
|
fuer die Pipeline konfiguriert. Benoetigt wird genau eines:
|
||||||
Pipeline benoetigt, da Images lokal gebaut und deployed werden (kein Registry-Push).
|
|
||||||
|
|
||||||
Falls kuenftig Deploy-Pfade oder Credentials benoetigt werden:
|
| Secret | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| `REGISTRY_TOKEN` | Gitea-Zugangstoken (Access Token) mit Schreibrecht auf Pakete (`package: write`). Wird im Job `publish` fuer `docker login localhost:3002 --password-stdin` verwendet. |
|
||||||
|
|
||||||
|
Das Token erscheint nie im Log: es wird per `--password-stdin` uebergeben und
|
||||||
|
Gitea maskiert Secret-Werte in der Job-Ausgabe. Das Veroeffentlichungs-Skript
|
||||||
|
`.gitea/scripts/publish-images.sh` kennt das Token nicht; der Login bleibt im
|
||||||
|
Workflow.
|
||||||
|
|
||||||
|
Der Push geht ueber `localhost:3002` (Gitea laeuft auf demselben Rechner wie der
|
||||||
|
Runner), weil der Nginx Proxy Manager vor `git.vicolab.de` grosse Image-Blobs
|
||||||
|
blockt. Das Pullen auf den Servern laeuft ueber `git.vicolab.de`
|
||||||
|
(`docker-compose.prod.yml`).
|
||||||
|
|
||||||
|
Weitere Secrets bei Bedarf:
|
||||||
|
|
||||||
1. In Gitea **Settings > Actions > Secrets** den Secret anlegen
|
1. In Gitea **Settings > Actions > Secrets** den Secret anlegen
|
||||||
2. In `.gitea/workflows/ci.yml` ueber `${{ secrets.SECRET_NAME }}` referenzieren
|
2. In `.gitea/workflows/ci.yml` ueber `${{ secrets.SECRET_NAME }}` referenzieren
|
||||||
@@ -94,27 +107,62 @@ hardcoden.
|
|||||||
|
|
||||||
## 4. Pipeline-Ueberblick
|
## 4. Pipeline-Ueberblick
|
||||||
|
|
||||||
Die CI/CD-Pipeline (`.gitea/workflows/ci.yml`) wird bei jedem Push auf `main`
|
Die CI/CD-Pipeline (`.gitea/workflows/ci.yml`) laeuft bei jedem Push auf die
|
||||||
ausgefuehrt und besteht aus drei aufeinander aufbauenden Jobs:
|
Zweige `main` und `live` sowie bei jedem Tag `v*` (z. B. `v1.0.0`) und besteht
|
||||||
|
aus drei aufeinander aufbauenden Jobs:
|
||||||
|
|
||||||
1. **quality** -- Lint (Biome) und TypeScript Type-Check
|
1. **quality** -- Lint und TypeScript Type-Check (Lint ist derzeit ein Leerlauf,
|
||||||
|
siehe WINDOWS #35; der Type-Check ist echt)
|
||||||
2. **test** -- Vitest Unit- und Integrationstests
|
2. **test** -- Vitest Unit- und Integrationstests
|
||||||
3. **build-deploy** -- Docker Images bauen und Services neu starten
|
3. **publish** -- Docker Images mit Versionsstempel bauen und in die Gitea-Registry
|
||||||
|
veroeffentlichen
|
||||||
|
|
||||||
Ablauf: `quality` -> `test` -> `build-deploy` (jeder Job nur bei Erfolg des
|
Ablauf: `quality` -> `test` -> `publish` (jeder Job nur bei Erfolg des
|
||||||
vorherigen).
|
vorherigen). Der Job `publish` besteht aus drei Schritten: `actions/checkout@v4`
|
||||||
|
mit `fetch-depth: 0` (volle Historie samt Tags, sonst liefert `git describe`
|
||||||
|
nichts), Login in die Registry (siehe Abschnitt 3) und der Aufruf von
|
||||||
|
`.gitea/scripts/publish-images.sh`.
|
||||||
|
|
||||||
### Kein Registry-Push
|
### Zwei Kanaele: Etiketten je Anlass
|
||||||
|
|
||||||
Images werden **lokal auf dem Server gebaut** und nicht in eine Registry
|
Das Skript `.gitea/scripts/publish-images.sh` entscheidet allein anhand
|
||||||
gepusht (D-13). Da der Runner und die Applikation auf demselben Server laufen,
|
`GITHUB_REF`, ob und unter welchen Etiketten veroeffentlicht wird:
|
||||||
baut die Pipeline die Images direkt mit `docker compose build` und startet die
|
|
||||||
Services mit `docker compose up -d` neu.
|
|
||||||
|
|
||||||
Vorteile:
|
| Anlass | Kanal (`APP_CHANNEL`) | Etiketten in der Registry |
|
||||||
- Keine Registry-Infrastruktur noetig
|
|--------|----------------------|---------------------------|
|
||||||
- Schnellerer Deploy (kein Push/Pull ueber Netzwerk)
|
| Push auf `main` | `beta` | `beta` und `latest` (`latest` ist nur ein Alias fuer `beta` und entfaellt spaeter) |
|
||||||
- Einfachere Konfiguration
|
| Tag `vX.Y.Z` | `live` | `live` und `vX.Y.Z` |
|
||||||
|
| Push auf `live` ohne Tag | -- | keine; der Lauf prueft nur (`quality`, `test`), das Skript endet mit "nichts zu tun" |
|
||||||
|
|
||||||
|
Das Kanalmodell fuer den Betrieb (welcher Server welches Etikett zieht, Freigabe,
|
||||||
|
Hotfix) steht in `docs/anleitung-betrieb.md`, Kapitel 9.
|
||||||
|
|
||||||
|
### Versionsstempel
|
||||||
|
|
||||||
|
Das Skript berechnet vier Werte und gibt sie als `--build-arg` an beide
|
||||||
|
Dockerfiles (`apps/web/Dockerfile`, `apps/api/Dockerfile`):
|
||||||
|
|
||||||
|
| Build-Arg | Quelle |
|
||||||
|
|-----------|--------|
|
||||||
|
| `APP_VERSION` | `git describe --tags --always` (ohne Tag: kurzer Commit-SHA) |
|
||||||
|
| `APP_CHANNEL` | `beta` oder `live`, siehe Tabelle oben |
|
||||||
|
| `APP_COMMIT` | `git rev-parse --short HEAD` |
|
||||||
|
| `APP_BUILD_TIME` | `date -u`, ISO-Format |
|
||||||
|
|
||||||
|
Die API liest die Werte zur Laufzeit (`GET /health/version`, Startzeile im Log).
|
||||||
|
Das Web-Image bettet `NEXT_PUBLIC_APP_*` beim Build in das Browser-Bundle ein --
|
||||||
|
deshalb laeuft die `builder`-Stufe des Web-Images jetzt bei jedem Pipeline-Lauf
|
||||||
|
neu, die Laufzeit liegt eher bei 4-6 statt 2 Minuten.
|
||||||
|
|
||||||
|
Lokale Probe ohne Docker-Aufruf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweis: Die fruehere Entscheidung D-13 (Images lokal bauen, keine Registry) ist
|
||||||
|
ueberholt -- seit der Einfuehrung der Gitea-Registry werden Images gepusht und von
|
||||||
|
den Servern per `docker compose pull` geholt.
|
||||||
|
|
||||||
## 5. Sicherheitshinweise
|
## 5. Sicherheitshinweise
|
||||||
|
|
||||||
@@ -146,6 +194,12 @@ Workflow-Dateien in `.gitea/workflows/` werden direkt aus dem Repository
|
|||||||
geladen. Da nur vertrauenswuerdiger Code gepusht wird (D-11, Claude als
|
geladen. Da nur vertrauenswuerdiger Code gepusht wird (D-11, Claude als
|
||||||
einziger Committer), ist das Risiko einer manipulierten Pipeline minimal.
|
einziger Committer), ist das Risiko einer manipulierten Pipeline minimal.
|
||||||
|
|
||||||
|
Ein Tag-Push `v*` ist der Freigabe-Hebel fuer Live: wer ihn setzen darf, kann
|
||||||
|
das `live`-Etikett neu belegen. Heute hat nur das Konto `schalli` Schreibrecht
|
||||||
|
(0 Kollaborateure, keine Branch-Regeln). Kommen weitere Konten dazu, in Gitea
|
||||||
|
unter **Repository > Settings > Branches / Tags** eine Tag-Schutzregel fuer `v*`
|
||||||
|
und einen Branch-Schutz fuer `live` anlegen (T-KU1-04).
|
||||||
|
|
||||||
## 6. Fehlerbehebung
|
## 6. Fehlerbehebung
|
||||||
|
|
||||||
### Runner registriert sich nicht
|
### Runner registriert sich nicht
|
||||||
@@ -167,3 +221,11 @@ einziger Committer), ist das Risiko einer manipulierten Pipeline minimal.
|
|||||||
erreichbar ist
|
erreichbar ist
|
||||||
2. Docker Daemon Status pruefen: `docker info`
|
2. Docker Daemon Status pruefen: `docker info`
|
||||||
3. Disk Space pruefen: `df -h`
|
3. Disk Space pruefen: `df -h`
|
||||||
|
|
||||||
|
### Stempel zeigt `dev` oder nur eine Kurzkennung statt des Tags
|
||||||
|
|
||||||
|
1. Im Job `publish` pruefen, dass `actions/checkout@v4` mit `fetch-depth: 0`
|
||||||
|
auscheckt -- ohne Tags liefert `git describe --tags --always` nur den SHA
|
||||||
|
2. Pruefen, ob der Tag wirklich gepusht wurde: `git ls-remote --tags origin`
|
||||||
|
3. `dev` bedeutet: das Image wurde ohne Build-Args gebaut (lokal statt ueber
|
||||||
|
das Skript) -- das ist fuer lokale Builds normal
|
||||||
|
|||||||
@@ -4,3 +4,16 @@ export interface HealthResponse {
|
|||||||
status: string;
|
status: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Antwort von `GET /health/version` (quick-260914-ku1).
|
||||||
|
* `channel` ist in der Praxis `beta` | `live` | `dev`; die Wahrheit der
|
||||||
|
* Version ist der Git-Tag (`git describe`), nicht eine package.json.
|
||||||
|
*/
|
||||||
|
export interface VersionResponse {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
channel: string;
|
||||||
|
commit: string;
|
||||||
|
buildTime: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user