"""Visual Git, TrueNAS, and optional APK release manager with safety gates. The GUI intentionally leaves the TrueNAS portion as copyable shell commands: the app must be stopped and started from the TrueNAS Apps UI around that block. Only Python's standard library is required. """ from __future__ import annotations import argparse import json import os import queue import re import shutil import subprocess import sys import tempfile import threading import webbrowser from collections import deque from dataclasses import dataclass from pathlib import Path from typing import Callable, Iterable, Sequence PROJECT_ROOT = Path(__file__).resolve().parents[1] PACKAGE_FILE = PROJECT_ROOT / "package.json" PACKAGE_LOCK_FILE = PROJECT_ROOT / "package-lock.json" RELEASE_VERSION_FILE = PROJECT_ROOT / "release-version.json" PUBLISH_SCRIPT = PROJECT_ROOT / "scripts" / "publish-gitea-release.ps1" RELEASES_URL = "https://git.whoagland.com/phenom/healer-man/releases" DEFAULT_KEYSTORE_FILE = Path(r"F:\secure\healer-man-release.jks") SAVED_SIGNING_PASSWORD_FILE = Path(r"F:\secure\healer-man-release-password.dpapi") SAVED_GITEA_TOKEN_FILE = Path(r"F:\secure\healer-man-gitea-token.dpapi") SEMVER_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") FORBIDDEN_DIRECTORIES = { ".android-public", "artifacts", "backups", "content", "dist", "dist-android", "releases", } FORBIDDEN_SUFFIXES = { ".aab", ".apk", ".db", ".jks", ".key", ".keystore", ".p12", ".pem", ".pfx", ".sqlite", ".sqlite3", } ALLOWED_GENERATED_FILES = { "public/data/healer-man-loot.sqlite", } OVERRIDABLE_TEST_LABELS = frozenset( { "Content tests", "Server tests", "Application tests", "Shelved-mode application tests", } ) ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") FAILURE_OUTPUT_LINE_LIMIT = 160 FAILURE_OUTPUT_CHARACTER_LIMIT = 20_000 DEFAULT_GIT_TRUENAS_ONLY = True DEFAULT_RUN_TESTS = True class ReleaseError(RuntimeError): """A release precondition or command failed.""" class ReleaseCancelled(ReleaseError): """The user cancelled an active release.""" class ReleaseCommandError(ReleaseError): """A release subprocess failed and retained its output for review.""" def __init__( self, label: str, command: Sequence[str], return_code: int, output: str, ) -> None: super().__init__(f"{label} failed with exit code {return_code}.") self.label = label self.command = tuple(command) self.return_code = return_code self.output = output @dataclass(frozen=True) class ReleaseVersion: version_name: str version_code: int def full_release_checks( npm: str, *, include_android: bool = True, include_tests: bool = True, ) -> tuple[tuple[list[str], str], ...]: """Commands that must succeed before an update can be staged.""" test_checks = ( ([npm, "run", "content:test"], "Content tests"), ([npm, "run", "server:test"], "Server tests"), ([npm, "test"], "Application tests"), ([npm, "run", "test:shelved"], "Shelved-mode application tests"), ) required_checks = ( ([npm, "run", "content:publish"], "Publish verified 54-dungeon content manifest"), ([npm, "run", "build"], "Production build"), ) checks = (test_checks if include_tests else ()) + required_checks if not include_android: return checks return checks + ( ([npm, "run", "android:sync"], "Android web build and Capacitor sync"), ) def can_override_release_check(label: str) -> bool: """Return whether the interactive release flow may continue after a failure.""" return label in OVERRIDABLE_TEST_LABELS def format_failed_test_warning(failure: ReleaseCommandError) -> str: """Build the detailed warning shown before overriding a failed test command.""" clean_output = ANSI_ESCAPE_PATTERN.sub("", failure.output).strip() output_lines = clean_output.splitlines() omitted_lines = max(0, len(output_lines) - FAILURE_OUTPUT_LINE_LIMIT) if omitted_lines: output_lines = output_lines[-FAILURE_OUTPUT_LINE_LIMIT:] recent_output = "\n".join(output_lines) if len(recent_output) > FAILURE_OUTPUT_CHARACTER_LIMIT: recent_output = recent_output[-FAILURE_OUTPUT_CHARACTER_LIMIT:] recent_output = "… earlier output omitted …\n" + recent_output elif omitted_lines: recent_output = f"… {omitted_lines} earlier line(s) omitted …\n" + recent_output if not recent_output: recent_output = "(The command produced no output.)" command = subprocess.list2cmdline(list(failure.command)) return ( f"{failure.label} failed with exit code {failure.return_code}.\n\n" f"Command:\n{command}\n\n" f"Recent output:\n{recent_output}\n\n" "Continuing can publish a release with known test failures. " "Only continue if you reviewed the failure and accept that risk." ) def parse_semver(value: str) -> tuple[int, int, int]: match = SEMVER_PATTERN.fullmatch(value.strip()) if not match: raise ValueError("Version must contain three numbers, such as 0.1.3.") return tuple(int(part) for part in match.groups()) # type: ignore[return-value] def bump_semver(value: str, part: str = "patch") -> str: major, minor, patch = parse_semver(value) if part == "major": return f"{major + 1}.0.0" if part == "minor": return f"{major}.{minor + 1}.0" if part == "patch": return f"{major}.{minor}.{patch + 1}" raise ValueError(f"Unknown version component: {part}") def semantic_version_code(value: str) -> int: """Map SemVer to a monotonic Android code with room for 0-999 components.""" major, minor, patch = parse_semver(value) if minor > 999 or patch > 999: raise ValueError("Minor and patch version components must be 999 or less.") code = major * 1_000_000 + minor * 1_000 + patch if not 1 <= code <= 2_147_483_647: raise ValueError("The calculated Android version code is out of range.") return code def suggest_version_code(next_version: str, previous_code: int) -> int: return max(previous_code + 1, semantic_version_code(next_version)) def read_package_version() -> str: raw = json.loads(PACKAGE_FILE.read_text(encoding="utf-8")) version = str(raw.get("version", "")) parse_semver(version) return version def read_release_version() -> ReleaseVersion: if not RELEASE_VERSION_FILE.exists(): version = read_package_version() return ReleaseVersion(version, semantic_version_code(version)) raw = json.loads(RELEASE_VERSION_FILE.read_text(encoding="utf-8")) version = str(raw.get("versionName", "")) code = int(raw.get("versionCode", 0)) parse_semver(version) if not 1 <= code <= 2_147_483_647: raise ValueError("release-version.json contains an invalid versionCode.") return ReleaseVersion(version, code) def write_release_version(version: ReleaseVersion) -> None: payload = { "versionName": version.version_name, "versionCode": version.version_code, } temporary = RELEASE_VERSION_FILE.with_suffix(".json.tmp") temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") temporary.replace(RELEASE_VERSION_FILE) def find_dangerous_staged_paths(paths: Iterable[str]) -> list[str]: dangerous: list[str] = [] for raw_path in paths: normalized = raw_path.replace("\\", "/").strip("/") parts = [part.lower() for part in normalized.split("/") if part] name = parts[-1] if parts else "" suffix = Path(name).suffix.lower() is_environment_file = name == ".env" or name.startswith(".env.") is_allowed_generated_file = normalized.lower() in ALLOWED_GENERATED_FILES if ( (parts and parts[0] in FORBIDDEN_DIRECTORIES) or (suffix in FORBIDDEN_SUFFIXES and not is_allowed_generated_file) or is_environment_file ): dangerous.append(raw_path) return dangerous def generate_truenas_deploy_commands(expected_commit: str) -> str: commit = expected_commit.strip() or "PASTE_RELEASE_COMMIT_HERE" return f"""# Stop healer-man in the TrueNAS Apps UI before running this block. set -eu EXPECTED_COMMIT='{commit}' sudo mkdir -p /mnt/usbssds/apps/healer-man/backups if sudo test -f /mnt/usbssds/apps/healer-man/data/game.db; then sudo cp -p \\ /mnt/usbssds/apps/healer-man/data/game.db \\ /mnt/usbssds/apps/healer-man/backups/game-$(date +%Y%m%d-%H%M%S).db fi sudo git config --global --add safe.directory \\ /mnt/usbssds/apps/healer-man/app sudo git config --global --add safe.directory \\ /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git sudo git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \\ /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \\ main sudo chown -R truenas_admin:truenas_admin \\ /mnt/usbssds/apps/healer-man/app ACTUAL_COMMIT=$(sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD) printf 'Expected: %s\\nActual: %s\\n' "$EXPECTED_COMMIT" "$ACTUAL_COMMIT" test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT" df -h /mnt/usbssds/apps/healer-man # Now start healer-man in the TrueNAS Apps UI and watch its startup log. """ def generate_truenas_verify_commands() -> str: return """# Run after healer-man has started and its log says the server is listening. set -eu curl -fI http://127.0.0.1:4173/ curl -fsS http://127.0.0.1:4173/api/health curl -fsS http://127.0.0.1:4173/content/manifest.json | head -c 500 printf '\\nTrueNAS verification completed.\\n' """ def command_output(args: Sequence[str], *, check: bool = True) -> str: result = subprocess.run( list(args), cwd=PROJECT_ROOT, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, ) if check and result.returncode != 0: raise ReleaseError( f"Command failed ({result.returncode}): {' '.join(args)}\n{result.stdout.strip()}" ) return result.stdout.strip() def git_output(*args: str, check: bool = True) -> str: return command_output(("git", *args), check=check) def current_commit() -> str: try: return git_output("rev-parse", "HEAD") except ReleaseError: return "" def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]: """Check tracked and untracked release files without changing the real index.""" with tempfile.TemporaryDirectory(prefix="healer-man-release-index-") as temp_dir: environment = os.environ.copy() environment["GIT_INDEX_FILE"] = str(Path(temp_dir) / "index") def run_temporary_index_command(*args: str) -> subprocess.CompletedProcess[bytes]: result = subprocess.run( ["git", *args], cwd=project_root, env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if result.returncode != 0: output = b"\n".join((result.stdout, result.stderr)).decode( "utf-8", errors="replace" ).strip() raise ReleaseError( f"Release-candidate Git check failed ({result.returncode}): " f"git {' '.join(args)}\n{output}" ) return result run_temporary_index_command("read-tree", "HEAD") run_temporary_index_command("add", "-A") raw_paths = run_temporary_index_command( "diff", "--cached", "--name-only", "-z" ).stdout staged_paths = [ path.decode("utf-8", errors="replace") for path in raw_paths.split(b"\0") if path ] whitespace_result = subprocess.run( ["git", "diff", "--cached", "--check"], cwd=project_root, env=environment, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if whitespace_result.returncode != 0: detail = "\n".join( part.strip() for part in (whitespace_result.stdout, whitespace_result.stderr) if part.strip() ) raise ReleaseError( "Release-candidate whitespace check failed. This check includes " f"tracked and untracked files:\n{detail}" ) dangerous = find_dangerous_staged_paths(staged_paths) if dangerous: raise ReleaseError( "Refusing to release forbidden output or a possible secret:\n" + "\n".join(f" - {path}" for path in dangerous) ) return staged_paths def windows_powershell_environment(powershell: str) -> dict[str, str]: environment = os.environ.copy() if Path(powershell).name.lower() == "powershell.exe": user_profile = Path(environment.get("USERPROFILE", str(Path.home()))) windows_root = Path(environment.get("WINDIR", r"C:\Windows")) program_files = Path(environment.get("ProgramFiles", r"C:\Program Files")) environment["PSModulePath"] = os.pathsep.join( ( str(user_profile / "Documents" / "WindowsPowerShell" / "Modules"), str(program_files / "WindowsPowerShell" / "Modules"), str(windows_root / "System32" / "WindowsPowerShell" / "v1.0" / "Modules"), ) ) return environment def read_windows_encrypted_secret(path: Path, label: str) -> str: """Decrypt a local DPAPI credential through the current Windows account.""" if os.name != "nt" or not path.is_file(): return "" powershell = shutil.which("powershell.exe") or shutil.which("powershell") if not powershell: raise ReleaseError(f"Windows PowerShell is required to read the saved {label}.") script = ( "$secure = Get-Content -LiteralPath $env:HEALERMAN_CREDENTIAL_FILE -Raw " "| ConvertTo-SecureString; " "$plain = [System.Net.NetworkCredential]::new('', $secure).Password; " "Write-Output $plain" ) environment = windows_powershell_environment(powershell) environment["HEALERMAN_CREDENTIAL_FILE"] = str(path) result = subprocess.run( [powershell, "-NoProfile", "-Command", script], cwd=PROJECT_ROOT, env=environment, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, creationflags=subprocess.CREATE_NO_WINDOW, ) password = result.stdout.rstrip("\r\n") if result.returncode != 0 or not password: raise ReleaseError( f"The saved {label} could not be decrypted by this Windows account." ) return password def write_windows_encrypted_secret(path: Path, value: str, label: str) -> None: """Encrypt a secret with Windows DPAPI without putting it on the command line.""" if os.name != "nt": raise ReleaseError(f"Saving the {label} is supported only on Windows.") powershell = shutil.which("powershell.exe") or shutil.which("powershell") if not powershell: raise ReleaseError(f"Windows PowerShell is required to save the {label}.") path.parent.mkdir(parents=True, exist_ok=True) script = ( "$ErrorActionPreference = 'Stop'; " "$secure = ConvertTo-SecureString $env:HEALERMAN_SECRET_VALUE -AsPlainText -Force; " "$encrypted = ConvertFrom-SecureString $secure; " "[IO.File]::WriteAllText($env:HEALERMAN_CREDENTIAL_FILE, $encrypted, " "[Text.UTF8Encoding]::new($false))" ) environment = windows_powershell_environment(powershell) environment["HEALERMAN_CREDENTIAL_FILE"] = str(path) environment["HEALERMAN_SECRET_VALUE"] = value result = subprocess.run( [powershell, "-NoProfile", "-Command", script], cwd=PROJECT_ROOT, env=environment, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, creationflags=subprocess.CREATE_NO_WINDOW, ) environment["HEALERMAN_SECRET_VALUE"] = "" if result.returncode != 0 or not path.is_file(): raise ReleaseError(f"The {label} could not be saved with Windows encryption.") def read_saved_signing_password() -> str: return read_windows_encrypted_secret( SAVED_SIGNING_PASSWORD_FILE, "signing password" ) def read_saved_gitea_token() -> str: return read_windows_encrypted_secret(SAVED_GITEA_TOKEN_FILE, "Gitea token").strip() def android_toolchain_status() -> tuple[bool, str]: workspace_root = PROJECT_ROOT.parent wow_root = workspace_root / "wow335a" configured_java = os.environ.get("JAVA_HOME", "").strip() java_candidates: list[Path] = [] if configured_java: java_candidates.append(Path(configured_java)) portable_java_roots = ( PROJECT_ROOT / ".tools" / "temurin-21", wow_root / "dungeon-export-work" / "tools" / "temurin-21", ) for portable_java_root in portable_java_roots: if portable_java_root.is_dir(): java_candidates.extend( sorted( (path for path in portable_java_root.iterdir() if path.is_dir()), reverse=True, ) ) java_home = next( (path for path in java_candidates if (path / "bin" / "java.exe").is_file()), None, ) configured_sdk = ( os.environ.get("ANDROID_SDK_ROOT", "").strip() or os.environ.get("ANDROID_HOME", "").strip() ) sdk_candidates = ([Path(configured_sdk)] if configured_sdk else []) + [ wow_root / ".android-sdk" ] sdk_root = next( ( path for path in sdk_candidates if (path / "platforms" / "android-36" / "android.jar").is_file() ), None, ) missing: list[str] = [] if java_home is None: missing.append("JDK 21 (set JAVA_HOME or install the documented portable JDK)") if sdk_root is None: missing.append("Android SDK Platform 36 (set ANDROID_SDK_ROOT)") if missing: return False, "; ".join(missing) return True, f"Java: {java_home}; Android SDK: {sdk_root}" def check_summary() -> dict[str, object]: package_version = read_package_version() release_version = read_release_version() next_version = bump_semver(package_version) toolchain_ready, toolchain_detail = android_toolchain_status() return { "project": str(PROJECT_ROOT), "branch": git_output("branch", "--show-current"), "commit": current_commit(), "packageVersion": package_version, "trackedAndroidVersion": release_version.version_name, "trackedAndroidVersionCode": release_version.version_code, "suggestedNextVersion": next_version, "suggestedNextVersionCode": suggest_version_code( next_version, release_version.version_code ), "androidToolchainReady": toolchain_ready, "androidToolchain": toolchain_detail, "releaseKeystoreReady": DEFAULT_KEYSTORE_FILE.is_file(), "savedSigningCredentialReady": SAVED_SIGNING_PASSWORD_FILE.is_file(), "savedGiteaTokenReady": SAVED_GITEA_TOKEN_FILE.is_file(), "workingTree": git_output("status", "--short", "--branch"), } def launch_gui(*, smoke_test: bool = False) -> int: import tkinter as tk from tkinter import filedialog, messagebox, scrolledtext, ttk class ReleaseManagerApp: BG = "#0c1515" PANEL = "#122222" PANEL_2 = "#172b2a" TEXT = "#e9f4ef" MUTED = "#91aaa2" ACCENT = "#63d9a4" ACCENT_DARK = "#183b31" WARNING = "#f2c66d" ERROR = "#ff8b83" def __init__(self, root: tk.Tk) -> None: self.root = root self.events: queue.Queue[tuple[object, ...]] = queue.Queue() self.cancel_event = threading.Event() self.current_process: subprocess.Popen[str] | None = None self.busy = False self.last_commit = current_commit() self._configure_window() self._build_ui() self.refresh() self.root.after(100, self._poll_events) self.root.protocol("WM_DELETE_WINDOW", self._close) def _configure_window(self) -> None: self.root.title("Healer Man Release Manager") self.root.geometry("1120x820") self.root.minsize(960, 700) self.root.configure(bg=self.BG) style = ttk.Style(self.root) try: style.theme_use("clam") except tk.TclError: pass style.configure(".", background=self.BG, foreground=self.TEXT) style.configure("TFrame", background=self.BG) style.configure("Panel.TFrame", background=self.PANEL) style.configure( "Title.TLabel", background=self.BG, foreground=self.TEXT, font=("Segoe UI Semibold", 20), ) style.configure( "Sub.TLabel", background=self.BG, foreground=self.MUTED, font=("Segoe UI", 10), ) style.configure( "Panel.TLabel", background=self.PANEL, foreground=self.TEXT ) style.configure( "Muted.Panel.TLabel", background=self.PANEL, foreground=self.MUTED ) style.configure( "Accent.TButton", background=self.ACCENT, foreground="#06110d", font=("Segoe UI Semibold", 10), padding=(13, 9), ) style.map("Accent.TButton", background=[("active", "#84e7bb")]) style.configure("TButton", padding=(10, 7)) style.configure( "TEntry", fieldbackground="#0a1716", foreground=self.TEXT, insertcolor=self.TEXT, bordercolor="#31504a", ) style.configure( "TCombobox", fieldbackground="#0a1716", foreground=self.TEXT, arrowcolor=self.TEXT, ) style.configure("TNotebook", background=self.PANEL, borderwidth=0) style.configure( "TNotebook.Tab", background=self.PANEL_2, foreground=self.MUTED, padding=(12, 7), ) style.map( "TNotebook.Tab", background=[("selected", self.ACCENT_DARK)], foreground=[("selected", self.ACCENT)], ) def _build_ui(self) -> None: shell = ttk.Frame(self.root, padding=18) shell.pack(fill="both", expand=True) shell.columnconfigure(0, weight=3) shell.columnconfigure(1, weight=2) shell.rowconfigure(2, weight=1) title = ttk.Frame(shell) title.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 14)) title.columnconfigure(0, weight=1) ttk.Label(title, text="Healer Man Release Manager", style="Title.TLabel").grid( row=0, column=0, sticky="w" ) ttk.Label( title, text="Validate → inspect → commit → push → deploy, with optional signed APK", style="Sub.TLabel", ).grid(row=1, column=0, sticky="w", pady=(2, 0)) self.refresh_button = ttk.Button(title, text="Refresh", command=self.refresh) self.refresh_button.grid(row=0, column=1, rowspan=2, sticky="e") release_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15) release_panel.grid(row=1, column=0, sticky="nsew", padx=(0, 8)) release_panel.columnconfigure(1, weight=1) ttk.Label( release_panel, text="Release details", style="Panel.TLabel", font=("Segoe UI Semibold", 12), ).grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 11)) self.current_version_var = tk.StringVar(value="—") self.current_code_var = tk.StringVar(value="—") self.bump_var = tk.StringVar(value="Patch") self.next_version_var = tk.StringVar() self.next_code_var = tk.StringVar() self.commit_message_var = tk.StringVar() self.git_truenas_only_var = tk.BooleanVar( value=DEFAULT_GIT_TRUENAS_ONLY ) self.run_tests_var = tk.BooleanVar(value=DEFAULT_RUN_TESTS) self.release_mode_hint_var = tk.StringVar() self.test_mode_hint_var = tk.StringVar() ttk.Label(release_panel, text="Current", style="Muted.Panel.TLabel").grid( row=1, column=0, sticky="w" ) ttk.Label(release_panel, textvariable=self.current_version_var, style="Panel.TLabel").grid( row=1, column=1, sticky="w", padx=(8, 20) ) ttk.Label(release_panel, text="Android code", style="Muted.Panel.TLabel").grid( row=1, column=2, sticky="w" ) ttk.Label(release_panel, textvariable=self.current_code_var, style="Panel.TLabel").grid( row=1, column=3, sticky="w", padx=(8, 0) ) ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid( row=2, column=0, sticky="w", pady=(12, 0) ) self.bump_box = ttk.Combobox( release_panel, textvariable=self.bump_var, values=("Patch", "Minor", "Major"), state="readonly", width=10, ) self.bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0)) self.bump_box.bind("<>", lambda _event: self._suggest_versions()) ttk.Label(release_panel, text="Next version", style="Muted.Panel.TLabel").grid( row=3, column=0, sticky="w", pady=(9, 0) ) self.next_version_entry = ttk.Entry( release_panel, textvariable=self.next_version_var, width=15 ) self.next_version_entry.grid( row=3, column=1, sticky="ew", padx=(8, 20), pady=(9, 0) ) ttk.Label(release_panel, text="Next code", style="Muted.Panel.TLabel").grid( row=3, column=2, sticky="w", pady=(9, 0) ) self.next_code_entry = ttk.Entry( release_panel, textvariable=self.next_code_var, width=12 ) self.next_code_entry.grid( row=3, column=3, sticky="ew", padx=(8, 0), pady=(9, 0) ) ttk.Label(release_panel, text="Commit message", style="Muted.Panel.TLabel").grid( row=4, column=0, sticky="w", pady=(9, 0) ) ttk.Entry(release_panel, textvariable=self.commit_message_var).grid( row=4, column=1, columnspan=3, sticky="ew", padx=(8, 0), pady=(9, 0) ) ttk.Label(release_panel, text="Release notes (APK only)", style="Muted.Panel.TLabel").grid( row=5, column=0, sticky="nw", pady=(9, 0) ) self.release_notes = tk.Text( release_panel, height=4, wrap="word", bg="#0a1716", fg=self.TEXT, insertbackground=self.TEXT, relief="flat", padx=7, pady=6, font=("Segoe UI", 9), ) self.release_notes.grid( row=5, column=1, columnspan=3, sticky="nsew", padx=(8, 0), pady=(9, 0) ) self.release_mode_toggle = ttk.Checkbutton( release_panel, text="Git + TrueNAS only (skip Android version bump and APK publication)", variable=self.git_truenas_only_var, command=self._update_release_mode, ) self.release_mode_toggle.grid( row=6, column=0, columnspan=4, sticky="w", pady=(12, 0) ) ttk.Label( release_panel, textvariable=self.release_mode_hint_var, style="Muted.Panel.TLabel", wraplength=610, ).grid(row=7, column=0, columnspan=4, sticky="w", pady=(4, 0)) self.run_tests_toggle = ttk.Checkbutton( release_panel, text="Run test suites (recommended)", variable=self.run_tests_var, command=self._update_test_mode, ) self.run_tests_toggle.grid( row=8, column=0, columnspan=4, sticky="w", pady=(10, 0) ) ttk.Label( release_panel, textvariable=self.test_mode_hint_var, style="Muted.Panel.TLabel", wraplength=610, ).grid(row=9, column=0, columnspan=4, sticky="w", pady=(4, 0)) secrets_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15) secrets_panel.grid(row=1, column=1, sticky="nsew", padx=(8, 0)) secrets_panel.columnconfigure(1, weight=1) ttk.Label( secrets_panel, text="Signing and Gitea (APK only)", style="Panel.TLabel", font=("Segoe UI Semibold", 12), ).grid(row=0, column=0, columnspan=3, sticky="w") ttk.Label( secrets_panel, text="Signing password and saved token are Windows-encrypted outside Git.", style="Muted.Panel.TLabel", ).grid(row=1, column=0, columnspan=3, sticky="w", pady=(2, 10)) self.keystore_var = tk.StringVar(value=str(DEFAULT_KEYSTORE_FILE)) self.alias_var = tk.StringVar(value="healer-man") self.keystore_password_var = tk.StringVar() self.key_password_var = tk.StringVar() try: saved_token = read_saved_gitea_token() except ReleaseError: saved_token = "" self.token_var = tk.StringVar(value=saved_token) self.remember_token_var = tk.BooleanVar(value=True) credential_rows = ( ("Keystore", self.keystore_var, False), ("Key alias", self.alias_var, False), ("Store password", self.keystore_password_var, True), ("Key password", self.key_password_var, True), ("Gitea token", self.token_var, True), ) for row, (label, variable, secret) in enumerate(credential_rows, start=2): ttk.Label(secrets_panel, text=label, style="Muted.Panel.TLabel").grid( row=row, column=0, sticky="w", pady=4 ) ttk.Entry(secrets_panel, textvariable=variable, show="•" if secret else "").grid( row=row, column=1, sticky="ew", padx=(8, 5), pady=4 ) if row == 2: ttk.Button(secrets_panel, text="…", width=3, command=self._browse_keystore).grid( row=row, column=2, pady=4 ) ttk.Checkbutton( secrets_panel, text="Remember Gitea token on this PC", variable=self.remember_token_var, ).grid(row=7, column=0, columnspan=2, sticky="w", pady=(7, 0)) ttk.Button( secrets_panel, text="Forget saved token", command=self._forget_saved_token, ).grid(row=7, column=2, sticky="e", pady=(7, 0)) ttk.Label( secrets_panel, text=( "Blank password fields use the saved Windows-encrypted signing credential; " "enter the Gitea token once and it will load automatically." ), style="Muted.Panel.TLabel", wraplength=360, ).grid(row=8, column=0, columnspan=3, sticky="w", pady=(7, 0)) lower = ttk.Panedwindow(shell, orient="horizontal") lower.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(16, 0)) log_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12) commands_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12) lower.add(log_panel, weight=3) lower.add(commands_panel, weight=2) log_panel.rowconfigure(2, weight=1) log_panel.columnconfigure(0, weight=1) commands_panel.rowconfigure(1, weight=1) commands_panel.columnconfigure(0, weight=1) status_row = ttk.Frame(log_panel, style="Panel.TFrame") status_row.grid(row=0, column=0, sticky="ew") status_row.columnconfigure(0, weight=1) self.status_var = tk.StringVar(value="Ready") ttk.Label( status_row, textvariable=self.status_var, style="Panel.TLabel", font=("Segoe UI Semibold", 11), ).grid(row=0, column=0, sticky="w") self.progress = ttk.Progressbar(status_row, mode="indeterminate", length=150) self.progress.grid(row=0, column=1, sticky="e") self.git_summary_var = tk.StringVar(value="Loading Git status…") ttk.Label( log_panel, textvariable=self.git_summary_var, style="Muted.Panel.TLabel", wraplength=600, ).grid(row=1, column=0, sticky="ew", pady=(4, 8)) self.log = scrolledtext.ScrolledText( log_panel, wrap="word", bg="#071110", fg="#cfe2dc", insertbackground=self.TEXT, relief="flat", font=("Cascadia Mono", 9), state="disabled", ) self.log.grid(row=2, column=0, sticky="nsew") ttk.Label( commands_panel, text="TrueNAS shell commands", style="Panel.TLabel", font=("Segoe UI Semibold", 11), ).grid(row=0, column=0, sticky="w", pady=(0, 7)) self.command_tabs = ttk.Notebook(commands_panel) self.command_tabs.grid(row=1, column=0, sticky="nsew") self.deploy_commands = self._command_text_widget(self.command_tabs) self.verify_commands = self._command_text_widget(self.command_tabs) self.command_tabs.add(self.deploy_commands.master, text="Deploy") self.command_tabs.add(self.verify_commands.master, text="Verify") command_buttons = ttk.Frame(commands_panel, style="Panel.TFrame") command_buttons.grid(row=2, column=0, sticky="ew", pady=(8, 0)) ttk.Button(command_buttons, text="Copy selected tab", command=self._copy_commands).pack( side="left" ) ttk.Button(command_buttons, text="Open releases", command=lambda: webbrowser.open(RELEASES_URL)).pack( side="right" ) actions = ttk.Frame(shell) actions.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(14, 0)) actions.columnconfigure(0, weight=1) self.release_button = ttk.Button( actions, text="Validate, Push, and Publish APK", style="Accent.TButton", command=self.start_full_release, ) self.release_button.grid(row=0, column=0, sticky="ew", padx=(0, 7)) self.retry_button = ttk.Button( actions, text="Publish Current APK Only", command=self.start_apk_only, ) self.retry_button.grid(row=0, column=1, padx=7) self.cancel_button = ttk.Button(actions, text="Cancel", command=self.cancel, state="disabled") self.cancel_button.grid(row=0, column=2, padx=(7, 0)) self._update_release_mode() self._update_test_mode() def _update_release_mode(self) -> None: git_truenas_only = self.git_truenas_only_var.get() self.bump_box.configure(state="disabled" if git_truenas_only else "readonly") entry_state = "disabled" if git_truenas_only else "normal" self.next_version_entry.configure(state=entry_state) self.next_code_entry.configure(state=entry_state) if git_truenas_only: self.release_button.configure(text="Validate and Push Git + TrueNAS Update") self.release_mode_hint_var.set( "Default mode: keeps the current Android version, skips Android sync and APK " "publication, and prepares the pinned TrueNAS deployment commands after push." ) else: self.release_button.configure(text="Validate, Push, and Publish APK") self.release_mode_hint_var.set( "APK mode: bumps the app and Android versions, runs Android sync, and publishes " "the signed APK after the Git push." ) self._suggest_versions() current_notes = self.release_notes.get("1.0", "end").strip() server_default = "Healer Man Git and TrueNAS server update." apk_default = ( f"Healer Man {self.next_version_var.get()} app and Android update." ) generated_apk_notes = re.fullmatch( r"Healer Man \d+\.\d+\.\d+ app and Android update\.", current_notes, ) if git_truenas_only and (not current_notes or generated_apk_notes): self._set_text(self.release_notes, server_default) elif not git_truenas_only and ( not current_notes or current_notes == server_default ): self._set_text(self.release_notes, apk_default) def _update_test_mode(self) -> None: if self.run_tests_var.get(): self.test_mode_hint_var.set( "Runs content, server, normal-application, and shelved-application tests." ) else: self.test_mode_hint_var.set( "Skips those four test suites. Candidate safety checks, content publication, " "the production build, and staged-file review still run." ) def _command_text_widget(self, notebook: ttk.Notebook) -> tk.Text: frame = ttk.Frame(notebook) frame.rowconfigure(0, weight=1) frame.columnconfigure(0, weight=1) widget = tk.Text( frame, wrap="none", bg="#071110", fg="#cfe2dc", relief="flat", padx=8, pady=8, font=("Cascadia Mono", 8), ) widget.grid(row=0, column=0, sticky="nsew") return widget def _browse_keystore(self) -> None: selected = filedialog.askopenfilename( title="Select Android release keystore", filetypes=(("Java keystore", "*.jks *.keystore"), ("All files", "*.*")), ) if selected: self.keystore_var.set(selected) def _forget_saved_token(self) -> None: if not SAVED_GITEA_TOKEN_FILE.exists(): self.token_var.set("") self.status_var.set("No saved Gitea token") return if not messagebox.askyesno( "Forget saved token?", "Remove the Windows-encrypted Gitea token from this PC?", parent=self.root, ): return SAVED_GITEA_TOKEN_FILE.unlink(missing_ok=True) self.token_var.set("") self.status_var.set("Saved Gitea token removed") def _set_text(self, widget: tk.Text, value: str, *, readonly: bool = False) -> None: widget.configure(state="normal") widget.delete("1.0", "end") widget.insert("1.0", value) if readonly: widget.configure(state="disabled") def _append_log(self, value: str) -> None: self.log.configure(state="normal") self.log.insert("end", value.rstrip("\n") + "\n") self.log.see("end") self.log.configure(state="disabled") def _log_event(self, value: str) -> None: self.events.put(("log", value)) def refresh(self) -> None: if self.busy: return try: package_version = read_package_version() release_version = read_release_version() status = git_output("status", "--short", "--branch") branch = git_output("branch", "--show-current") or "detached HEAD" toolchain_ready, _toolchain_detail = android_toolchain_status() self.last_commit = current_commit() self.current_version_var.set(package_version) self.current_code_var.set(str(release_version.version_code)) self._update_release_mode() change_count = len( [line for line in status.splitlines() if not line.startswith("##")] ) self.git_summary_var.set( f"Branch {branch} • {change_count} changed path(s) • HEAD {self.last_commit[:12]} • " f"Android toolchain {'ready' if toolchain_ready else 'missing'}" ) self._set_text( self.deploy_commands, generate_truenas_deploy_commands(self.last_commit), readonly=True, ) self._set_text( self.verify_commands, generate_truenas_verify_commands(), readonly=True, ) self.status_var.set("Ready") except Exception as exc: self.status_var.set("Configuration error") self.git_summary_var.set(str(exc)) def _suggest_versions(self) -> None: try: current = read_package_version() release = read_release_version() next_version = bump_semver(current, self.bump_var.get().lower()) next_code = suggest_version_code(next_version, release.version_code) self.next_version_var.set(next_version) self.next_code_var.set(str(next_code)) if self.git_truenas_only_var.get(): self.commit_message_var.set("Update Healer Man server") else: self.commit_message_var.set(f"Release Healer Man {next_version}") except Exception as exc: self.status_var.set(str(exc)) def _credentials(self) -> dict[str, str]: fields = { "ANDROID_KEYSTORE_FILE": self.keystore_var.get().strip(), "ANDROID_KEY_ALIAS": self.alias_var.get().strip(), "ANDROID_KEYSTORE_PASSWORD": self.keystore_password_var.get(), "ANDROID_KEY_PASSWORD": self.key_password_var.get(), "GITEA_TOKEN": self.token_var.get().strip(), } for key, value in list(fields.items()): if not value: fields[key] = os.environ.get(key, "") if not fields["GITEA_TOKEN"]: fields["GITEA_TOKEN"] = read_saved_gitea_token() selected_keystore = os.path.normcase( os.path.abspath(fields["ANDROID_KEYSTORE_FILE"]) ) default_keystore = os.path.normcase(os.path.abspath(DEFAULT_KEYSTORE_FILE)) if selected_keystore == default_keystore and ( not fields["ANDROID_KEYSTORE_PASSWORD"] or not fields["ANDROID_KEY_PASSWORD"] ): saved_password = read_saved_signing_password() if not fields["ANDROID_KEYSTORE_PASSWORD"]: fields["ANDROID_KEYSTORE_PASSWORD"] = saved_password if not fields["ANDROID_KEY_PASSWORD"]: fields["ANDROID_KEY_PASSWORD"] = saved_password missing = [key for key, value in fields.items() if not value] if missing: raise ReleaseError("Missing release credentials: " + ", ".join(missing)) if self.remember_token_var.get(): write_windows_encrypted_secret( SAVED_GITEA_TOKEN_FILE, fields["GITEA_TOKEN"], "Gitea token" ) keystore = Path(fields["ANDROID_KEYSTORE_FILE"]) if not keystore.is_file(): raise ReleaseError(f"Keystore does not exist: {keystore}") return fields def _requested_version(self) -> ReleaseVersion: version_name = self.next_version_var.get().strip() parse_semver(version_name) try: version_code = int(self.next_code_var.get().strip()) except ValueError as exc: raise ReleaseError("Android version code must be a whole number.") from exc current = read_package_version() tracked = read_release_version() if parse_semver(version_name) <= parse_semver(current): raise ReleaseError(f"Next version must be newer than {current}.") if version_code <= tracked.version_code: raise ReleaseError( f"Android version code must be greater than {tracked.version_code}." ) if version_code > 2_147_483_647: raise ReleaseError("Android version code exceeds Android's maximum.") return ReleaseVersion(version_name, version_code) def start_full_release(self) -> None: if self.busy: return publish_apk = not self.git_truenas_only_var.get() run_tests = self.run_tests_var.get() test_summary = ( "The four test suites will run." if run_tests else "The four test suites will be skipped; safety checks and the production build still run." ) try: commit_message = self.commit_message_var.get().strip() if not commit_message: raise ReleaseError("Commit message is required.") version: ReleaseVersion | None = None credentials: dict[str, str] | None = None notes = "" if publish_apk: version = self._requested_version() credentials = self._credentials() toolchain_ready, toolchain_detail = android_toolchain_status() if not toolchain_ready: raise ReleaseError(toolchain_detail) notes = self.release_notes.get("1.0", "end").strip() if not notes: raise ReleaseError("Release notes are required.") except Exception as exc: messagebox.showerror("Cannot start release", str(exc), parent=self.root) return if not publish_apk: if not messagebox.askyesno( "Start Git + TrueNAS update?", "Run the server validation and production build, review and commit the exact " "changed files, then push main?\n\nThe Android version will not change and no " "APK will be built or published. The Deploy tab will be pinned to the new commit.\n\n" + test_summary, parent=self.root, ): return self._begin_worker( "Validating Git + TrueNAS update", lambda: self._full_release_worker( None, commit_message, "", None, publish_apk=False, run_tests=run_tests, ), ( "Git main is published without changing the Android version or publishing " "an APK.\n\nUse the TrueNAS Deploy tab after stopping the app, then the " "Verify tab after restarting it." ), ) return assert version is not None assert credentials is not None if not messagebox.askyesno( "Start full release?", f"Prepare Healer Man {version.version_name} (Android code {version.version_code})?\n\n" "The app will bump versions, run all release checks, then show the exact staged files " "before it commits, pushes main, and publishes the signed APK.\n\n" + test_summary, parent=self.root, ): return self._begin_worker( "Validating release", lambda: self._full_release_worker( version, commit_message, notes, credentials, publish_apk=True, run_tests=run_tests, ), ( "Git main and the signed APK release are published.\n\nUse the TrueNAS Deploy " "tab after stopping the app, then the Verify tab after restarting it." ), ) def start_apk_only(self) -> None: if self.busy: return try: package_version = read_package_version() release_version = read_release_version() credentials = self._credentials() toolchain_ready, toolchain_detail = android_toolchain_status() if not toolchain_ready: raise ReleaseError(toolchain_detail) if package_version != release_version.version_name: raise ReleaseError( "package.json and release-version.json do not describe the same version." ) notes = self.release_notes.get("1.0", "end").strip() or ( f"Healer Man {package_version} Android update." ) except Exception as exc: messagebox.showerror("Cannot publish APK", str(exc), parent=self.root) return if not messagebox.askyesno( "Publish current APK?", f"Build and publish {package_version} (Android code {release_version.version_code}) " "from the current clean, pushed commit?", parent=self.root, ): return self._begin_worker( "Publishing current APK", lambda: self._apk_only_worker(release_version, notes, credentials), ( "The signed APK release is published for the current Git commit.\n\nUse the " "TrueNAS Deploy tab only if that commit is not already deployed." ), ) def _begin_worker( self, label: str, target: Callable[[], None], success_message: str, ) -> None: self.busy = True self.cancel_event.clear() self.status_var.set(label) self.progress.start(12) self.release_button.configure(state="disabled") self.retry_button.configure(state="disabled") self.refresh_button.configure(state="disabled") self.release_mode_toggle.configure(state="disabled") self.run_tests_toggle.configure(state="disabled") self.cancel_button.configure(state="normal") self._append_log(f"\n=== {label} ===") threading.Thread( target=self._worker_wrapper, args=(target, success_message), daemon=True, ).start() def _worker_wrapper( self, target: Callable[[], None], success_message: str, ) -> None: try: target() except ReleaseCancelled as exc: self.events.put(("done", "cancelled", str(exc))) except Exception as exc: self.events.put(("done", "error", str(exc))) else: self.events.put(("done", "success", success_message)) def _preflight_git(self, *, require_clean: bool) -> None: if git_output("branch", "--show-current") != "main": raise ReleaseError("Releases are only allowed from the main branch.") merge_head = git_output("rev-parse", "-q", "--verify", "MERGE_HEAD", check=False) if merge_head: raise ReleaseError("A merge is in progress. Finish it before releasing.") if require_clean and git_output("status", "--porcelain"): raise ReleaseError("APK-only publication requires a clean working tree.") if not require_clean: staged = git_output("diff", "--cached", "--name-only") if staged: raise ReleaseError( "The Git index already contains staged changes. Unstage or commit them so the " "release manager can create an exact review checkpoint." ) def _full_release_worker( self, version: ReleaseVersion | None, commit_message: str, notes: str, credentials: dict[str, str] | None, *, publish_apk: bool, run_tests: bool, ) -> None: self._preflight_git(require_clean=False) self._log_event( "\n--- Early release-candidate check ---\n" "$ npm run release:check" ) candidate_paths = validate_release_candidates() self._log_event( f"✓ Early release-candidate check passed " f"({len(candidate_paths)} changed path(s), including untracked files)" ) npm = shutil.which("npm") if not npm: raise ReleaseError("npm must be available on PATH.") powershell = None if publish_apk: if version is None or credentials is None: raise ReleaseError("APK publication requires version and signing details.") powershell = shutil.which("powershell.exe") or shutil.which("powershell") if not powershell: raise ReleaseError("Windows PowerShell must be available on PATH.") managed_files = ( (PACKAGE_FILE, PACKAGE_LOCK_FILE, RELEASE_VERSION_FILE) if publish_apk else () ) backups = { path: path.read_bytes() if path.exists() else None for path in managed_files } staged = False committed = False base_commit = current_commit() try: if publish_apk: assert version is not None self._run_command( [npm, "version", version.version_name, "--no-git-tag-version"], "Bump package version", ) write_release_version(version) self._log_event( f"Recorded Android version {version.version_name} / {version.version_code}." ) else: self._log_event( "Git + TrueNAS mode: keeping the current Android version and " "skipping APK publication." ) if not run_tests: self._log_event( "⚠ Test suites disabled: skipping content, server, normal-application, " "and shelved-application tests." ) for command, label in full_release_checks( npm, include_android=publish_apk, include_tests=run_tests, ): try: self._run_command(command, label) except ReleaseCommandError as failure: if not can_override_release_check(label): raise approved = self._request_approval( format_failed_test_warning(failure), title=f"Warning: {label} failed", heading="Tests failed — continue the release?", approve_label="Continue Anyway", cancel_label="Stop Release", ) if not approved: raise ReleaseCancelled( f"Release stopped because {label} failed." ) from failure self._log_event( f"⚠ Continuing after failed {label}; override approved by user." ) self._run_command(["git", "add", "-A"], "Stage release") staged = True raw_paths = subprocess.run( ["git", "diff", "--cached", "--name-only", "-z"], cwd=PROJECT_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, ).stdout staged_paths = [ path.decode("utf-8", errors="replace") for path in raw_paths.split(b"\0") if path ] dangerous = find_dangerous_staged_paths(staged_paths) if dangerous: raise ReleaseError( "Refusing to commit forbidden release output or a possible secret:\n" + "\n".join(f" - {path}" for path in dangerous) ) self._run_command( ["git", "diff", "--cached", "--check"], "Staged whitespace check" ) if not staged_paths: raise ReleaseError("There are no staged changes to release.") stat = git_output("diff", "--cached", "--stat") if publish_apk: assert version is not None review_heading = ( f"Version: {version.version_name}\n" f"Android code: {version.version_code}\n" ) review_question = "Commit, push main, and publish the APK?" approval_title = "Review staged release" approval_heading = "Inspect every staged path before publishing" approve_label = "Commit, Push, and Publish" cancel_label = "Cancel release" else: current_android = read_release_version() review_heading = ( "Mode: Git + TrueNAS only\n" f"Android version remains: {current_android.version_name} " f"(code {current_android.version_code})\n" ) review_question = ( "Commit and push main without publishing an APK? The TrueNAS Deploy tab " "will be pinned to the new commit." ) approval_title = "Review staged Git + TrueNAS update" approval_heading = "Inspect every staged path before pushing" approve_label = "Commit and Push" cancel_label = "Cancel update" review_heading += ( f"Test suites: {'run' if run_tests else 'SKIPPED'}\n" ) review = ( review_heading + f"Commit: {commit_message}\n\n" f"Staged paths ({len(staged_paths)}):\n" + "\n".join(staged_paths) + f"\n\nDiff summary:\n{stat}\n\n{review_question}" ) approved = self._request_approval( review, title=approval_title, heading=approval_heading, approve_label=approve_label, cancel_label=cancel_label, ) if not approved: raise ReleaseCancelled("Release stopped at the staged-file review.") self._run_command(["git", "commit", "-m", commit_message], "Commit release") committed = current_commit() != base_commit release_commit = current_commit() self._run_command(["git", "push", "origin", "main"], "Push main") remote_line = git_output("ls-remote", "origin", "refs/heads/main") remote_commit = remote_line.split()[0] if remote_line else "" if remote_commit != release_commit: raise ReleaseError( f"Remote main is {remote_commit or 'unknown'}, expected {release_commit}." ) self.events.put(("commit", release_commit)) if publish_apk: assert powershell is not None assert version is not None assert credentials is not None self._publish_apk(powershell, version, notes, credentials) except Exception: if current_commit() != base_commit: committed = True if not committed: if staged: subprocess.run( ["git", "restore", "--staged", ":/"], cwd=PROJECT_ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) if publish_apk: for path, content in backups.items(): if content is None: if path.exists(): path.unlink() else: path.write_bytes(content) self._log_event( "Restored version files because no release commit was created." ) else: self._log_event( "No update commit was created; reviewed files were left in the working tree." ) else: if publish_apk: self._log_event( "The release commit exists. Version files were kept; use " "'Publish Current APK Only' after correcting the publication problem." ) else: self._log_event( "The update commit exists locally; resolve the push problem without " "rewriting the commit." ) raise def _apk_only_worker( self, version: ReleaseVersion, notes: str, credentials: dict[str, str], ) -> None: self._preflight_git(require_clean=True) local_commit = current_commit() remote_line = git_output("ls-remote", "origin", "refs/heads/main") remote_commit = remote_line.split()[0] if remote_line else "" if local_commit != remote_commit: raise ReleaseError( "Current HEAD does not match origin/main; push or update the checkout first." ) powershell = shutil.which("powershell.exe") or shutil.which("powershell") if not powershell: raise ReleaseError("Windows PowerShell must be available on PATH.") self._publish_apk(powershell, version, notes, credentials) self.events.put(("commit", local_commit)) def _publish_apk( self, powershell: str, version: ReleaseVersion, notes: str, credentials: dict[str, str], ) -> None: environment = os.environ.copy() environment.update(credentials) self._run_command( [ powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(PUBLISH_SCRIPT), "-VersionName", version.version_name, "-VersionCode", str(version.version_code), "-ReleaseNotes", notes, ], "Build, sign, and publish APK", environment=environment, ) def _run_command( self, args: Sequence[str], label: str, *, environment: dict[str, str] | None = None, ) -> None: if self.cancel_event.is_set(): raise ReleaseCancelled("Release cancelled.") self._log_event(f"\n--- {label} ---\n$ {' '.join(args)}") creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0 process = subprocess.Popen( list(args), cwd=PROJECT_ROOT, env=environment, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, creationflags=creation_flags, ) self.current_process = process assert process.stdout is not None output_lines: deque[str] = deque(maxlen=FAILURE_OUTPUT_LINE_LIMIT) try: for line in process.stdout: rendered = line.rstrip("\n") output_lines.append(rendered) self._log_event(rendered) return_code = process.wait() finally: self.current_process = None if self.cancel_event.is_set(): raise ReleaseCancelled("Release cancelled.") if return_code != 0: raise ReleaseCommandError( label, args, return_code, "\n".join(output_lines), ) self._log_event(f"✓ {label} passed") def _request_approval( self, review: str, *, title: str = "Review staged release", heading: str = "Inspect every staged path before publishing", approve_label: str = "Commit, Push, and Publish", cancel_label: str = "Cancel release", ) -> bool: response: dict[str, bool] = {} ready = threading.Event() self.events.put( ( "approval", review, title, heading, approve_label, cancel_label, response, ready, ) ) while not ready.wait(0.2): if self.cancel_event.is_set(): return False return response.get("approved", False) def _show_approval( self, review: str, *, title: str, heading: str, approve_label: str, cancel_label: str, ) -> bool: decision = {"approved": False} dialog = tk.Toplevel(self.root) dialog.title(title) dialog.geometry("820x640") dialog.minsize(640, 420) dialog.configure(bg=self.BG) dialog.transient(self.root) dialog.grab_set() dialog.rowconfigure(1, weight=1) dialog.columnconfigure(0, weight=1) ttk.Label( dialog, text=heading, style="Title.TLabel", ).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10)) review_text = scrolledtext.ScrolledText( dialog, wrap="none", bg="#071110", fg="#cfe2dc", insertbackground=self.TEXT, relief="flat", padx=9, pady=9, font=("Cascadia Mono", 9), ) review_text.grid(row=1, column=0, sticky="nsew", padx=18) review_text.insert("1.0", review) review_text.configure(state="disabled") buttons = ttk.Frame(dialog, padding=(18, 12, 18, 16)) buttons.grid(row=2, column=0, sticky="ew") def finish(approved: bool) -> None: decision["approved"] = approved dialog.grab_release() dialog.destroy() ttk.Button(buttons, text=cancel_label, command=lambda: finish(False)).pack( side="left" ) ttk.Button( buttons, text=approve_label, style="Accent.TButton", command=lambda: finish(True), ).pack(side="right") dialog.protocol("WM_DELETE_WINDOW", lambda: finish(False)) dialog.wait_window() return decision["approved"] def cancel(self) -> None: if not self.busy: return self.cancel_event.set() self.status_var.set("Cancelling…") process = self.current_process if process and process.poll() is None: try: if os.name == "nt": subprocess.Popen( ["taskkill", "/PID", str(process.pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW, ) else: process.terminate() except OSError: pass def _copy_commands(self) -> None: selected = self.command_tabs.select() widget = self.deploy_commands if selected == str(self.deploy_commands.master) else self.verify_commands value = widget.get("1.0", "end").strip() + "\n" self.root.clipboard_clear() self.root.clipboard_append(value) self.status_var.set("TrueNAS commands copied") def _poll_events(self) -> None: try: while True: event = self.events.get_nowait() kind = event[0] if kind == "log": self._append_log(str(event[1])) elif kind == "approval": review, title, heading, approve_label, cancel_label = event[1:6] response, ready = event[6], event[7] approved = self._show_approval( str(review), title=str(title), heading=str(heading), approve_label=str(approve_label), cancel_label=str(cancel_label), ) assert isinstance(response, dict) response["approved"] = approved assert isinstance(ready, threading.Event) ready.set() elif kind == "commit": self.last_commit = str(event[1]) self._set_text( self.deploy_commands, generate_truenas_deploy_commands(self.last_commit), readonly=True, ) elif kind == "done": self._finish(str(event[1]), str(event[2])) except queue.Empty: pass self.root.after(100, self._poll_events) def _finish(self, result: str, detail: str) -> None: self.busy = False self.progress.stop() self.release_button.configure(state="normal") self.retry_button.configure(state="normal") self.refresh_button.configure(state="normal") self.release_mode_toggle.configure(state="normal") self.run_tests_toggle.configure(state="normal") self.cancel_button.configure(state="disabled") self.keystore_password_var.set("") self.key_password_var.set("") if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file(): self.token_var.set(read_saved_gitea_token()) if result == "success": self.status_var.set("Workflow completed") self._append_log("\n✓ Workflow completed successfully.") messagebox.showinfo( "Workflow complete", detail, parent=self.root, ) elif result == "cancelled": self.status_var.set("Workflow cancelled") self._append_log(f"\nCancelled: {detail}") else: self.status_var.set("Workflow failed") self._append_log(f"\nERROR: {detail}") messagebox.showerror("Workflow failed", detail, parent=self.root) self.refresh() def _close(self) -> None: if self.busy: if not messagebox.askyesno( "Release is running", "Cancel the active command and close the release manager?", parent=self.root, ): return self.cancel() self.root.destroy() root = tk.Tk() ReleaseManagerApp(root) if smoke_test: root.withdraw() root.update_idletasks() root.update() root.destroy() return 0 root.mainloop() return 0 def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Healer Man visual release manager") parser.add_argument( "--check", action="store_true", help="print release/version/Git readiness without opening the GUI", ) parser.add_argument( "--check-release", action="store_true", help="check all release candidates, including untracked files", ) parser.add_argument( "--smoke-test", action="store_true", help=argparse.SUPPRESS, ) args = parser.parse_args(argv) if args.check_release: try: paths = validate_release_candidates() print( f"Release candidate check passed: {len(paths)} changed path(s), " "including untracked files." ) except Exception as exc: print(f"Release candidate check failed: {exc}", file=sys.stderr) return 1 return 0 if args.check: try: print(json.dumps(check_summary(), indent=2)) except Exception as exc: print(f"Release manager check failed: {exc}", file=sys.stderr) return 1 return 0 return launch_gui(smoke_test=args.smoke_test) if __name__ == "__main__": raise SystemExit(main())