Files
alpenwerk-hr/.github/workflows/deploy.yml
Maximilian Stubhan 77d9a95f7f
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m25s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m9s
Run the database in a container of our own
Supabase was only ever the host: the application has talked to PostgreSQL
directly through pg/Kysely for a while. So the move is mostly about
supplying what the platform used to supply.

Proved before building anything. All 65 migrations replay onto an empty
database, and the result matches production exactly — 183 columns, 25
policies, 68 indexes, 84 constraints, identical sets, no diff. The only
function missing from the rebuild turned out to matter, see below.

What the platform supplied, deploy/db-init now does:

  - alpenwerk_app, explicitly NOBYPASSRLS. The whole access model is 21
    RLS policies; a role that bypasses them would leave everything
    working while showing too much, and nobody would notice.
  - pgcrypto and pg_trgm. uuid-ossp was available on Supabase but is
    used nowhere — no column default, no function calls uuid_generate_*.
  - anon, authenticated and service_role as NOLOGIN placeholders. No
    policy names them; they only carry grants the platform handed out,
    and a data dump referencing them would fail to restore without them.
  - A stub `auth` schema. The end state needs none of it — checked: no
    foreign key, no policy, no column default refers to it. The June
    2026 migrations do, and rewriting those would be falsifying history;
    they describe what was true then.

The gap the comparison found: rls_auto_enable() and the ensure_rls event
trigger existed only in the running database, created by hand, in no
migration. That is the net which forces RLS on every newly created
table — the reason a forgotten policy yields an empty table instead of
an open one. A rebuild from migrations would silently not have had it:
everything works, and the next new table is unprotected. Now a migration
(20260819100000), verified by creating a table on the rebuild and
confirming RLS came on by itself.

Data moves separately, via scripts/umzug-von-supabase.sh: schema from
the migrations, then pg_dump --data-only --disable-triggers for the rows.
Without --disable-triggers every foreign key trips over load order. RLS
does not interfere — none of the 19 tables uses FORCE ROW LEVEL
SECURITY, so the owner writes through. The dump is deliberately left on
disk afterwards.

psql and node come from two `tools`-profile services rather than being
installed on the host, so the server needs nothing but Docker. The db
service publishes no port at all — reachable only inside the compose
network.

SUPABASE_DB_URL is renamed MIGRATE_DATABASE_URL, since after this it
describes something else entirely; the old name still works so existing
.env files keep running. Both were exercised, as was the error when
neither is set.

The deploy workflow is set to manual-only. Its preconditions were never
met — no secrets, and whether the job container can reach the host's
Docker daemon is untested — and failing on every push teaches people to
ignore red runs. It also needs updating for the new database service
before it could work at all.

Not verified: none of this has run in an actual container. There is no
Docker daemon on this machine. What is verified is the part that
decides whether it can work — the schema, on a real empty database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:34:08 +02:00

192 lines
9.0 KiB
YAML

