From 39956cc7d925ad4c3354f36fbc7fcfbfccfc9824 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 12:08:12 +0200 Subject: [PATCH] feat(publish): Python-basierter publish-apk Workflow --- .github/prompts/publish.prompt.md | 17 +- .github/skills/publish/SKILL.md | 38 +-- .github/skills/publish/publish-apk.py | 332 ++++++++++++++++++++++++++ 3 files changed, 365 insertions(+), 22 deletions(-) create mode 100644 .github/skills/publish/publish-apk.py diff --git a/.github/prompts/publish.prompt.md b/.github/prompts/publish.prompt.md index e32828b..accf57e 100644 --- a/.github/prompts/publish.prompt.md +++ b/.github/prompts/publish.prompt.md @@ -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 (✅ / ❌) diff --git a/.github/skills/publish/SKILL.md b/.github/skills/publish/SKILL.md index af7167a..b0f2560 100644 --- a/.github/skills/publish/SKILL.md +++ b/.github/skills/publish/SKILL.md @@ -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) | --- diff --git a/.github/skills/publish/publish-apk.py b/.github/skills/publish/publish-apk.py new file mode 100644 index 0000000..078379a --- /dev/null +++ b/.github/skills/publish/publish-apk.py @@ -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 ' \\") + 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()