# -*- 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.42 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 def parse_major_minor(version_name: str): """Extrahiert major/minor aus x.y oder x.y.z.""" m = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?$", version_name.strip()) if not m: return None return m.group(1), m.group(2) def validate_three_part_version(version_name: str): """Validiert strikt x.y.z (nur numerische Segmente).""" m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_name.strip()) if not m: return None return int(m.group(1)), int(m.group(2)), int(m.group(3)) def _extract_admin_token(raw_text: str) -> str: """Extrahiert BOLLWERK_ADMIN_TOKEN aus docker-compose/.env-Inhalten.""" if not raw_text: return "" patterns = [ r"(?m)^\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)", r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)", r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*=\s*['\"]?([^'\"\s#]+)", ] for pattern in patterns: match = re.search(pattern, raw_text) if match: return match.group(1).strip() return "" def get_admin_token_from_vps() -> str: """Holt den Admin-Token direkt vom VPS (Dateien, dann Container-Env).""" ssh_opts = "-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no" probe_commands = [ ( "compose+env files", f'ssh {ssh_opts} {VPS} ' f'"cd {REMOTE_DIR}; ' f'[ -f docker-compose.yml ] && cat docker-compose.yml || true; ' f'echo __ENV_SPLIT__; ' f'[ -f .env ] && cat .env || true"', ), ( "docker inspect env", f'ssh {ssh_opts} {VPS} "docker inspect $(docker ps -q) 2>/dev/null || true"', ), ] for _, cmd in probe_commands: result = subprocess.run(cmd, shell=True, capture_output=True, text=True) token = _extract_admin_token(result.stdout or "") if token: return token return "" # --------------------------------------------------------------------------- # Argument-Parsing # --------------------------------------------------------------------------- def parse_args(): parser = argparse.ArgumentParser(description="Bollwerk APK Publish-Workflow") parser.add_argument("--version-name", default="", help="Neue versionName im Format x.y.z (z.B. '1.7.14')") 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: 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 if args.version_name: parsed = validate_three_part_version(args.version_name) if not parsed: fail("--version-name muss strikt im Format x.y.z angegeben werden (z. B. 1.7.14).") sys.exit(1) if parsed[2] != new_code: fail(f"Patch-Segment der versionName ({parsed[2]}) muss dem versionCode ({new_code}) entsprechen.") sys.exit(1) new_name = args.version_name.strip() else: major_minor = parse_major_minor(current_name) if not major_minor: fail(f"Aktuelle versionName '{current_name}' hat kein gueltiges Schema. Erlaubt: x.y oder x.y.z") sys.exit(1) new_name = f"{major_minor[0]}.{major_minor[1]}.{new_code}" 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) # --- Pre-Check: Admin-Token direkt vom VPS holen --- step("[Preflight] Lade BOLLWERK_ADMIN_TOKEN direkt vom VPS...") admin_token = get_admin_token_from_vps() if admin_token: ok("Admin-Token vom VPS geladen") else: fail("BOLLWERK_ADMIN_TOKEN konnte auf dem VPS nicht ermittelt werden.") info(f"Pruefe auf {REMOTE_DIR}/docker-compose.yml, {REMOTE_DIR}/.env oder Container-Env") sys.exit(1) ok("Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)") print(f"\n{CYAN}=== Publish APK v{new_name} ==={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, ) 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}") 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}"') 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}"') 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}", flush=True) print(f" Code : {new_code}", flush=True) print(f" Homepage: {SERVER_URL}/", flush=True) print(f" API : {SERVER_URL}/api/version", flush=True) if __name__ == "__main__": main()