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>
192 lines
9.0 KiB
YAML
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
|