Files
alpenwerk-hr/.github/workflows/deploy.yml
Maximilian Stubhan fc0989debb
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m52s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m13s
Deploy / Migrationen und Container (push) Has been cancelled
Deploy from a push, and start keeping track of migrations
A push to master now builds and restarts the application on the server:
a Gitea Actions workflow on a self-hosted runner writes .env from the
repository secrets, applies pending migrations, rebuilds the compose
stack against the host's Docker daemon, and waits for the container's
healthcheck before calling the run green. Without that last step a
deploy counts as successful the moment the container *starts*, even if
the app inside it dies immediately.

Switching migrations on automatically turned up something that had to be
fixed first: supabase_migrations.schema_migrations did not exist at all.
Every one of the 65 migrations was unrecorded, because they have been
applied by hand all along. An automatic `db push` would therefore have
replayed all 65 against the live database — initial_schema and the OM
cutover included. The database was checked against a spread of
migrations first (it is at head), then baselined: all 65 recorded as
applied without executing them.

The runner is scripts/migrate.mjs rather than the Supabase CLI. It needs
only `pg`, which the project already ships, instead of downloading a CLI
whose version drifts independently of this repository; and it does one
thing — the missing files, in order, each in its own transaction — where
`db push` also diffs schemas and may do more than that. Bookkeeping goes
in the same table in the same shape the CLI uses, so `supabase db push`
from a workstation still works and still skips what already ran.

The workflow lives in .github/workflows, not .gitea/. Gitea reads
.gitea/workflows and falls back to .github/workflows only when the
former is absent — creating .gitea/ would have silently switched off
ci.yml, with the run simply never appearing.

Verified: both workflow files parse; the secret check names what is
missing and refuses; values starting with "-" or containing "=" survive
being written to .env; and the runner was exercised against the real
database with a throwaway migration — applied once, skipped on a second
run, and on a deliberate syntax error rolled back whole, recording
nothing. Both probes were removed; the count is back to 65.

Not verified: nothing has run on an actual Gitea runner — none is
registered yet. DEPLOYMENT.md §5 covers registering one.

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

143 lines
6.3 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.)
on:
push:
branches: [master]
# 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
runs-on: self-hosted
steps:
- 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