# -*- 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()