bollwerk/.github/skills/publish/publish-apk.py
2026-05-18 12:08:12 +02:00

332 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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