name: Deploy
# Warum diese Datei unter .github/workflows liegt und nicht unter .gitea/:
#
# Gitea Actions liest `.gitea/workflows`, **und nur wenn dieses Verzeichnis
# fehlt**, ersatzweise `.github/workflows`. Ein neu angelegtes `.gitea/`
# würde also ci.yml stillschweigend abschalten — der Lauf verschwände
# einfach, ohne Fehlermeldung. Solange beide Dateien hier liegen, sieht Gitea
# beide. (Auf GitHub bliebe dieser Workflow hängen: es gibt dort keinen
# Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf
# git.elycon.solutions.)
# Nur von Hand auslösbar (Actions → Deploy → „Run workflow").
#
# Lief einmal bei jedem Push auf master. Das ist zurückgenommen: die
# Voraussetzungen dafür sind nicht erfüllt — es sind keine Secrets hinterlegt,
# und ob der Job-Container den Docker-Dienst des Hosts überhaupt erreicht, ist
# ungeprüft. Bei jedem Push zu scheitern erzieht dazu, rote Läufe zu
# übersehen; dann lieber gar nicht starten.
#
# Nach dem Umzug auf den eigenen db-Container stimmt hier ausserdem noch
# nicht alles: die Secrets unten kennen die neuen Werte (POSTGRES_PASSWORD,
# APP_DB_PASSWORD) nicht, und Migrationen laufen jetzt über den Dienst
# `migrate` statt über einen eigenen Node-Schritt. Wer das wieder scharf
# schaltet, muss beides nachziehen.
on:
workflow_dispatch:
# Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander
# fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose
# up` abgeschnitten zu werden ist der eine Zustand, den man nicht will.
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
name: Migrationen und Container
# `ubuntu-latest`, weil der vorhandene Runner (elycon-runner-01) genau
# diese Bezeichnung anbietet — neben `docker`. Ein Label, das kein Runner
# führt, lässt den Lauf wortlos in der Warteschlange stehen: kein Fehler,
# keine Meldung, nur „Waiting".
runs-on: ubuntu-latest
steps:
# ── Vorprüfung ──────────────────────────────────────────────────
#
# Dieser Job läuft in einem Container, den der Runner startet — nicht
# auf dem Server selbst. Ob er den Docker-Dienst des **Hosts** erreicht,
# hängt davon ab, wie der Runner konfiguriert ist; von aussen ist das
# nicht zu sehen. Statt es zu raten, wird es hier gemessen und im
# Fehlerfall gesagt, was fehlt — sonst scheitert der Lauf erst drei
# Schritte später an einer Meldung, die niemandem weiterhilft.
- name: Erreicht dieser Job den Docker-Dienst des Servers?
run: |
set -u
if ! command -v docker >/dev/null 2>&1; then
echo 'Im Job-Container gibt es kein "docker".'
echo
echo "Zwei Wege:"
echo " a) Im Runner (config.yaml) ein Abbild verwenden, das die Docker-CLI"
echo " mitbringt, und den Socket durchreichen — siehe DEPLOYMENT.md §5."
echo " b) Statt lokal per SSH auf den Server ausrollen."
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "Docker-CLI vorhanden, aber kein Dienst erreichbar."
echo
echo "Dem Job-Container fehlt der Socket des Hosts. In der config.yaml des"
echo "Runners:"
echo " container:"
echo " options: -v /var/run/docker.sock:/var/run/docker.sock"
echo " valid_volumes: [/var/run/docker.sock]"
echo "Danach den Runner neu starten. Alternative: Deploy per SSH."
exit 1
fi
docker compose version
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
# Nur was der Migrationsläufer braucht (`pg`), nicht der ganze Baum:
# gebaut wird die Anwendung im Container, nicht hier.
- name: Abhängigkeiten
run: npm ci --omit=dev --ignore-scripts
# ── .env aus den Gitea-Secrets ──────────────────────────────────
#
# Nicht die .env vom Server lesen: der Job läuft in einem eigenen
# Container, und dessen Dateisystem ist nicht das des Hosts. Die Werte
# kommen deshalb aus den Repository-Secrets und werden hier
# zusammengesetzt. Sie landen in keinem Abbild — docker compose reicht
# sie zur Laufzeit an den Container weiter.
- name: .env schreiben
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL }}
AUTH_MICROSOFT_ENTRA_ID_ID: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ID }}
AUTH_MICROSOFT_ENTRA_ID_SECRET: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_SECRET }}
AUTH_MICROSOFT_ENTRA_ID_ISSUER: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ISSUER }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
run: |
set -euo pipefail
fehlend=""
for name in DATABASE_URL SUPABASE_DB_URL AUTH_SECRET AUTH_URL \
AUTH_MICROSOFT_ENTRA_ID_ID AUTH_MICROSOFT_ENTRA_ID_SECRET \
AUTH_MICROSOFT_ENTRA_ID_ISSUER CRON_SECRET; do
eval "wert=\${$name:-}"
[ -n "$wert" ] || fehlend="$fehlend $name"
done
if [ -n "$fehlend" ]; then
echo "Diese Secrets fehlen im Repository (Settings → Actions → Secrets):$fehlend"
exit 1
fi
# printf statt echo: ein Wert, der mit - beginnt, wäre sonst ein Schalter.
{
printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET"
printf 'AUTH_URL=%s\n' "$AUTH_URL"
printf 'AUTH_MICROSOFT_ENTRA_ID_ID=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ID"
printf 'AUTH_MICROSOFT_ENTRA_ID_SECRET=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_SECRET"
printf 'AUTH_MICROSOFT_ENTRA_ID_ISSUER=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ISSUER"
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
} > .env
chmod 600 .env
# ── Migrationen ─────────────────────────────────────────────────
#
# Vor dem Neustart, nicht danach: der neue Code erwartet das neue
# Schema. Umgekehrt liefe die neue Anwendung kurz gegen das alte und
# fiele über fehlende Spalten.
#
# Erst zeigen, was ansteht — das steht dann im Protokoll des Laufs, auch
# wenn danach etwas schiefgeht.
- name: Ausstehende Migrationen zeigen
env:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
run: node scripts/migrate.mjs --dry-run
- name: Migrationen anwenden
env:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
run: node scripts/migrate.mjs
# ── Container ───────────────────────────────────────────────────
#
# Läuft gegen den Docker-Dienst des Hosts (der Runner-Container hat
# dessen Socket eingehängt). Der Projektname wird ausdrücklich gesetzt:
# sonst leitet ihn Compose vom Verzeichnisnamen ab, und der ist im
# Arbeitsverzeichnis des Runners ein anderer als bei der ersten
# Installation von Hand — es entstünde ein zweiter Stapel daneben,
# während der alte weiterläuft.
- name: Bauen und starten
env:
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
run: |
set -euo pipefail
docker compose build
docker compose up -d --remove-orphans
# ── Nachweis ────────────────────────────────────────────────────
#
# Ohne diesen Schritt gilt ein Deploy als erfolgreich, sobald der
# Container *gestartet* ist — auch wenn die Anwendung darin sofort
# abstürzt. Gewartet wird auf den Healthcheck aus dem Dockerfile
# (GET /login), nicht auf „läuft".
- name: Warten, bis die Anwendung antwortet
env:
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
run: |
set -euo pipefail
for versuch in $(seq 1 30); do
zustand=$(docker compose ps --format '{{.Health}}' app | head -1)
case "$zustand" in
healthy) echo "Gesund nach $versuch Versuchen."; exit 0 ;;
unhealthy) echo "Container meldet unhealthy."; break ;;
esac
sleep 4
done
echo "Anwendung ist nicht gesund geworden. Letzte Ausgaben:"
docker compose ps
docker compose logs --tail 80 app
exit 1
- name: Aufräumen
if: always()
run: rm -f .env