feat(publish): Python-basierter publish-apk Workflow

This commit is contained in:
Jens Reinemann 2026-05-18 12:08:12 +02:00
parent 09e01dff00
commit 39956cc7d9
3 changed files with 365 additions and 22 deletions

View file

@ -2,7 +2,15 @@
description: "publish Neue App-Version bauen und auf dem VPS veröffentlichen. Bumpt die Version automatisch, baut die APK, lädt sie auf den Server hoch, verifiziert QR-Code-Seite + Update-API und committet den Version-Bump."
name: "publish"
agent: agent
tools: [read, search, execute/runInTerminal, execute/sendToTerminal, execute/getTerminalOutput, edit]
tools:
[
read,
search,
execute/runInTerminal,
execute/sendToTerminal,
execute/getTerminalOutput,
edit,
]
---
Lies **zuerst** die Publish-Skill-Datei `.github/skills/publish/SKILL.md` vollständig mit `read_file`.
@ -22,12 +30,13 @@ Führe danach den Publish-Workflow durch:
## Schritt 2 Publish-Skript ausführen
```powershell
& ".github/skills/publish/publish-apk.ps1"
python ".github/skills/publish/publish-apk.py"
# oder mit explizitem versionName:
& ".github/skills/publish/publish-apk.ps1" -VersionName "2.0"
python ".github/skills/publish/publish-apk.py" --version-name "2.0"
```
Das Skript erledigt vollautomatisch:
1. versionCode +1 in `app/build.gradle.kts`
2. `./gradlew assembleDebug`
3. APK per SCP auf VPS hochladen
@ -38,6 +47,7 @@ Das Skript erledigt vollautomatisch:
Verwende `mode=sync` mit `timeout=300000`.
**Voraussetzungen prüfen** (falls Fehler auftreten):
- SSH-Agent: `ssh-add -l`
- Token: `$env:BOLLWERK_ADMIN_TOKEN` muss gesetzt sein
@ -46,6 +56,7 @@ Verwende `mode=sync` mit `timeout=300000`.
## Schritt 3 Ergebnis berichten
Berichte kurz:
- Neue Version (versionCode + versionName)
- Build-Status (✅ / ❌)
- VPS-Deployment (✅ / ❌)

View file

@ -109,17 +109,17 @@ Invoke-WebRequest -Uri "https://bollwerk.online/" -UseBasicParsing | Select-Obje
## Automatisiertes Skript
Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert den **gesamten** Release-Workflow (Version bumpen, bauen, hochladen, API-Call, verifizieren, committen):
Das Skript `publish-apk.py` in diesem Skill-Ordner automatisiert den **gesamten** Release-Workflow (Version bumpen, bauen, hochladen, API-Call, verifizieren, committen):
```powershell
# Alles automatisch (versionCode +1, versionName bleibt):
& ".github/skills/publish/publish-apk.ps1"
python ".github/skills/publish/publish-apk.py"
# Mit neuem versionName:
& ".github/skills/publish/publish-apk.ps1" -VersionName "2.0"
python ".github/skills/publish/publish-apk.py" --version-name "2.0"
# Nur hochladen (APK bereits gebaut, kein Push):
& ".github/skills/publish/publish-apk.ps1" -SkipBuild -SkipPush
python ".github/skills/publish/publish-apk.py" --skip-build --skip-push
```
**Voraussetzungen:**
@ -129,14 +129,14 @@ Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert den **gesamten
**Parameter:**
| Parameter | Pflicht | Default | Beschreibung |
| -------------- | ------- | ------------- | --------------------------------------------------- |
| `-VersionName` | nein | aktueller | Neuer versionName (z.B. "2.0"); sonst unverändert |
| `-VersionCode` | nein | aktuell + 1 | Expliziter versionCode; sonst automatisch +1 |
| `-ApkPath` | nein | debug-APK | Pfad zur APK-Datei |
| `-SkipBuild` | nein | false | Gradle-Build überspringen |
| `-SkipVerify` | nein | false | Verifizierung nach dem Deploy überspringen |
| `-SkipPush` | nein | false | Git-Push überspringen (nur lokaler Commit) |
| Parameter | Pflicht | Default | Beschreibung |
| ---------------- | ------- | ----------- | ------------------------------------------------- |
| `--version-name` | nein | aktueller | Neuer versionName (z.B. "2.0"); sonst unverändert |
| `--version-code` | nein | aktuell + 1 | Expliziter versionCode; sonst automatisch +1 |
| `--apk-path` | nein | debug-APK | Pfad zur APK-Datei |
| `--skip-build` | nein | false | Gradle-Build überspringen |
| `--skip-verify` | nein | false | Verifizierung nach dem Deploy überspringen |
| `--skip-push` | nein | false | Git-Push überspringen (nur lokaler Commit) |
---
@ -145,8 +145,8 @@ Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert den **gesamten
| Datei | Beschreibung |
| -------------------------------------------------------- | ------------------------------------------ |
| `app/build.gradle.kts` (L18-19) | `versionCode` / `versionName` |
| `server/src/main/resources/application.conf` | Server-Version-Defaults + Env-Var-Override |
| `server/src/main/kotlin/.../store/VersionStore.kt` | Persistente Version in `data/version.json` |
| `server/src/main/resources/application.conf` | Server-Version-Defaults + Env-Var-Override |
| `server/src/main/kotlin/.../store/VersionStore.kt` | Persistente Version in `data/version.json` |
| `server/src/main/kotlin/.../routes/VersionRoutes.kt` | Homepage + `/api/version` + POST-Endpoint |
| `server/src/main/kotlin/.../plugins/Routing.kt` | `staticFiles("/static", File("data"))` |
| `app/src/main/java/.../usecase/CheckForUpdateUseCase.kt` | Update-Prüfung in der App |
@ -156,11 +156,11 @@ Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert den **gesamten
## Environment-Variablen (VPS)
| Variable | Pflicht | Beschreibung |
| ------------------------------- | ------- | --------------------------------------------------------- |
| `BOLLWERK_ADMIN_TOKEN` | ja | Bearer-Token für `POST /api/admin/version` (min. 32 Zeichen) |
| `BOLLWERK_APP_VERSION_CODE` | nein | Fallback-VersionCode (nur wenn `data/version.json` fehlt) |
| `BOLLWERK_APP_VERSION_NAME` | nein | Fallback-VersionName (nur wenn `data/version.json` fehlt) |
| Variable | Pflicht | Beschreibung |
| --------------------------- | ------- | ------------------------------------------------------------ |
| `BOLLWERK_ADMIN_TOKEN` | ja | Bearer-Token für `POST /api/admin/version` (min. 32 Zeichen) |
| `BOLLWERK_APP_VERSION_CODE` | nein | Fallback-VersionCode (nur wenn `data/version.json` fehlt) |
| `BOLLWERK_APP_VERSION_NAME` | nein | Fallback-VersionName (nur wenn `data/version.json` fehlt) |
---

