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 }} MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_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 MIGRATE_DATABASE_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: MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }} run: node scripts/migrate.mjs --dry-run - name: Migrationen anwenden env: MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_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