Files
alpenwerk-hr/.github/workflows/deploy.yml
Maximilian Stubhan d574d3c9d6
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m28s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m11s
Deploy / Migrationen und Container (push) Failing after 4s
Aim the deploy at the runner that exists
The workflow asked for a `self-hosted` label. No runner on the instance
offers one, so the run would have sat in "Waiting" forever — no error, no
message, nothing to notice. The existing global runner elycon-runner-01
offers `docker` and `ubuntu-latest`, so both workflows now ask for
`ubuntu-latest`, the same label ci.yml already used.

That correction exposed a second thing the first version glossed over.
act_runner starts a container per job; mounting the Docker socket into
the *runner* does not put it in the *job*. Whether this job can reach the
host's daemon depends on the runner's config.yaml, which is not visible
from here — and the runner is global, so changing it affects every
repository on the instance, not just this one.

Rather than guess, the workflow now measures it in its first step and
fails with the fix if it cannot: which config lines to add for the
socket, or that SSH is the other way. Without that, the run would have
died three steps later on a message nobody could act on.

Both branches of the check were exercised: docker absent prints the
first message, docker present with no reachable daemon the second.

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

180 lines
8.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
# `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