332
.github/skills/publish/publish-apk.py vendored Normal file
View file

@ -0,0 +1,332 @@
# -*- coding: utf-8 -*-
"""
Vollstaendiger Publish-Workflow: Version bumpen, bauen, auf VPS deployen, committen.
Schritte:
1. versionCode automatisch erhoehen (aus build.gradle.kts)
2. APK bauen (./gradlew assembleDebug) ueberspringbar mit --skip-build
3. APK per SCP auf VPS hochladen
4. Server-Version per API aktualisieren (kein Container-Neustart noetig)
5. Verifizieren ueberspringbar mit --skip-verify
6. git commit + push ueberspringbar mit --skip-push
Rollback-Strategie:
- Fehler vor/waehrend Upload -> build.gradle.kts wird automatisch zurueckgesetzt
- Fehler nach Upload (API) -> APK liegt schon oben; kein Rollback, Recovery-Hinweis
- Fehler bei Verify/Git -> Deployment war erfolgreich; nur Warnung, kein Abbruch
Verwendung:
python publish-apk.py
python publish-apk.py --version-name 2.0
python publish-apk.py --skip-build --skip-push
"""
import argparse
import json
import os
import platform
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
GRADLEW = "gradlew.bat" if platform.system() == "Windows" else "./gradlew"
# ---------------------------------------------------------------------------
# Konfiguration
# ---------------------------------------------------------------------------
VPS = "root@195.246.231.210"
REMOTE_DIR = "/opt/bollwerk"
SERVER_URL = "https://bollwerk.online"
BUILD_GRADLE = "app/build.gradle.kts"
# ---------------------------------------------------------------------------
# Farb-Hilfsfunktionen (ANSI, funktioniert in Windows Terminal / PowerShell 7)
# ---------------------------------------------------------------------------
RESET = "\033[0m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
CYAN = "\033[96m"
GRAY = "\033[90m"
def ok(msg): print(f"{GREEN}[OK] {msg}{RESET}", flush=True)
def fail(msg): print(f"{RED}[!!] {msg}{RESET}", flush=True)
def skip(msg): print(f"{GRAY}[--] {msg}{RESET}", flush=True)
def step(msg): print(f"\n{YELLOW}{msg}{RESET}", flush=True)
def warn(msg): print(f"{YELLOW}[??] {msg}{RESET}", flush=True)
def info(msg): print(f"{GRAY} {msg}{RESET}", flush=True)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def run(cmd, check=True):
"""Fuehrt einen Shell-Befehl aus und gibt den Return-Code zurueck."""
result = subprocess.run(cmd, shell=True)
if check and result.returncode != 0:
raise RuntimeError(f"Befehl fehlgeschlagen (Exit {result.returncode}): {cmd}")
return result.returncode
def rollback_gradle(original_content: str):
print(f"{YELLOW}[ROLLBACK] build.gradle.kts wird zurueckgesetzt...{RESET}", flush=True)
try:
with open(BUILD_GRADLE, "w", encoding="utf-8") as f:
f.write(original_content)
print(f"{YELLOW}[ROLLBACK] build.gradle.kts zurueckgesetzt.{RESET}", flush=True)
except Exception as e:
print(f"{RED}[ROLLBACK FEHLGESCHLAGEN] Bitte build.gradle.kts manuell korrigieren: {e}{RESET}", flush=True)
def post_json(url: str, body: dict, token: str, timeout: int = 20):
"""HTTP POST mit JSON-Body und Bearer-Token. Gibt status_code zurueck."""
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status
def get_json(url: str, timeout: int = 15):
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def get_status(url: str, timeout: int = 10):
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status
# ---------------------------------------------------------------------------
# Argument-Parsing
# ---------------------------------------------------------------------------
def parse_args():
parser = argparse.ArgumentParser(description="Bollwerk APK Publish-Workflow")
parser.add_argument("--version-name", default="", help="Neue versionName (z.B. '1.8')")
parser.add_argument("--version-code", type=int, default=0, help="Expliziter versionCode")
parser.add_argument("--apk-path", default="app/build/outputs/apk/debug/app-debug.apk")
parser.add_argument("--skip-build", action="store_true")
parser.add_argument("--skip-verify", action="store_true")
parser.add_argument("--skip-push", action="store_true")
return parser.parse_args()
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
args = parse_args()
# --- Pre-Check: Admin-Token ---
admin_token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "")
if not admin_token:
fail("BOLLWERK_ADMIN_TOKEN ist nicht gesetzt.")
info("Bitte setzen: $env:BOLLWERK_ADMIN_TOKEN = 'dein-token'")
sys.exit(1)
# --- Pre-Check: build.gradle.kts lesen ---
if not os.path.exists(BUILD_GRADLE):
fail(f"Datei nicht gefunden: {BUILD_GRADLE}")
sys.exit(1)
with open(BUILD_GRADLE, "r", encoding="utf-8") as f:
original_content = f.read()
code_match = re.search(r'versionCode\s*=\s*(\d+)', original_content)
name_match = re.search(r'versionName\s*=\s*"([^"]+)"', original_content)
if not code_match:
fail(f"versionCode nicht in {BUILD_GRADLE} gefunden.")
sys.exit(1)
if not name_match:
fail(f"versionName nicht in {BUILD_GRADLE} gefunden.")
sys.exit(1)
current_code = int(code_match.group(1))
current_name = name_match.group(1)
new_code = args.version_code if args.version_code > 0 else current_code + 1
new_name = args.version_name if args.version_name else current_name
if new_code <= current_code:
fail(f"Neuer versionCode ({new_code}) muss groesser als aktueller ({current_code}) sein.")
sys.exit(1)
# --- Pre-Check: SSH-Agent ---
rc = subprocess.run("ssh-add -l", shell=True, capture_output=True).returncode
if rc != 0:
fail("SSH-Agent hat keinen Key. Bitte ausfuehren: ssh-add C:\\Users\\JensR\\.ssh\\id_ed25519")
sys.exit(1)
ok("Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)")
print(f"\n{CYAN}=== Publish APK v{new_name} (build {new_code}) ==={RESET}", flush=True)
print(f"{GRAY} {current_name} ({current_code}) -> {new_name} ({new_code}){RESET}", flush=True)
# -------------------------------------------------------------------------
# Schritt 0: build.gradle.kts patchen
# -------------------------------------------------------------------------
patched = re.sub(
rf'versionCode\s*=\s*{current_code}\b',
f'versionCode = {new_code}',
original_content,
)
if args.version_name:
patched = re.sub(
r'versionName\s*=\s*"[^"]+"',
f'versionName = "{new_name}"',
patched,
)
try:
with open(BUILD_GRADLE, "w", encoding="utf-8") as f:
f.write(patched)
ok(f"build.gradle.kts aktualisiert (versionCode={new_code}, versionName={new_name})")
except Exception as e:
fail(f"build.gradle.kts konnte nicht geschrieben werden: {e}")
sys.exit(1)
# -------------------------------------------------------------------------
# Schritt 1: Build
# -------------------------------------------------------------------------
if args.skip_build:
skip("Build uebersprungen (--skip-build)")
else:
step("[1/4] APK bauen...")
rc = subprocess.run(f"{GRADLEW} assembleDebug", shell=True).returncode
if rc != 0:
fail(f"Build fehlgeschlagen (Exit {rc})")
rollback_gradle(original_content)
sys.exit(1)
ok("APK gebaut")
if not os.path.exists(args.apk_path):
fail(f"APK nicht gefunden nach Build: {args.apk_path}")
rollback_gradle(original_content)
sys.exit(1)
# -------------------------------------------------------------------------
# Schritt 2: APK hochladen
# -------------------------------------------------------------------------
step("[2/4] APK hochladen -> VPS...")
try:
rc = subprocess.run(
f'ssh -o ConnectTimeout=15 {VPS} "mkdir -p {REMOTE_DIR}/data"',
shell=True,
).returncode
if rc != 0:
raise RuntimeError(f"ssh mkdir fehlgeschlagen (Exit {rc})")
rc = subprocess.run(
f'scp -o ConnectTimeout=30 "{args.apk_path}" "{VPS}:{REMOTE_DIR}/data/app-latest.apk"',
shell=True,
).returncode
if rc != 0:
raise RuntimeError(f"scp fehlgeschlagen (Exit {rc})")
ok("APK hochgeladen")
except Exception as e:
fail(f"Upload fehlgeschlagen: {e}")
rollback_gradle(original_content)
info(f"TIPP: Pruefen ob VPS erreichbar ist: ssh {VPS} 'echo OK'")
sys.exit(1)
# -------------------------------------------------------------------------
# Schritt 3: Server-Version per API setzen
# Ab hier kein Rollback mehr (APK liegt schon oben)
# -------------------------------------------------------------------------
step("[3/4] Server-Version aktualisieren...")
api_body = {"versionCode": new_code, "versionName": new_name}
api_success = False
max_retries = 2
for attempt in range(1, max_retries + 1):
try:
status = post_json(f"{SERVER_URL}/api/admin/version", api_body, admin_token)
if status == 200:
api_success = True
break
raise RuntimeError(f"HTTP {status}")
except Exception as e:
if attempt < max_retries:
print(f" Versuch {attempt} fehlgeschlagen ({e}) - Retry in 3s...", flush=True)
time.sleep(3)
else:
fail(f"API-Call nach {max_retries} Versuchen fehlgeschlagen: {e}")
info(f"APK liegt bereits auf dem VPS. Recovery:")
info(f" curl -X POST {SERVER_URL}/api/admin/version \\")
info(f" -H 'Authorization: Bearer <TOKEN>' \\")
info(f" -H 'Content-Type: application/json' \\")
info(f" -d '{json.dumps(api_body)}'")
if api_success:
ok(f"Version gesetzt: {new_name} (build {new_code})")
else:
warn("VPS-Version nicht aktualisiert. build.gradle.kts wird trotzdem committet.")
# -------------------------------------------------------------------------
# Schritt 4: Verifizieren (non-fatal)
# -------------------------------------------------------------------------
if not api_success or args.skip_verify:
skip("Verifizierung uebersprungen")
else:
step("[4/4] Verifizieren...")
time.sleep(2)
try:
ver = get_json(f"{SERVER_URL}/api/version")
if ver.get("versionCode") == new_code and ver.get("versionName") == new_name:
ok(f"/api/version: {ver.get('versionName')} ({ver.get('versionCode')})")
else:
warn(f"/api/version meldet {ver.get('versionName')} ({ver.get('versionCode')}) - erwartet {new_name} ({new_code})")
except Exception as e:
warn(f"/api/version nicht erreichbar: {e}")
try:
status = get_status(f"{SERVER_URL}/")
ok(f"Homepage erreichbar (HTTP {status})")
except Exception as e:
warn(f"Homepage nicht erreichbar: {e}")
# -------------------------------------------------------------------------
# Git Commit + Push
# -------------------------------------------------------------------------
step("[Git] Version-Bump committen...")
try:
run(f'git add "{BUILD_GRADLE}"')
run(f'git commit -m "chore: release v{new_name} ({new_code})"')
except Exception as e:
warn(f"git commit fehlgeschlagen: {e}")
info(f'Manuell: git add "{BUILD_GRADLE}" ; git commit -m "chore: release v{new_name} ({new_code})"')
if not args.skip_push:
try:
run("git push")
ok("Gepusht")
except Exception as e:
warn(f"git push fehlgeschlagen: {e}")
info("Manuell: git push")
else:
skip("Push uebersprungen (--skip-push)")
# -------------------------------------------------------------------------
# Zusammenfassung
# -------------------------------------------------------------------------
print(f"\n{CYAN}=== Publish abgeschlossen ==={RESET}", flush=True)
print(f" Version : {new_name} (build {new_code})", flush=True)
print(f" Homepage: {SERVER_URL}/", flush=True)
print(f" API : {SERVER_URL}/api/version", flush=True)
if __name__ == "__main__":
main()