Files
healer-man/scripts/release_manager.py
T
2026-08-17 17:12:02 -04:00

1392 lines
57 KiB
Python

"""Visual, fail-closed release manager for Healer Man.
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 threading
import webbrowser
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",
}
class ReleaseError(RuntimeError):
"""A release precondition or command failed."""
class ReleaseCancelled(ReleaseError):
"""The user cancelled an active release."""
@dataclass(frozen=True)
class ReleaseVersion:
version_name: str
version_code: int
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 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")
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 → publish 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()
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)
)
bump_box = ttk.Combobox(
release_panel,
textvariable=self.bump_var,
values=("Patch", "Minor", "Major"),
state="readonly",
width=10,
)
bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0))
bump_box.bind("<<ComboboxSelected>>", 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)
)
ttk.Entry(release_panel, textvariable=self.next_version_var, width=15).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)
)
ttk.Entry(release_panel, textvariable=self.next_code_var, width=12).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", 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)
)
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", 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))
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._suggest_versions()
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,
)
if not self.release_notes.get("1.0", "end").strip():
self.release_notes.insert(
"1.0", f"Healer Man {self.next_version_var.get()} app and Android update."
)
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))
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(),
}
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
try:
version = self._requested_version()
credentials = self._credentials()
toolchain_ready, toolchain_detail = android_toolchain_status()
if not toolchain_ready:
raise ReleaseError(toolchain_detail)
commit_message = self.commit_message_var.get().strip()
notes = self.release_notes.get("1.0", "end").strip()
if not commit_message:
raise ReleaseError("Commit message is required.")
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 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.",
parent=self.root,
):
return
self._begin_worker(
"Validating release",
lambda: self._full_release_worker(version, commit_message, notes, credentials),
)
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),
)
def _begin_worker(self, label: str, target: Callable[[], None]) -> 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.cancel_button.configure(state="normal")
self._append_log(f"\n=== {label} ===")
threading.Thread(target=self._worker_wrapper, args=(target,), daemon=True).start()
def _worker_wrapper(self, target: Callable[[], None]) -> 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", "Release workflow completed."))
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,
commit_message: str,
notes: str,
credentials: dict[str, str],
) -> None:
self._preflight_git(require_clean=False)
npm = shutil.which("npm")
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
if not npm or not powershell:
raise ReleaseError("npm and Windows PowerShell must be available on PATH.")
managed_files = (PACKAGE_FILE, PACKAGE_LOCK_FILE, RELEASE_VERSION_FILE)
backups = {
path: path.read_bytes() if path.exists() else None for path in managed_files
}
staged = False
committed = False
base_commit = current_commit()
try:
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}."
)
checks = (
([npm, "run", "content:test"], "Content tests"),
([npm, "run", "server:test"], "Server tests"),
([npm, "test"], "Application tests"),
([npm, "run", "build"], "Production build"),
([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
)
for command, label in checks:
self._run_command(command, label)
self._run_command(["git", "diff", "--check"], "Whitespace check")
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")
review = (
f"Version: {version.version_name}\n"
f"Android code: {version.version_code}\n"
f"Commit: {commit_message}\n\n"
f"Staged paths ({len(staged_paths)}):\n"
+ "\n".join(staged_paths)
+ f"\n\nDiff summary:\n{stat}\n\nCommit, push main, and publish the APK?"
)
approved = self._request_approval(review)
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))
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,
)
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(
"The release commit exists. Version files were kept; use "
"'Publish Current APK Only' after correcting the publication problem."
)
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
try:
for line in process.stdout:
self._log_event(line.rstrip("\n"))
return_code = process.wait()
finally:
self.current_process = None
if self.cancel_event.is_set():
raise ReleaseCancelled("Release cancelled.")
if return_code != 0:
raise ReleaseError(f"{label} failed with exit code {return_code}.")
self._log_event(f"✓ {label} passed")
def _request_approval(self, review: str) -> bool:
response: dict[str, bool] = {}
ready = threading.Event()
self.events.put(("approval", review, response, ready))
while not ready.wait(0.2):
if self.cancel_event.is_set():
return False
return response.get("approved", False)
def _show_staged_review(self, review: str) -> bool:
decision = {"approved": False}
dialog = tk.Toplevel(self.root)
dialog.title("Review staged release")
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="Inspect every staged path before publishing",
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 release", command=lambda: finish(False)).pack(
side="left"
)
ttk.Button(
buttons,
text="Commit, Push, and Publish",
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, response, ready = event[1], event[2], event[3]
approved = self._show_staged_review(str(review))
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.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("Release completed")
self._append_log("\n✓ Release workflow completed successfully.")
messagebox.showinfo(
"Release complete",
"Git main and the signed APK release are published.\n\n"
"Use the TrueNAS Deploy tab after stopping the app, then the Verify tab after restarting it.",
parent=self.root,
)
elif result == "cancelled":
self.status_var.set("Release cancelled")
self._append_log(f"\nCancelled: {detail}")
else:
self.status_var.set("Release failed")
self._append_log(f"\nERROR: {detail}")
messagebox.showerror("Release 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(
"--smoke-test",
action="store_true",
help=argparse.SUPPRESS,
)
args = parser.parse_args(argv)
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())