deploy: Git-Deploy per Tag — dev/betrieb/deploy.sh, Ablauf und einmalige Umstellung in git-deploy.md, Runbook §10; public/build wird versioniert

- deploy.sh: bricht ohne Änderung ab bei Abweichung im Checkout oder neuen
  Migrationen ohne --migrations; down, Checkout, RELEASE, Composer, Caches,
  queue:restart, FPM, up; Rollback = derselbe Aufruf mit dem vorherigen Tag
- git-deploy.md: Forgejo-Zugang, Umwandlung von ~/pressekonto.com in einen
  Checkout von v1.1.0 ohne Dateiänderung, Release lokal, Prüfungen, Rollback
- Assets werden lokal gebaut und committet, auf dem Server kein Node

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-06 10:12:09 +00:00
parent dd688e5a7a
commit 129de4fc76
4 changed files with 351 additions and 2 deletions

View file

@ -372,7 +372,10 @@ Newsroom-Übersicht, eine alte PM-Detailseite (lädt Bild + Anhang?), Firmenlogo
## 10. Betrieb nach Go-Live (Stand 20.07.2026 — System ist LIVE)
Live-Setup: Hetzner-Cloud-Server via **Ploi**, App unter `~/pressekonto.com`,
Deploys laufen per **SFTP-Datei-Upload** (kein Git-Deploy). Echte Domains
Deploys liefen bis `v1.1.0` per **SFTP-Datei-Upload**. **Ab `v1.1.1` per Git-Tag**
mit `dev/betrieb/deploy.sh`; Ablauf, einmalige Umstellung und Rollback in
`dev/betrieb/git-deploy.md`. Die SFTP-Checkliste unten gilt nur noch bis zur
Umstellung. Echte Domains
(pressekonto.com, presseecho.de, businessportal24.com) inkl. www-DNS geschaltet,
Stripe im Live-Mode, `legacy:verify` 0 failures. Migration/Cutover: erledigt,
Doku archiviert unter `dev/archive/optimize/cutover-runbook.md`.

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

@ -0,0 +1,145 @@
#!/usr/bin/env bash
#
# Deploy eines Git-Tags auf Live (Ablauf und Hintergrund: dev/betrieb/git-deploy.md).
#
# Aufruf im App-Verzeichnis auf dem Server:
#
# cd ~/pressekonto.com
# bash dev/betrieb/deploy.sh v1.2.0 # ohne neue Migrationen
# bash dev/betrieb/deploy.sh v1.2.0 --migrations # mit Migrationen, DB-Backup vorher!
#
# 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).
#
# Umgebung:
# PK_FPM_RELOAD Befehl zum Neuladen von PHP-FPM
# (Standard: echo "" | sudo -S service php8.4-fpm reload).
# Leer setzen, wenn FPM von Hand in Ploi neu geladen wird.
#
# Rollback: dasselbe Skript mit dem vorherigen Tag.
set -euo pipefail
# Git ersetzt diese Datei beim Checkout. Bash liest Skripte stückweise, deshalb
# läuft der Deploy aus einer Kopie.
if [[ -z "${PK_DEPLOY_COPY:-}" ]]; then
copy="$(mktemp)"
cp "$0" "$copy"
PK_DEPLOY_COPY="$copy" exec bash "$copy" "$@"
fi
trap 'rm -f "$PK_DEPLOY_COPY"' EXIT
tag="${1:-}"
shift || true
run_migrations=0
clear_schedule=0
for option in "$@"; do
case "$option" in
--migrations) run_migrations=1 ;;
--clear-schedule) clear_schedule=1 ;;
*) echo "Unbekannte Option: $option" >&2; exit 2 ;;
esac
done
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Aufruf: bash dev/betrieb/deploy.sh vX.Y.Z [--migrations] [--clear-schedule]" >&2
exit 2
fi
if [[ ! -f artisan || ! -d .git ]]; then
echo "Im App-Verzeichnis aufrufen (artisan und .git fehlen hier)." >&2
exit 2
fi
step() { printf '\n==> %s\n' "$*"; }
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)"
echo "Live: $previous → Ziel: $tag"
if [[ "$(git rev-parse HEAD)" == "$(git rev-parse "$tag^{commit}")" ]]; then
echo "$tag ist bereits ausgecheckt. Nichts zu tun."
exit 0
fi
new_migrations="$(git diff --name-only --diff-filter=A HEAD "$tag" -- database/migrations)"
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
trap 'echo; echo "!! Deploy abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rollback: bash dev/betrieb/deploy.sh $previous" >&2; rm -f "$PK_DEPLOY_COPY"' ERR
step "Wartungsmodus"
php artisan down --retry=60
step "Checkout $tag"
git checkout --quiet --detach "$tag"
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
if [[ -d public/vendor/livewire ]]; then
php artisan vendor:publish --tag=livewire: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 php8.4-fpm reload'
fpm_reload="${PK_FPM_RELOAD-$default_fpm_reload}"
if [[ -n "$fpm_reload" ]]; then
bash -c "$fpm_reload"
else
echo "PHP-FPM jetzt in Ploi neu laden (Server ▸ PHP ▸ Restart), danach Enter."
read -r
fi
step "Wartungsmodus aus"
php artisan up
step "Version"
php artisan about --only=pressekonto
trap - ERR
echo
echo "Deploy $tag fertig (vorher $previous). Weiter mit den Prüfungen in dev/betrieb/git-deploy.md, Abschnitt D."

