Git-Deploy per Tag, Betriebsüberwachung und Release-Seeder für den Livegang

- dev/betrieb/deploy.sh: Deploy nur per Tag, mit --seed (Seeder im Wartungsmodus)
  und --initial (Vorschau/Live einmalig an Ort und Stelle auf Git umstellen)
- dev/betrieb/vorschau-auffrischen.sh: Live-DB und Live-Medien in die Vorschau (Generalprobe)
- InteriorReleaseSeeder: alle Inhaltsschritte des Relaunchs in einem Lauf, idempotent;
  Displays, Playlists und Mediathek bleiben unberührt
- DisplayInteriorBrandingSeeder überschreibt keinen inzwischen gepflegten Claim mehr
- Monitoring: GlitchTip (Server + Browser), /health mit Token, Scheduler-Herzschlag,
  Alarme per Mail und ntfy, Version in about und /health
- public/build versioniert; .gitignore: macOS-Muster Icon traf resources/views/flux/icon/
- Runbook: dev/betrieb/README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 08:56:23 +00:00
parent fcb10be120
commit b5ed756d67
98 changed files with 3947 additions and 82 deletions

294
dev/betrieb/deploy.sh Executable file
View file

@ -0,0 +1,294 @@
#!/usr/bin/env bash
#
# Deploy eines Git-Tags auf Vorschau oder Live (B2in). Grundlage: Baukasten
# dev/git-deploy-monitoring, Ablauf für B2in: dev/betrieb/README.md.
#
# Aufruf im App-Verzeichnis auf dem Server:
#
# bash dev/betrieb/deploy.sh v2.0.0 # ohne neue Migrationen
# bash dev/betrieb/deploy.sh v2.0.0 --migrations # mit Migrationen, DB-Backup vorher!
# bash dev/betrieb/deploy.sh v2.0.0 --migrations --seed=InteriorReleaseSeeder
#
# Optionen:
# --migrations Neue Migrationen ausführen. Ohne diese Option bricht das
# Skript ab, sobald der Tag neue Migrationen mitbringt.
# --clear-schedule php artisan schedule:clear-cache (alte withoutOverlapping-Sperren).
# --seed=<Klasse> Seeder im Wartungsmodus ausführen (nach den Migrationen,
# vor den Caches) – für Inhaltsschritte, die die Release-Notiz nennt.
# --initial Einmalig: ein Verzeichnis ohne Git (bisher SFTP/Archiv) an Ort und
# Stelle zum Checkout des Tags machen. Sichert vorher den Code nach
# ~/<verzeichnis>-vor-git-<zeit>.tgz, zeigt Dateien, die es nur auf dem
# Server gibt, und verschiebt sie nach Rückfrage nach ~/<verzeichnis>-vor-git-<zeit>/.
# Verlangt --migrations (vorher DB-Backup).
#
# Umgebung (optional):
# DEPLOY_FPM_RELOAD Befehl zum Neuladen von PHP-FPM (Standard siehe unten).
# Leer setzen, wenn FPM von Hand im Server-Panel neu geladen
# wird — dann wartet das Skript auf Enter. Nie „Restart“:
# der entfernt kurz den Socket, Nginx liefert 502.
# DEPLOY_PHP_FPM_SERVICE systemd-Name des FPM-Dienstes (Standard php8.4-fpm).
# DEPLOY_REPOSITORY Repository für --initial (Standard: Host-Alias forge-b2in aus ~/.ssh/config).
#
# Exit-Codes: 0 fertig, 1 abgebrochen (siehe Ausgabe), 2 falscher Aufruf,
# 3 live, aber kein Queue-Worker hat den Test-Job abgeholt.
#
# Rollback: dasselbe Skript mit dem vorherigen Tag. Nach --initial: Sicherung
# zurückspielen (Pfad steht in der Ausgabe) und DB-Backup einspielen.
set -euo pipefail
# ---- Projekt-Einstellungen --------------------------------------------------
DEPLOY_SCRIPT="dev/betrieb/deploy.sh" # Pfad dieses Skripts im Repository
PHP_FPM_SERVICE="${DEPLOY_PHP_FPM_SERVICE:-php8.4-fpm}" # systemd-Name des FPM-Dienstes
ABOUT_SECTION="b2in" # AboutCommand::add('B2in', …) im AppServiceProvider
QUEUE_PROBE=0 # B2in stellt keine Jobs in die Queue (Mails gehen sofort raus)
REPOSITORY="${DEPLOY_REPOSITORY:-forge-b2in:kadmin/b2in.git}"
# -----------------------------------------------------------------------------
# Git ersetzt diese Datei beim Checkout. Bash liest Skripte stückweise, deshalb
# läuft der Deploy aus einer Kopie.
if [[ -z "${DEPLOY_COPY:-}" ]]; then
copy="$(mktemp)"
cp "$0" "$copy"
DEPLOY_COPY="$copy" exec bash "$copy" "$@"
fi
trap 'rm -f "$DEPLOY_COPY"' EXIT
tag="${1:-}"
shift || true
run_migrations=0
clear_schedule=0
initial=0
seeders=()
for option in "$@"; do
case "$option" in
--migrations) run_migrations=1 ;;
--clear-schedule) clear_schedule=1 ;;
--initial) initial=1 ;;
--seed=*)
seeder="${option#--seed=}"
if [[ ! "$seeder" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
echo "Ungültiger Seeder: $seeder" >&2; exit 2
fi
seeders+=("$seeder") ;;
*) echo "Unbekannte Option: $option" >&2; exit 2 ;;
esac
done
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Aufruf: bash $DEPLOY_SCRIPT vX.Y.Z [--migrations] [--seed=<Klasse>] [--clear-schedule] [--initial]" >&2
exit 2
fi
if [[ ! -f artisan ]]; then
echo "Im App-Verzeichnis aufrufen (artisan fehlt hier)." >&2
exit 2
fi
step() { printf '\n==> %s\n' "$*"; }
if [[ "$initial" -eq 1 ]]; then
if [[ -d .git ]]; then
echo "--initial nur für ein Verzeichnis ohne Git – hier gibt es .git schon." >&2
exit 2
fi
if [[ "$run_migrations" -ne 1 ]]; then
echo "--initial verlangt --migrations (vorher DB-Backup ziehen)." >&2
exit 2
fi
stamp="$(date +%Y-%m-%d-%H%M)"
site="$(basename "$PWD")"
backup="$HOME/$site-vor-git-$stamp.tgz"
parked="$HOME/$site-vor-git-$stamp"
step "Sicherung des Codes nach $backup (ohne storage/, node_modules/)"
# Exit 1 = „Datei hat sich beim Lesen geändert“ (Logs o. Ä.) – die Sicherung ist trotzdem brauchbar.
tar czf "$backup" --exclude=./storage --exclude=./node_modules --exclude=./public/storage . || [[ $? -eq 1 ]]
ls -lh "$backup"
step "Git einrichten, Tag $tag holen"
git init -q
git remote add origin "$REPOSITORY"
if ! git fetch --quiet --tags origin || ! git rev-parse --quiet --verify "refs/tags/$tag^{commit}" > /dev/null; then
rm -rf .git
echo "Tag $tag nicht abrufbar (Deploy-Key? Tag gepusht?). .git wieder entfernt, nichts verändert." >&2
exit 1
fi
git update-ref --no-deref HEAD "$(git rev-parse "$tag^{commit}")"
git read-tree HEAD
# Ignorier-Regeln des Tags gelten schon jetzt (die .gitignore im Verzeichnis ist noch die alte).
git show "$tag:.gitignore" > .git/info/exclude
changed="$(git status --porcelain --untracked-files=no | wc -l | tr -d ' ')"
server_only="$(git -c core.quotePath=false ls-files --others --exclude-standard)"
echo "$changed Dateien weichen vom Tag ab oder fehlen – der Checkout ersetzt sie."
if [[ -n "$server_only" ]]; then
echo
echo "Diese $(echo "$server_only" | wc -l | tr -d ' ') Dateien gibt es nur auf dem Server (nicht im Tag, nicht ignoriert):"
echo "$server_only" | sed 's/^/ /' | head -200
echo
echo "Sie werden nach $parked/ verschoben (Pfade bleiben erhalten). Wird eine davon gebraucht,"
echo "jetzt abbrechen und sie ins Repository oder in die .gitignore aufnehmen (neuer Tag)."
fi
read -r -p "Weiter mit dem Deploy von $tag? [ja/nein] " answer
if [[ "$answer" != "ja" ]]; then
rm -rf .git
echo "Abgebrochen. .git wieder entfernt, nichts verändert (Sicherung bleibt liegen)." >&2
exit 1
fi
previous="vor-git (Sicherung: $backup)"
new_migrations="(Umstellung: alle offenen Migrationen)"
else
if [[ ! -d .git ]]; then
echo "Hier gibt es kein .git – für die einmalige Umstellung --initial verwenden." >&2
exit 2
fi
step "Arbeitsverzeichnis prüfen"
drift="$(git status --porcelain)"
if [[ -n "$drift" ]]; then
echo "Das Arbeitsverzeichnis weicht vom ausgecheckten Stand ab:" >&2
echo "$drift" >&2
echo "Erst klären (git diff), dann deployen. Nichts wurde verändert." >&2
exit 1
fi
step "Tags von origin holen"
git fetch --quiet --tags origin
if ! git rev-parse --quiet --verify "refs/tags/$tag^{commit}" > /dev/null; then
echo "Tag $tag gibt es auf origin nicht." >&2
exit 1
fi
previous="$(cat RELEASE 2>/dev/null || git describe --tags --always)"
if [[ "$(git rev-parse HEAD)" == "$(git rev-parse "$tag^{commit}")" ]]; then
echo "$tag ist bereits ausgecheckt. Nichts zu tun."
exit 0
fi
fi
echo "Bisher: $previous → Ziel: $tag"
for build in b2in b2a stileigentum style2own portal; do
if ! git cat-file -e "$tag:public/build/$build/manifest.json" 2> /dev/null; then
echo "public/build/$build/manifest.json fehlt im Tag $tag – Assets lokal bauen und committen. Nichts wurde verändert." >&2
exit 1
fi
done
if [[ "$initial" -ne 1 ]]; then
new_migrations="$(git diff --name-only --diff-filter=A HEAD "$tag" -- database/migrations)"
fi
if [[ -n "$new_migrations" ]]; then
echo "Neue Migrationen:"
echo "$new_migrations" | sed 's/^/ /'
if [[ "$run_migrations" -ne 1 ]]; then
echo "Abbruch: DB-Backup ziehen und mit --migrations erneut aufrufen. Nichts wurde verändert." >&2
exit 1
fi
fi
if [[ "$initial" -eq 1 ]]; then
trap 'echo; echo "!! Umstellung abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rückweg: Verzeichnis aus $backup wiederherstellen (vorher .git entfernen), DB-Backup einspielen." >&2; rm -f "$DEPLOY_COPY"' ERR
else
trap 'echo; echo "!! Deploy abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rollback: bash $DEPLOY_SCRIPT $previous" >&2; rm -f "$DEPLOY_COPY"' ERR
fi
step "Wartungsmodus"
php artisan down --retry=60
step "Checkout $tag"
if [[ "$initial" -eq 1 ]]; then
if [[ -n "$server_only" ]]; then
while IFS= read -r file; do
mkdir -p "$parked/$(dirname "$file")"
mv "$file" "$parked/$file"
done <<< "$server_only"
echo "Nur-Server-Dateien verschoben nach $parked/"
fi
git checkout --quiet --force --detach "$tag"
: > .git/info/exclude
else
git checkout --quiet --detach "$tag"
fi
echo "$tag" > RELEASE
step "Composer"
composer install --no-dev --optimize-autoloader --no-interaction
if [[ -n "$new_migrations" ]]; then
step "Migrationen"
php artisan migrate --force
fi
for seeder in "${seeders[@]}"; do
step "Seeder $seeder"
php artisan db:seed --class="$seeder" --force
done
step "Öffentliche Assets"
if [[ ! -e public/storage ]]; then
php artisan storage:link
fi
if [[ -d public/vendor/livewire ]]; then
php artisan vendor:publish --tag=livewire:assets --force
fi
# Statisch ausgelieferte Flux-Skripte passend zur installierten Flux-Version
if [[ -d public/flux ]]; then
php artisan flux:publish-assets --force
fi
step "Caches"
php artisan config:cache
php artisan route:cache
php artisan view:clear
php artisan view:cache
php artisan event:cache
if [[ "$clear_schedule" -eq 1 ]]; then
php artisan schedule:clear-cache
fi
step "Worker und PHP-FPM"
php artisan queue:restart
default_fpm_reload="echo '' | sudo -S service $PHP_FPM_SERVICE reload"
fpm_reload="${DEPLOY_FPM_RELOAD-$default_fpm_reload}"
if [[ -n "$fpm_reload" ]]; then
bash -c "$fpm_reload"
else
echo "PHP-FPM jetzt im Server-Panel neu laden (Reload, nicht Restart), danach Enter."
read -r
fi
step "Wartungsmodus aus"
php artisan up
step "Version"
if [[ -n "$ABOUT_SECTION" ]]; then
php artisan about --only="$ABOUT_SECTION"
else
cat RELEASE
fi
trap - ERR
# Läuft ein Worker? `queue:restart` oben beendet ihn; neu starten muss ihn
# Supervisor. Läuft Supervisor nicht, steht die Queue still, ohne dass es
# jemand merkt.
if [[ "$QUEUE_PROBE" -eq 1 ]]; then
step "Queue-Worker"
if ! php artisan monitoring:queue-probe --wait=60; then
echo
echo "!! Deploy $tag ist live, aber KEIN Queue-Worker läuft." >&2
echo "!! Jetzt Supervisor und den Worker im Server-Panel prüfen — sonst bleiben Jobs und Mails liegen." >&2
exit 3
fi
fi
echo
echo "Deploy $tag fertig (vorher $previous). Weiter mit den Prüfungen aus der Deploy-Anleitung."