Files
healer-man/scripts/release_manager.py
T
2026-09-26 17:51:56 -04:00

1994 lines
82 KiB
Python

"""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 = "#0b1220"
PANEL = "#172337"
PANEL_2 = "#22344d"
TEXT = "#f2f6fc"
MUTED = "#aabbd0"
ACCENT = "#63dfb5"
ACCENT_DARK = "#214a47"
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")
width = min(1100, max(700, self.root.winfo_screenwidth() - 60))
height = min(740, max(500, self.root.winfo_screenheight() - 100))
self.root.geometry(f"{width}x{height}")
self.root.minsize(700, 500)
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, font=("Segoe UI", 10))
style.configure("TFrame", background=self.BG)
style.configure("Panel.TFrame", background=self.PANEL)
style.configure(
"Panel.TLabel", background=self.PANEL, foreground=self.TEXT
)
style.configure(
"Muted.Panel.TLabel", background=self.PANEL, foreground=self.MUTED
)
style.configure(
"Section.Panel.TLabel",
background=self.PANEL,
foreground=self.TEXT,
font=("Segoe UI Semibold", 13),
)
style.configure(
"Accent.TButton",
background=self.ACCENT,
foreground="#082019",
font=("Segoe UI Semibold", 10),
padding=(16, 11),
)
style.map("Accent.TButton", background=[("active", "#8af1cc"), ("disabled", self.PANEL_2)])
style.configure("TButton", background=self.PANEL_2, foreground=self.TEXT, padding=(12, 8))
style.map("TButton", background=[("active", "#314866")], foreground=[("disabled", self.MUTED)])
style.configure("Panel.TCheckbutton", background=self.PANEL, foreground=self.TEXT)
style.map("Panel.TCheckbutton", background=[("active", self.PANEL)])
style.configure("Panel.TRadiobutton", background=self.PANEL, foreground=self.TEXT)
style.map("Panel.TRadiobutton", background=[("active", self.PANEL)])
style.configure(
"TEntry",
fieldbackground="#101b2b",
foreground=self.TEXT,
insertcolor=self.TEXT,
bordercolor="#3b5068",
padding=6,
)
style.configure(
"TCombobox",
fieldbackground="#101b2b",
foreground=self.TEXT,
arrowcolor=self.TEXT,
)
style.configure("Horizontal.TProgressbar", background=self.ACCENT, troughcolor=self.PANEL_2)
style.configure("TNotebook", background=self.PANEL, borderwidth=0)
style.configure(
"TNotebook.Tab",
background=self.PANEL_2,
foreground=self.MUTED,
padding=(17, 9),
)
style.map(
"TNotebook.Tab",
background=[("selected", self.ACCENT_DARK)],
foreground=[("selected", self.ACCENT)],
)
def _build_ui(self) -> None:
shell = ttk.Frame(self.root, padding=10)
shell.pack(fill="both", expand=True)
shell.columnconfigure(0, weight=1)
shell.rowconfigure(2, weight=1)
mode_panel = ttk.Frame(shell, style="Panel.TFrame", padding=(10, 6))
mode_panel.grid(row=0, column=0, sticky="ew", pady=(0, 8))
mode_panel.columnconfigure(3, weight=1)
ttk.Label(mode_panel, text="Mode", style="Muted.Panel.TLabel").grid(
row=0, column=0, sticky="w", padx=(0, 12)
)
self.git_truenas_only_var = tk.BooleanVar(value=DEFAULT_GIT_TRUENAS_ONLY)
self.release_mode_toggle = ttk.Radiobutton(
mode_panel,
text="Git + TrueNAS",
variable=self.git_truenas_only_var,
value=True,
command=self._update_release_mode,
style="Panel.TRadiobutton",
)
self.release_mode_toggle.grid(row=0, column=1, sticky="w", padx=(0, 18))
self.apk_mode_toggle = ttk.Radiobutton(
mode_panel,
text="Include signed APK",
variable=self.git_truenas_only_var,
value=False,
command=self._update_release_mode,
style="Panel.TRadiobutton",
)
self.apk_mode_toggle.grid(row=0, column=2, sticky="w")
self.refresh_button = ttk.Button(mode_panel, text="Refresh", command=self.refresh)
self.refresh_button.grid(row=0, column=4, sticky="e")
self.git_summary_var = tk.StringVar(value="Loading Git status…")
setup_host = ttk.Frame(shell)
setup_host.grid(row=1, column=0, sticky="nsew")
setup_host.columnconfigure(0, weight=1)
setup_host.rowconfigure(0, weight=1)
self.setup_canvas = tk.Canvas(
setup_host, bg=self.BG, highlightthickness=0, borderwidth=0, height=220
)
self.setup_canvas.grid(row=0, column=0, sticky="nsew")
setup_scrollbar = ttk.Scrollbar(
setup_host, orient="vertical", command=self.setup_canvas.yview
)
setup_scrollbar.grid(row=0, column=1, sticky="ns", padx=(4, 0))
self.setup_canvas.configure(yscrollcommand=setup_scrollbar.set)
self.setup_content = ttk.Frame(self.setup_canvas)
self.setup_content.columnconfigure(0, weight=3)
self.setup_content.columnconfigure(1, weight=2)
self.setup_window = self.setup_canvas.create_window(
(0, 0), window=self.setup_content, anchor="nw"
)
self.setup_canvas.bind(
"<Configure>",
lambda event: self.setup_canvas.itemconfigure(self.setup_window, width=event.width),
)
self.setup_content.bind(
"<Configure>",
lambda _event: self.setup_canvas.configure(
scrollregion=self.setup_canvas.bbox("all")
),
)
self.setup_canvas.bind(
"<MouseWheel>",
self._scroll_setup,
)
release_panel = ttk.Frame(self.setup_content, style="Panel.TFrame", padding=12)
release_panel.grid(row=0, column=0, sticky="nsew")
self.release_panel = release_panel
release_panel.columnconfigure(1, weight=1)
ttk.Label(
release_panel, text="Release details", style="Section.Panel.TLabel",
).grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 8))
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.run_tests_var = tk.BooleanVar(value=DEFAULT_RUN_TESTS)
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=(7, 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=(7, 0))
self.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)
)
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=3,
wrap="word",
bg="#101b2b",
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.run_tests_toggle = ttk.Checkbutton(
release_panel,
text="Run all test suites before release",
variable=self.run_tests_var,
command=self._update_test_mode,
style="Panel.TCheckbutton",
)
self.run_tests_toggle.grid(
row=6, column=0, columnspan=4, sticky="w", pady=(10, 0)
)
ttk.Label(
release_panel,
textvariable=self.test_mode_hint_var,
style="Muted.Panel.TLabel",
wraplength=620,
).grid(row=7, column=0, columnspan=4, sticky="w", pady=(4, 0))
self.apk_detail_widgets = tuple(
widget
for row in (2, 3, 5)
for widget in release_panel.grid_slaves(row=row)
)
secrets_panel = ttk.Frame(self.setup_content, style="Panel.TFrame", padding=12)
secrets_panel.grid(row=0, column=1, sticky="nsew", padx=(8, 0))
self.secrets_panel = secrets_panel
secrets_panel.columnconfigure(1, weight=1)
ttk.Label(
secrets_panel, text="Signing and Gitea", style="Section.Panel.TLabel",
).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",
wraplength=260,
).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,
style="Panel.TCheckbutton",
).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=8, column=0, columnspan=3, sticky="w", pady=(5, 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=260,
).grid(row=9, column=0, columnspan=3, sticky="w", pady=(7, 0))
workspace = ttk.Notebook(shell)
workspace.grid(row=2, column=0, sticky="nsew", pady=(8, 0))
self.workspace_tabs = workspace
log_panel = ttk.Frame(workspace, style="Panel.TFrame", padding=14)
commands_panel = ttk.Frame(workspace, style="Panel.TFrame", padding=14)
workspace.add(log_panel, text="Activity")
workspace.add(commands_panel, text="TrueNAS commands")
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")
ttk.Label(
log_panel,
textvariable=self.git_summary_var,
style="Muted.Panel.TLabel",
wraplength=1060,
).grid(row=1, column=0, sticky="ew", pady=(4, 8))
self.log = scrolledtext.ScrolledText(
log_panel,
wrap="word",
bg="#101b2b",
fg=self.TEXT,
insertbackground=self.TEXT,
relief="flat",
font=("Cascadia Mono", 10),
state="disabled",
)
self.log.grid(row=2, column=0, sticky="nsew")
ttk.Label(
commands_panel,
text="Deploy from TrueNAS after the Git push",
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)
self.actions = actions
actions.grid(row=3, column=0, sticky="ew", pady=(8, 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()
self._bind_setup_scroll(self.setup_content)
self.root.bind("<Configure>", self._fit_setup_height)
self.root.after_idle(self._fit_setup_height)
def _bind_setup_scroll(self, widget: tk.Misc) -> None:
if not isinstance(widget, (tk.Text, ttk.Combobox)):
widget.bind("<MouseWheel>", self._scroll_setup, add="+")
for child in widget.winfo_children():
self._bind_setup_scroll(child)
def _scroll_setup(self, event: tk.Event[tk.Misc]) -> str:
if event.delta:
self.setup_canvas.yview_scroll(-1 if event.delta > 0 else 1, "units")
return "break"
def _fit_setup_height(self, event: tk.Event[tk.Misc] | None = None) -> None:
if event is not None and event.widget is not self.root:
return
self._layout_setup_panels()
available = max(140, int(self.root.winfo_height() * 0.43))
desired = min(self.setup_content.winfo_reqheight(), available)
if int(self.setup_canvas.cget("height")) != desired:
self.setup_canvas.configure(height=desired)
def _layout_setup_panels(self) -> None:
apk_mode = not self.git_truenas_only_var.get()
stacked = apk_mode and self.root.winfo_width() < 940
layout = (apk_mode, stacked)
if layout == getattr(self, "_setup_layout", None):
return
self._setup_layout = layout
if not apk_mode:
self.release_panel.grid_configure(
row=0, column=0, columnspan=2, padx=0, pady=0
)
self.secrets_panel.grid_remove()
elif stacked:
self.release_panel.grid_configure(
row=0, column=0, columnspan=2, padx=0, pady=0
)
self.secrets_panel.grid_configure(
row=1, column=0, columnspan=2, padx=0, pady=(8, 0)
)
else:
self.release_panel.grid_configure(
row=0, column=0, columnspan=1, padx=(0, 4), pady=0
)
self.secrets_panel.grid_configure(
row=0, column=1, columnspan=1, padx=(4, 0), pady=0
)
self.root.after_idle(self._fit_setup_height)
def _update_release_mode(self) -> None:
git_truenas_only = self.git_truenas_only_var.get()
if git_truenas_only:
self.retry_button.grid_remove()
for widget in self.apk_detail_widgets:
widget.grid_remove()
else:
self.retry_button.grid()
for widget in self.apk_detail_widgets:
widget.grid()
self._layout_setup_panels()
self.setup_canvas.yview_moveto(0)
self.root.after_idle(self._fit_setup_height)
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")
else:
self.release_button.configure(text="Validate, Push, and Publish APK")
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="word",
bg="#101b2b",
fg=self.TEXT,
relief="flat",
padx=12,
pady=10,
font=("Cascadia Mono", 10),
)
widget.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(frame, orient="vertical", command=widget.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
widget.configure(yscrollcommand=scrollbar.set)
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.workspace_tabs.select(0)
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.apk_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.apk_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._append_log("\n✓ Workflow completed successfully.")
if self.git_truenas_only_var.get():
self.workspace_tabs.select(1)
messagebox.showinfo(
"Workflow complete",
detail,
parent=self.root,
)
elif result == "cancelled":
self._append_log(f"\nCancelled: {detail}")
else:
self._append_log(f"\nERROR: {detail}")
messagebox.showerror("Workflow failed", detail, parent=self.root)
self.refresh()
self.status_var.set({
"success": "Workflow completed",
"cancelled": "Workflow cancelled",
}.get(result, "Workflow failed"))
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()
app = ReleaseManagerApp(root)
if smoke_test:
root.withdraw()
root.update_idletasks()
root.update()
app.git_truenas_only_var.set(False)
app._update_release_mode()
root.update_idletasks()
assert app.secrets_panel.winfo_manager() == "grid"
assert app.release_panel.grid_info()["columnspan"] == 1
app.git_truenas_only_var.set(True)
app._update_release_mode()
root.update_idletasks()
assert app.release_panel.grid_info()["columnspan"] == 2
assert not app.secrets_panel.winfo_manager()
root.geometry("700x500")
root.deiconify()
root.update()
app.git_truenas_only_var.set(False)
app._update_release_mode()
root.update()
app._fit_setup_height()
assert app.secrets_panel.grid_info()["row"] == 1
assert app.release_panel.grid_info()["columnspan"] == 2
assert int(app.setup_canvas.cget("height")) <= 215
assert app.setup_canvas.yview()[1] < 1
app.setup_canvas.yview_moveto(1)
root.update_idletasks()
assert app.setup_canvas.yview()[0] > 0
assert app.release_button.winfo_manager() == "grid"
assert app.actions.winfo_y() + app.actions.winfo_height() <= root.winfo_height()
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())