bollwerk/.github/skills/publish/publish-apk.py

410 lines
16 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.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 <TOKEN>' \\")
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()