1392 lines
57 KiB
Python
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").strip()
|
|
|
|
|
|
def android_toolchain_status() -> tuple[bool, str]:
|
|
workspace_root = PROJECT_ROOT.parent
|
|
wow_root = workspace_root / "wow335a"
|
|
configured_java = os.environ.get("JAVA_HOME", "").strip()
|
|
java_candidates: list[Path] = []
|
|
if configured_java:
|
|
java_candidates.append(Path(configured_java))
|
|
portable_java_roots = (
|
|
PROJECT_ROOT / ".tools" / "temurin-21",
|
|
wow_root / "dungeon-export-work" / "tools" / "temurin-21",
|
|
)
|
|
for portable_java_root in portable_java_roots:
|
|
if portable_java_root.is_dir():
|
|
java_candidates.extend(
|
|
sorted(
|
|
(path for path in portable_java_root.iterdir() if path.is_dir()),
|
|
reverse=True,
|
|
)
|
|
)
|
|
java_home = next(
|
|
(path for path in java_candidates if (path / "bin" / "java.exe").is_file()),
|
|
None,
|
|
)
|
|
|
|
configured_sdk = (
|
|
os.environ.get("ANDROID_SDK_ROOT", "").strip()
|
|
or os.environ.get("ANDROID_HOME", "").strip()
|
|
)
|
|
sdk_candidates = ([Path(configured_sdk)] if configured_sdk else []) + [
|
|
wow_root / ".android-sdk"
|
|
]
|
|
sdk_root = next(
|
|
(
|
|
path
|
|
for path in sdk_candidates
|
|
if (path / "platforms" / "android-36" / "android.jar").is_file()
|
|
),
|
|
None,
|
|
)
|
|
missing: list[str] = []
|
|
if java_home is None:
|
|
missing.append("JDK 21 (set JAVA_HOME or install the documented portable JDK)")
|
|
if sdk_root is None:
|
|
missing.append("Android SDK Platform 36 (set ANDROID_SDK_ROOT)")
|
|
if missing:
|
|
return False, "; ".join(missing)
|
|
return True, f"Java: {java_home}; Android SDK: {sdk_root}"
|
|
|
|
|
|
def check_summary() -> dict[str, object]:
|
|
package_version = read_package_version()
|
|
release_version = read_release_version()
|
|
next_version = bump_semver(package_version)
|
|
toolchain_ready, toolchain_detail = android_toolchain_status()
|
|
return {
|
|
"project": str(PROJECT_ROOT),
|
|
"branch": git_output("branch", "--show-current"),
|
|
"commit": current_commit(),
|
|
"packageVersion": package_version,
|
|
"trackedAndroidVersion": release_version.version_name,
|
|
"trackedAndroidVersionCode": release_version.version_code,
|
|
"suggestedNextVersion": next_version,
|
|
"suggestedNextVersionCode": suggest_version_code(
|
|
next_version, release_version.version_code
|
|
),
|
|
"androidToolchainReady": toolchain_ready,
|
|
"androidToolchain": toolchain_detail,
|
|
"releaseKeystoreReady": DEFAULT_KEYSTORE_FILE.is_file(),
|
|
"savedSigningCredentialReady": SAVED_SIGNING_PASSWORD_FILE.is_file(),
|
|
"savedGiteaTokenReady": SAVED_GITEA_TOKEN_FILE.is_file(),
|
|
"workingTree": git_output("status", "--short", "--branch"),
|
|
}
|
|
|
|
|
|
def launch_gui(*, smoke_test: bool = False) -> int:
|
|
import tkinter as tk
|
|
from tkinter import filedialog, messagebox, scrolledtext, ttk
|
|
|
|
class ReleaseManagerApp:
|
|
BG = "#0c1515"
|
|
PANEL = "#122222"
|
|
PANEL_2 = "#172b2a"
|
|
TEXT = "#e9f4ef"
|
|
MUTED = "#91aaa2"
|
|
ACCENT = "#63d9a4"
|
|
ACCENT_DARK = "#183b31"
|
|
WARNING = "#f2c66d"
|
|
ERROR = "#ff8b83"
|
|
|
|
def __init__(self, root: tk.Tk) -> None:
|
|
self.root = root
|
|
self.events: queue.Queue[tuple[object, ...]] = queue.Queue()
|
|
self.cancel_event = threading.Event()
|
|
self.current_process: subprocess.Popen[str] | None = None
|
|
self.busy = False
|
|
self.last_commit = current_commit()
|
|
self._configure_window()
|
|
self._build_ui()
|
|
self.refresh()
|
|
self.root.after(100, self._poll_events)
|
|
self.root.protocol("WM_DELETE_WINDOW", self._close)
|
|
|
|
def _configure_window(self) -> None:
|
|
self.root.title("Healer Man Release Manager")
|
|
self.root.geometry("1120x820")
|
|
self.root.minsize(960, 700)
|
|
self.root.configure(bg=self.BG)
|
|
style = ttk.Style(self.root)
|
|
try:
|
|
style.theme_use("clam")
|
|
except tk.TclError:
|
|
pass
|
|
style.configure(".", background=self.BG, foreground=self.TEXT)
|
|
style.configure("TFrame", background=self.BG)
|
|
style.configure("Panel.TFrame", background=self.PANEL)
|
|
style.configure(
|
|
"Title.TLabel",
|
|
background=self.BG,
|
|
foreground=self.TEXT,
|
|
font=("Segoe UI Semibold", 20),
|
|
)
|
|
style.configure(
|
|
"Sub.TLabel",
|
|
background=self.BG,
|
|
foreground=self.MUTED,
|
|
font=("Segoe UI", 10),
|
|
)
|
|
style.configure(
|
|
"Panel.TLabel", background=self.PANEL, foreground=self.TEXT
|
|
)
|
|
style.configure(
|
|
"Muted.Panel.TLabel", background=self.PANEL, foreground=self.MUTED
|
|
)
|
|
style.configure(
|
|
"Accent.TButton",
|
|
background=self.ACCENT,
|
|
foreground="#06110d",
|
|
font=("Segoe UI Semibold", 10),
|
|
padding=(13, 9),
|
|
)
|
|
style.map("Accent.TButton", background=[("active", "#84e7bb")])
|
|
style.configure("TButton", padding=(10, 7))
|
|
style.configure(
|
|
"TEntry",
|
|
fieldbackground="#0a1716",
|
|
foreground=self.TEXT,
|
|
insertcolor=self.TEXT,
|
|
bordercolor="#31504a",
|
|
)
|
|
style.configure(
|
|
"TCombobox",
|
|
fieldbackground="#0a1716",
|
|
foreground=self.TEXT,
|
|
arrowcolor=self.TEXT,
|
|
)
|
|
style.configure("TNotebook", background=self.PANEL, borderwidth=0)
|
|
style.configure(
|
|
"TNotebook.Tab",
|
|
background=self.PANEL_2,
|
|
foreground=self.MUTED,
|
|
padding=(12, 7),
|
|
)
|
|
style.map(
|
|
"TNotebook.Tab",
|
|
background=[("selected", self.ACCENT_DARK)],
|
|
foreground=[("selected", self.ACCENT)],
|
|
)
|
|
|
|
def _build_ui(self) -> None:
|
|
shell = ttk.Frame(self.root, padding=18)
|
|
shell.pack(fill="both", expand=True)
|
|
shell.columnconfigure(0, weight=3)
|
|
shell.columnconfigure(1, weight=2)
|
|
shell.rowconfigure(2, weight=1)
|
|
|
|
title = ttk.Frame(shell)
|
|
title.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 14))
|
|
title.columnconfigure(0, weight=1)
|
|
ttk.Label(title, text="Healer Man Release Manager", style="Title.TLabel").grid(
|
|
row=0, column=0, sticky="w"
|
|
)
|
|
ttk.Label(
|
|
title,
|
|
text="Validate → inspect → commit → push → 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().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
|
|
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())
|