name: CI on: push: branches: [master, main] pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: check: name: Lint, Typen, Tests, Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - name: Lint run: npm run lint - name: Typecheck run: npm run typecheck # Fängt die Abweichung, die `tsc` nicht sehen kann: lib/types.ts wird von # Hand gepflegt, eine Migration mit einer neuen Spalte lässt sie also # stillschweigend veralten. - name: Schema/Typen-Abgleich run: npm run types:check - name: Unit- und Komponententests run: npm test # Ohne Umgebungsvariablen: seit dem Wegfall der Browser-Anbindung wird # zur Bauzeit nichts mehr aus der Umgebung gelesen, und das Abbild ist # für jede Umgebung dasselbe. - name: Build run: npm run build migrationen: name: Migrationen auf leerer Datenbank runs-on: ubuntu-latest # Derselbe Stand wie im Betrieb. Ein eigener Dienst statt einer fremden # Plattform — die Datenbank gehört seit dem Umzug zum Projekt. services: postgres: image: postgres:17-alpine env: POSTGRES_PASSWORD: ci POSTGRES_DB: alpenwerk ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 20 env: MIGRATE_DATABASE_URL: postgresql://postgres:ci@postgres:5432/alpenwerk DATABASE_SSL: "false" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci # Das Abbild des Runners bringt keinen Postgres-Client mit; der # Schritt darunter ruft aber psql. Ohne das bricht er mit # "psql: command not found" ab, bevor die erste Datei laeuft. - name: psql bereitstellen run: | apt-get update -qq apt-get install -y -qq --no-install-recommends postgresql-client psql --version # Was das Schema von der Umgebung erwartet: die Anwendungsrolle ohne # BYPASSRLS, zwei Erweiterungen und die Attrappe des `auth`-Schemas, ohne # die sich die Migrationen von Juni 2026 nicht abspielen lassen. - name: Rollen, Erweiterungen, auth-Attrappe env: PGPASSWORD: ci APP_DB_PASSWORD: ci-anwendungsrolle run: | for f in deploy/db-init/*.sql; do echo "── $f" psql -v ON_ERROR_STOP=1 -h postgres -U postgres -d alpenwerk -f "$f" done # Der eigentliche Zweck dieses Jobs: jede Migration muss sich auf einer # leeren Datenbank abspielen lassen. Eine, die das nicht kann, fällt hier # auf — und nicht beim Wiederherstellen einer Sicherung oder beim # Aufsetzen einer neuen Umgebung. - name: Migrationen einspielen run: node scripts/migrate.mjs # Zweiter Lauf: die Buchführung muss greifen. Meldet er etwas anderes als # „nichts anzuwenden", verbucht der Läufer nicht richtig — und ein # Ausrollen spielte Migrationen doppelt ein. - name: Zweiter Lauf ist ein Nichts run: | ausgabe=$(node scripts/migrate.mjs) echo "$ausgabe" echo "$ausgabe" | grep -q "^Nichts anzuwenden" \ || { echo "Der zweite Lauf wollte erneut anwenden — die Buchführung greift nicht."; exit 1; } # Dass die Migrationen durchlaufen, heisst nicht, dass das Ergebnis # stimmt. Bis hierher prüft dieser Job nur, dass keine Datei abbricht — # und genau daran ist ein Fehler vorbeigekommen: vier Tabellen aus dem # August (Kostenstellen, On-/Offboarding) standen ohne Zeilenschutz da, # weil sie sich auf den Ereignis-Trigger `ensure_rls` verliessen, den # eine spätere Migration erst anlegt. Alle Dateien liefen sauber durch, # die Policies standen im Katalog, und ausgewertet wurde keine davon. # # Deshalb wird ab hier der **Zustand** geprüft, nicht der Durchlauf. # Nachgezogen hat das 20260908100000; dort steht dieselbe Gegenprobe. # Sie läuft aber nur einmal, beim Anwenden jener Datei. Der Schritt hier # läuft bei jedem Push gegen eine frisch aufgebaute Datenbank und fängt # deshalb auch die *nächste* Tabelle, die es wieder vergisst. - name: Zugriffsschutz nach dem Lauf env: PGPASSWORD: ci run: | set -euo pipefail frage() { psql -v ON_ERROR_STOP=1 -h postgres -U postgres -d alpenwerk -At -c "$1" } # relkind in ('r','p'): gewöhnliche und partitionierte Tabellen. ohne_rls=$(frage " select coalesce(string_agg(c.relname, ', ' order by c.relname), '') from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r', 'p') and not c.relrowsecurity") # RLS ohne Policy ist nicht offen, sondern leer — kein Leck, aber # eine Tabelle, aus der nie etwas zurückkommt. ohne_policy=$(frage " select coalesce(string_agg(c.relname, ', ' order by c.relname), '') from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r', 'p') and not exists (select 1 from pg_policy p where p.polrelid = c.oid)") # Das Netz selbst: fehlt oder schläft es, ist die nächste neue # Tabelle wieder ungeschützt. netz=$(frage " select count(*) from pg_event_trigger where evtname = 'ensure_rls' and evtenabled <> 'D'") fehler=0 if [ -n "$ohne_rls" ]; then echo "Ohne Row Level Security: $ohne_rls" fehler=1 fi if [ -n "$ohne_policy" ]; then echo "Mit Row Level Security, aber ohne jede Policy: $ohne_policy" fehler=1 fi if [ "$netz" != "1" ]; then echo "Der Ereignis-Trigger ensure_rls fehlt oder ist abgeschaltet." fehler=1 fi if [ "$fehler" -ne 0 ]; then echo echo "Jede Tabelle in public braucht RLS und mindestens eine Policy." echo "Nachziehen in einer neuen Migration, nicht in einer bereits angewendeten." exit 1 fi echo "Zugriffsschutz vollstaendig: jede Tabelle mit RLS und Policy, ensure_rls aktiv." # Die Integrationstests brauchen einen befüllten Bestand; der Seed lag # in der abgelösten Umgebung und ist noch nicht nachgezogen (siehe # README, offene Punkte). Bis dahin laufen sie gegen eine erreichbare # Datenbank von Hand, nicht hier.