202
dev/betrieb/git-deploy.md Normal file
View file

@ -0,0 +1,202 @@
# Git-Deploy
**Stand:** 06.10.2026 · **Status:** vorbereitet, Umstellung steht aus (erstes Release über Git: `v1.1.1`)
Bis `v1.1.0` gingen Deploys als Paket per SFTP auf den Server
([`deploy-v1.1.0.md`](deploy-v1.1.0.md)). Künftig ist `~/pressekonto.com` ein
Git-Checkout, und ausgeliefert wird ein Tag aus `origin`.
## Grundsätze
- **Remotes:** `origin` = `ssh://git@forge.adametz.media:222/kadmin/pressekonto.git`
(Forgejo auf Hetzner, privat). Von dort holt der Live-Server.
`NAS` = `git.adametz.media` im lokalen Netz, nur als Backup.
- **Ausgeliefert wird nur ein Tag** (`vX.Y.Z`, SemVer wie bisher), nie ein Branch-Stand.
- **Deploy nur von Hand** per SSH mit [`deploy.sh`](deploy.sh). Ploi-„Quick Deploy"
und Push-Webhooks bleiben aus.
- **Assets werden lokal gebaut und committet.** `public/build` ist versioniert
(2,4 MB, 18 Dateien). Auf dem Server gibt es kein Node.
- **Auf dem Server wird nichts von Hand geändert.** Das Skript bricht ab, sobald
der Checkout vom Tag abweicht. Hotfixes laufen ebenfalls über Commit, Tag und Deploy.
- **Kein `git clean` auf Live.** `.env`, `storage/`, `vendor/`, `auth.json`,
`RELEASE` und `public/storage` sind ignoriert und bleiben unberührt.
- **Kein Zero-Downtime-Modus mit Release-Verzeichnissen.** `storage/app` (Archiv,
Quarantäne, Exporte) bleibt an seinem Ort. Deploy im selben Verzeichnis mit
`php artisan down`, wie bisher.
- `dev/`, `tests/` und die Doku liegen damit auch auf dem Server, aber außerhalb
von `public/`. Ausgeliefert werden sie nicht.
## A — Einmalig: Zugang vom Server zu Forgejo
1. Auf dem Server (Benutzer der App) prüfen: `git --version`.
2. Deploy-Key erzeugen und in Forgejo **nur lesend** am Repository eintragen
(Repository ▸ Einstellungen ▸ Deploy-Schlüssel):
```bash
ssh-keygen -t ed25519 -f ~/.ssh/forge_pressekonto -N "" -C "pressekonto-live"
cat ~/.ssh/forge_pressekonto.pub
```
3. `~/.ssh/config` ergänzen:
```
Host forge.adametz.media
Port 222
User git
IdentityFile ~/.ssh/forge_pressekonto
IdentitiesOnly yes
```
4. Host-Key aufnehmen und den Fingerprint mit dem in Forgejo angezeigten vergleichen:
```bash
ssh-keyscan -p 222 forge.adametz.media >> ~/.ssh/known_hosts
ssh-keygen -lf <(ssh-keyscan -p 222 forge.adametz.media 2>/dev/null)
```
5. Test: `git ls-remote ssh://git@forge.adametz.media:222/kadmin/pressekonto.git`
zeigt `refs/tags/v1.1.0`.
6. FPM-Neustart ohne Ploi testen: `echo "" | sudo -S service php8.4-fpm reload`.
Geht das nicht, beim Deploy `PK_FPM_RELOAD=` setzen. Das Skript wartet dann,
bis FPM in Ploi neu geladen ist.
## B — Einmalig: Live-Verzeichnis in einen Checkout umwandeln
Live läuft auf `v1.1.0`. Das Verzeichnis wird an Ort und Stelle zum Checkout
dieses Tags. Dabei ändert sich zunächst keine Datei. Git zeigt nur, wo der
Server vom Tag abweicht.
7. Dateibackup wie bei v1.1.0:
```bash
cd ~ && tar czf backup-vor-git.tgz --exclude=./storage --exclude=./node_modules -C ~/pressekonto.com .
```
8. Repository anlegen und auf `v1.1.0` setzen, **ohne** Dateien anzufassen:
```bash
cd ~/pressekonto.com
git init -q
git remote add origin ssh://git@forge.adametz.media:222/kadmin/pressekonto.git
git fetch --tags origin
git update-ref --no-deref HEAD "$(git rev-parse 'v1.1.0^{commit}')"
git read-tree HEAD
```
9. Abweichungen sichten:
```bash
git status --porcelain | grep -v -E '^.. (dev/|docs/|_docs/|tests/|\.agents/|\.codex/|\.cursorrules|\.devcontainer/|\.github/|\.mcp\.json|AGENTS\.md|CLAUDE\.md|boost\.json|docker-compose\.yml|phpstan|phpunit\.xml|pint\.json)'
```
Herausgefiltert sind die Entwicklerdateien, die nie im Paket waren. Was danach
übrig bleibt:
| Zeile | Bedeutung | Vorgehen |
|---|---|---|
| ` M datei` | Datei auf Live weicht vom Tag ab | `git diff -- datei` ansehen. Fehlt die Änderung im Repository, erst lokal nachziehen, neuer Tag, dann weiter. Sonst ist es ein Paketrest und wird überschrieben. |
| ` D datei` | Datei fehlt auf Live | Meist Entwicklerdatei oder ein nie hochgeladener Teil. Einordnen, der Checkout legt sie an. |
| `?? datei` | nur auf Live, nicht ignoriert | **Nicht löschen.** Nach `~/vor-git/` verschieben, wenn sie nicht gebraucht wird. Wird sie gebraucht, gehört sie in die `.gitignore` (lokal, neuer Tag). |
10. Wenn nichts Unerklärtes übrig ist, auf den Tag auschecken:
```bash
git checkout -q -f --detach v1.1.0
git status --porcelain # muss leer sein
```
Der Code ist danach inhaltlich derselbe wie vorher. Ein Neustart ist nicht nötig.
## C — Release lokal (jedes Mal)
11. `main` sauber, volle Suite und Larastan grün (wie bisher).
12. Assets bauen und mitcommitten:
```bash
npm ci && npm run build
git add public/build
git diff --cached --quiet public/build || git commit -m "build: Assets für vX.Y.Z"
```
Vite benennt Dateien nach ihrem Inhalt. Ohne Frontend-Änderung entsteht kein Diff.
13. Tag setzen und auf beide Remotes pushen:
```bash
git tag -a vX.Y.Z -m "vX.Y.Z — <Inhalt>"
git push origin main --follow-tags
git push NAS main --follow-tags
```
## D — Deploy auf Live (jedes Mal)
14. Bringt der Tag Migrationen mit: **DB-Backup** ziehen (`ARCHIVE_KEY` getrennt gesichert).
15. Neue `.env`-Werte laut Release-Notiz vorher eintragen.
16. Deploy:
```bash
cd ~/pressekonto.com
bash dev/betrieb/deploy.sh vX.Y.Z # ohne Migrationen
bash dev/betrieb/deploy.sh vX.Y.Z --migrations # mit Migrationen
```
Das Skript macht Folgendes:
- Es prüft, dass der Checkout sauber ist, und holt die Tags.
- Es zeigt neue Migrationen an. Ohne `--migrations` bricht es dann ab, **bevor** etwas verändert ist.
- Danach in dieser Reihenfolge: `down`, Checkout, `RELEASE`, `composer install`, gegebenenfalls `migrate`, Livewire-Assets, Caches, `queue:restart`, FPM, `up`, Version.
`--clear-schedule` setzt zusätzlich die `withoutOverlapping`-Sperren zurück.
Das ist nur nötig, wenn sich Sperrdauern im Scheduler geändert haben.
Darf eine Migration länger auf Sperren warten:
`DB_LOCK_WAIT_TIMEOUT=300 bash dev/betrieb/deploy.sh vX.Y.Z --migrations`.
17. Prüfungen:
- `/health` mit Token liefert `"version":"vX.Y.Z"`.
- Admin ▸ Reports ▸ Betriebsüberwachung ist grün.
- `storage/logs` zeigt keine neuen Fehler.
- Smoke-Test aus dem Runbook (Abschnitt 8).
- Hat sich der Scheduler geändert: `php artisan schedule:list`.
Schlägt ein Schritt fehl, bleibt die Seite im Wartungsmodus. Das Skript nennt
den Rollback-Befehl.
## E — Rollback
```bash
cd ~/pressekonto.com
bash dev/betrieb/deploy.sh <vorheriger Tag>
```
Liegt die Abbruchstelle nach dem Checkout, ist der Checkout zwar sauber, aber
`vendor/` und die Caches können halb erneuert sein. Der Rollback-Aufruf baut
beides für den alten Tag neu auf. Migrationen sind additiv. Der alte Code läuft
mit den neuen Spalten, ein `migrate:rollback` gibt es auf Live nicht. Nur bei
Datenproblemen das DB-Backup einspielen und danach Runbook Abschnitt 9 (Archiv) befolgen.
## F — Erste Umstellung mit `v1.1.1`
`v1.1.1` dient nur dazu, den neuen Weg zu testen. Das Release enthält keine
Migration und keine Fachänderung.
| Schritt | Inhalt |
|---|---|
| lokal | `/public/build` aus der `.gitignore`; `deploy.sh`, diese Datei und Runbook §10 committen; Doku-Commits seit `v1.1.0`; Build-Commit; Tag `v1.1.1`; Push auf beide Remotes |
| Server | A (Zugang), B (Umwandlung auf `v1.1.0`), dann `bash dev/betrieb/deploy.sh v1.1.1` |
| Prüfung | D 17. Zusätzlich `git status --porcelain`: Bis `v1.1.0` war `public/build` ignoriert, ab `v1.1.1` ist es versioniert. Alte Build-Reste auf Live (z. B. `public/build/admin/`, `public/build/assets/`, `public/build/manifest.json` aus der stillgelegten `vite.config.js`) erscheinen erst jetzt als `??`. Nach `~/vor-git/` verschieben, sonst bricht der nächste Deploy an der Abweichungsprüfung ab. |
| danach | `~/deploy-v1.1.0.tgz`, `~/backup-vor-v1.1.0.tgz` und nach ein paar stabilen Tagen `~/backup-vor-git.tgz` löschen |
## Später: Changelog
Der Changelog-Entwurf
([Decision-Update](../2026-10/Decision-Update_Changelog_Automatisierung_2026-10-04.md),
Stufe 2) dockt in `deploy.sh` nach `up` an. Ein Artisan-Befehl liest die Commits
zwischen dem vorherigen und dem neuen Tag. Die liegen jetzt auf dem Server vor.
Daraus legt er die Entwürfe an. Der LLM-Schritt läuft als Queue-Job, damit ein
Ausfall des Anbieters keinen Deploy abbricht.
## Protokoll
| Datum | Schritt | Ergebnis |
|---|---|---|
| 06.10.2026 | Vorbereitung | `origin` auf Forgejo (Hetzner) umgestellt, NAS als zweites Remote, Push erledigt. `deploy.sh` gegen ein simuliertes Repository geprüft: Abbruch bei neuen Migrationen ohne Option, Deploy, wiederholter Aufruf, Rollback, unbekannter Tag, Fehler mitten im Lauf. Die Umwandlung eines bestehenden Verzeichnisses (B) wurde ebenso simuliert: Abweichungen werden sichtbar, `.env` und ungetrackte Dateien bleiben, ignorierte Builds werden beim Checkout überschrieben. |
| 06.10.2026 | `v1.1.1` lokal | `/public/build` versioniert. Alte Build-Reste von 2025 lokal entfernt (nicht mehr referenziert). Suite 2.316 grün, einer übersprungen (parallel, 96 s); Larastan ohne Meldung. Build-Commit und Tag `v1.1.1` gesetzt, Push auf `origin`/`NAS` nach Freigabe. |