1864 lines
76 KiB
Python
1864 lines
76 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 = "#0c1515"
|
|
PANEL = "#122222"
|
|
PANEL_2 = "#172b2a"
|
|
TEXT = "#e9f4ef"
|
|
MUTED = "#91aaa2"
|
|
ACCENT = "#63d9a4"
|
|
ACCENT_DARK = "#183b31"
|
|
WARNING = "#f2c66d"
|
|
ERROR = "#ff8b83"
|
|
|
|
def __init__(self, root: tk.Tk) -> None:
|
|
self.root = root
|
|
self.events: queue.Queue[tuple[object, ...]] = queue.Queue()
|
|
self.cancel_event = threading.Event()
|
|
self.current_process: subprocess.Popen[str] | None = None
|
|
self.busy = False
|
|
self.last_commit = current_commit()
|
|
self._configure_window()
|
|
self._build_ui()
|
|
self.refresh()
|
|
self.root.after(100, self._poll_events)
|
|
self.root.protocol("WM_DELETE_WINDOW", self._close)
|
|
|
|
def _configure_window(self) -> None:
|
|
self.root.title("Healer Man Release Manager")
|
|
self.root.geometry("1120x820")
|
|
self.root.minsize(960, 700)
|
|
self.root.configure(bg=self.BG)
|
|
style = ttk.Style(self.root)
|
|
try:
|
|
style.theme_use("clam")
|
|
except tk.TclError:
|
|
pass
|
|
style.configure(".", background=self.BG, foreground=self.TEXT)
|
|
style.configure("TFrame", background=self.BG)
|
|
style.configure("Panel.TFrame", background=self.PANEL)
|
|
style.configure(
|
|
"Title.TLabel",
|
|
background=self.BG,
|
|
foreground=self.TEXT,
|
|
font=("Segoe UI Semibold", 20),
|
|
)
|
|
style.configure(
|
|
"Sub.TLabel",
|
|
background=self.BG,
|
|
foreground=self.MUTED,
|
|
font=("Segoe UI", 10),
|
|
)
|
|
style.configure(
|
|
"Panel.TLabel", background=self.PANEL, foreground=self.TEXT
|
|
)
|
|
style.configure(
|
|
"Muted.Panel.TLabel", background=self.PANEL, foreground=self.MUTED
|
|
)
|
|
style.configure(
|
|
"Accent.TButton",
|
|
background=self.ACCENT,
|
|
foreground="#06110d",
|
|
font=("Segoe UI Semibold", 10),
|
|
padding=(13, 9),
|
|
)
|
|
style.map("Accent.TButton", background=[("active", "#84e7bb")])
|
|
style.configure("TButton", padding=(10, 7))
|
|
style.configure(
|
|
"TEntry",
|
|
fieldbackground="#0a1716",
|
|
foreground=self.TEXT,
|
|
insertcolor=self.TEXT,
|
|
bordercolor="#31504a",
|
|
)
|
|
style.configure(
|
|
"TCombobox",
|
|
fieldbackground="#0a1716",
|
|
foreground=self.TEXT,
|
|
arrowcolor=self.TEXT,
|
|
)
|
|
style.configure("TNotebook", background=self.PANEL, borderwidth=0)
|
|
style.configure(
|
|
"TNotebook.Tab",
|
|
background=self.PANEL_2,
|
|
foreground=self.MUTED,
|
|
padding=(12, 7),
|
|
)
|
|
style.map(
|
|
"TNotebook.Tab",
|
|
background=[("selected", self.ACCENT_DARK)],
|
|
foreground=[("selected", self.ACCENT)],
|
|
)
|
|
|
|
def _build_ui(self) -> None:
|
|
shell = ttk.Frame(self.root, padding=18)
|
|
shell.pack(fill="both", expand=True)
|
|
shell.columnconfigure(0, weight=3)
|
|
shell.columnconfigure(1, weight=2)
|
|
shell.rowconfigure(2, weight=1)
|
|
|
|
title = ttk.Frame(shell)
|
|
title.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 14))
|
|
title.columnconfigure(0, weight=1)
|
|
ttk.Label(title, text="Healer Man Release Manager", style="Title.TLabel").grid(
|
|
row=0, column=0, sticky="w"
|
|
)
|
|
ttk.Label(
|
|
title,
|
|
text="Validate → inspect → commit → push → deploy, with optional signed APK",
|
|
style="Sub.TLabel",
|
|
).grid(row=1, column=0, sticky="w", pady=(2, 0))
|
|
self.refresh_button = ttk.Button(title, text="Refresh", command=self.refresh)
|
|
self.refresh_button.grid(row=0, column=1, rowspan=2, sticky="e")
|
|
|
|
release_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15)
|
|
release_panel.grid(row=1, column=0, sticky="nsew", padx=(0, 8))
|
|
release_panel.columnconfigure(1, weight=1)
|
|
ttk.Label(
|
|
release_panel, text="Release details", style="Panel.TLabel",
|
|
font=("Segoe UI Semibold", 12),
|
|
).grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 11))
|
|
|
|
self.current_version_var = tk.StringVar(value="—")
|
|
self.current_code_var = tk.StringVar(value="—")
|
|
self.bump_var = tk.StringVar(value="Patch")
|
|
self.next_version_var = tk.StringVar()
|
|
self.next_code_var = tk.StringVar()
|
|
self.commit_message_var = tk.StringVar()
|
|
self.git_truenas_only_var = tk.BooleanVar(
|
|
value=DEFAULT_GIT_TRUENAS_ONLY
|
|
)
|
|
self.run_tests_var = tk.BooleanVar(value=DEFAULT_RUN_TESTS)
|
|
self.release_mode_hint_var = tk.StringVar()
|
|
self.test_mode_hint_var = tk.StringVar()
|
|
|
|
ttk.Label(release_panel, text="Current", style="Muted.Panel.TLabel").grid(
|
|
row=1, column=0, sticky="w"
|
|
)
|
|
ttk.Label(release_panel, textvariable=self.current_version_var, style="Panel.TLabel").grid(
|
|
row=1, column=1, sticky="w", padx=(8, 20)
|
|
)
|
|
ttk.Label(release_panel, text="Android code", style="Muted.Panel.TLabel").grid(
|
|
row=1, column=2, sticky="w"
|
|
)
|
|
ttk.Label(release_panel, textvariable=self.current_code_var, style="Panel.TLabel").grid(
|
|
row=1, column=3, sticky="w", padx=(8, 0)
|
|
)
|
|
|
|
ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid(
|
|
row=2, column=0, sticky="w", pady=(12, 0)
|
|
)
|
|
self.bump_box = ttk.Combobox(
|
|
release_panel,
|
|
textvariable=self.bump_var,
|
|
values=("Patch", "Minor", "Major"),
|
|
state="readonly",
|
|
width=10,
|
|
)
|
|
self.bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0))
|
|
self.bump_box.bind("<<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=4,
|
|
wrap="word",
|
|
bg="#0a1716",
|
|
fg=self.TEXT,
|
|
insertbackground=self.TEXT,
|
|
relief="flat",
|
|
padx=7,
|
|
pady=6,
|
|
font=("Segoe UI", 9),
|
|
)
|
|
self.release_notes.grid(
|
|
row=5, column=1, columnspan=3, sticky="nsew", padx=(8, 0), pady=(9, 0)
|
|
)
|
|
self.release_mode_toggle = ttk.Checkbutton(
|
|
release_panel,
|
|
text="Git + TrueNAS only (skip Android version bump and APK publication)",
|
|
variable=self.git_truenas_only_var,
|
|
command=self._update_release_mode,
|
|
)
|
|
self.release_mode_toggle.grid(
|
|
row=6, column=0, columnspan=4, sticky="w", pady=(12, 0)
|
|
)
|
|
ttk.Label(
|
|
release_panel,
|
|
textvariable=self.release_mode_hint_var,
|
|
style="Muted.Panel.TLabel",
|
|
wraplength=610,
|
|
).grid(row=7, column=0, columnspan=4, sticky="w", pady=(4, 0))
|
|
self.run_tests_toggle = ttk.Checkbutton(
|
|
release_panel,
|
|
text="Run test suites (recommended)",
|
|
variable=self.run_tests_var,
|
|
command=self._update_test_mode,
|
|
)
|
|
self.run_tests_toggle.grid(
|
|
row=8, column=0, columnspan=4, sticky="w", pady=(10, 0)
|
|
)
|
|
ttk.Label(
|
|
release_panel,
|
|
textvariable=self.test_mode_hint_var,
|
|
style="Muted.Panel.TLabel",
|
|
wraplength=610,
|
|
).grid(row=9, column=0, columnspan=4, sticky="w", pady=(4, 0))
|
|
|
|
secrets_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15)
|
|
secrets_panel.grid(row=1, column=1, sticky="nsew", padx=(8, 0))
|
|
secrets_panel.columnconfigure(1, weight=1)
|
|
ttk.Label(
|
|
secrets_panel, text="Signing and Gitea (APK only)", style="Panel.TLabel",
|
|
font=("Segoe UI Semibold", 12),
|
|
).grid(row=0, column=0, columnspan=3, sticky="w")
|
|
ttk.Label(
|
|
secrets_panel,
|
|
text="Signing password and saved token are Windows-encrypted outside Git.",
|
|
style="Muted.Panel.TLabel",
|
|
).grid(row=1, column=0, columnspan=3, sticky="w", pady=(2, 10))
|
|
|
|
self.keystore_var = tk.StringVar(value=str(DEFAULT_KEYSTORE_FILE))
|
|
self.alias_var = tk.StringVar(value="healer-man")
|
|
self.keystore_password_var = tk.StringVar()
|
|
self.key_password_var = tk.StringVar()
|
|
try:
|
|
saved_token = read_saved_gitea_token()
|
|
except ReleaseError:
|
|
saved_token = ""
|
|
self.token_var = tk.StringVar(value=saved_token)
|
|
self.remember_token_var = tk.BooleanVar(value=True)
|
|
credential_rows = (
|
|
("Keystore", self.keystore_var, False),
|
|
("Key alias", self.alias_var, False),
|
|
("Store password", self.keystore_password_var, True),
|
|
("Key password", self.key_password_var, True),
|
|
("Gitea token", self.token_var, True),
|
|
)
|
|
for row, (label, variable, secret) in enumerate(credential_rows, start=2):
|
|
ttk.Label(secrets_panel, text=label, style="Muted.Panel.TLabel").grid(
|
|
row=row, column=0, sticky="w", pady=4
|
|
)
|
|
ttk.Entry(secrets_panel, textvariable=variable, show="•" if secret else "").grid(
|
|
row=row, column=1, sticky="ew", padx=(8, 5), pady=4
|
|
)
|
|
if row == 2:
|
|
ttk.Button(secrets_panel, text="…", width=3, command=self._browse_keystore).grid(
|
|
row=row, column=2, pady=4
|
|
)
|
|
ttk.Checkbutton(
|
|
secrets_panel,
|
|
text="Remember Gitea token on this PC",
|
|
variable=self.remember_token_var,
|
|
).grid(row=7, column=0, columnspan=2, sticky="w", pady=(7, 0))
|
|
ttk.Button(
|
|
secrets_panel,
|
|
text="Forget saved token",
|
|
command=self._forget_saved_token,
|
|
).grid(row=7, column=2, sticky="e", pady=(7, 0))
|
|
ttk.Label(
|
|
secrets_panel,
|
|
text=(
|
|
"Blank password fields use the saved Windows-encrypted signing credential; "
|
|
"enter the Gitea token once and it will load automatically."
|
|
),
|
|
style="Muted.Panel.TLabel",
|
|
wraplength=360,
|
|
).grid(row=8, column=0, columnspan=3, sticky="w", pady=(7, 0))
|
|
|
|
lower = ttk.Panedwindow(shell, orient="horizontal")
|
|
lower.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(16, 0))
|
|
|
|
log_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12)
|
|
commands_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12)
|
|
lower.add(log_panel, weight=3)
|
|
lower.add(commands_panel, weight=2)
|
|
log_panel.rowconfigure(2, weight=1)
|
|
log_panel.columnconfigure(0, weight=1)
|
|
commands_panel.rowconfigure(1, weight=1)
|
|
commands_panel.columnconfigure(0, weight=1)
|
|
|
|
status_row = ttk.Frame(log_panel, style="Panel.TFrame")
|
|
status_row.grid(row=0, column=0, sticky="ew")
|
|
status_row.columnconfigure(0, weight=1)
|
|
self.status_var = tk.StringVar(value="Ready")
|
|
ttk.Label(
|
|
status_row, textvariable=self.status_var, style="Panel.TLabel",
|
|
font=("Segoe UI Semibold", 11),
|
|
).grid(row=0, column=0, sticky="w")
|
|
self.progress = ttk.Progressbar(status_row, mode="indeterminate", length=150)
|
|
self.progress.grid(row=0, column=1, sticky="e")
|
|
self.git_summary_var = tk.StringVar(value="Loading Git status…")
|
|
ttk.Label(
|
|
log_panel,
|
|
textvariable=self.git_summary_var,
|
|
style="Muted.Panel.TLabel",
|
|
wraplength=600,
|
|
).grid(row=1, column=0, sticky="ew", pady=(4, 8))
|
|
self.log = scrolledtext.ScrolledText(
|
|
log_panel,
|
|
wrap="word",
|
|
bg="#071110",
|
|
fg="#cfe2dc",
|
|
insertbackground=self.TEXT,
|
|
relief="flat",
|
|
font=("Cascadia Mono", 9),
|
|
state="disabled",
|
|
)
|
|
self.log.grid(row=2, column=0, sticky="nsew")
|
|
|
|
ttk.Label(
|
|
commands_panel,
|
|
text="TrueNAS shell commands",
|
|
style="Panel.TLabel",
|
|
font=("Segoe UI Semibold", 11),
|
|
).grid(row=0, column=0, sticky="w", pady=(0, 7))
|
|
self.command_tabs = ttk.Notebook(commands_panel)
|
|
self.command_tabs.grid(row=1, column=0, sticky="nsew")
|
|
self.deploy_commands = self._command_text_widget(self.command_tabs)
|
|
self.verify_commands = self._command_text_widget(self.command_tabs)
|
|
self.command_tabs.add(self.deploy_commands.master, text="Deploy")
|
|
self.command_tabs.add(self.verify_commands.master, text="Verify")
|
|
command_buttons = ttk.Frame(commands_panel, style="Panel.TFrame")
|
|
command_buttons.grid(row=2, column=0, sticky="ew", pady=(8, 0))
|
|
ttk.Button(command_buttons, text="Copy selected tab", command=self._copy_commands).pack(
|
|
side="left"
|
|
)
|
|
ttk.Button(command_buttons, text="Open releases", command=lambda: webbrowser.open(RELEASES_URL)).pack(
|
|
side="right"
|
|
)
|
|
|
|
actions = ttk.Frame(shell)
|
|
actions.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(14, 0))
|
|
actions.columnconfigure(0, weight=1)
|
|
self.release_button = ttk.Button(
|
|
actions,
|
|
text="Validate, Push, and Publish APK",
|
|
style="Accent.TButton",
|
|
command=self.start_full_release,
|
|
)
|
|
self.release_button.grid(row=0, column=0, sticky="ew", padx=(0, 7))
|
|
self.retry_button = ttk.Button(
|
|
actions,
|
|
text="Publish Current APK Only",
|
|
command=self.start_apk_only,
|
|
)
|
|
self.retry_button.grid(row=0, column=1, padx=7)
|
|
self.cancel_button = ttk.Button(actions, text="Cancel", command=self.cancel, state="disabled")
|
|
self.cancel_button.grid(row=0, column=2, padx=(7, 0))
|
|
self._update_release_mode()
|
|
self._update_test_mode()
|
|
|
|
def _update_release_mode(self) -> None:
|
|
git_truenas_only = self.git_truenas_only_var.get()
|
|
self.bump_box.configure(state="disabled" if git_truenas_only else "readonly")
|
|
entry_state = "disabled" if git_truenas_only else "normal"
|
|
self.next_version_entry.configure(state=entry_state)
|
|
self.next_code_entry.configure(state=entry_state)
|
|
if git_truenas_only:
|
|
self.release_button.configure(text="Validate and Push Git + TrueNAS Update")
|
|
self.release_mode_hint_var.set(
|
|
"Default mode: keeps the current Android version, skips Android sync and APK "
|
|
"publication, and prepares the pinned TrueNAS deployment commands after push."
|
|
)
|
|
else:
|
|
self.release_button.configure(text="Validate, Push, and Publish APK")
|
|
self.release_mode_hint_var.set(
|
|
"APK mode: bumps the app and Android versions, runs Android sync, and publishes "
|
|
"the signed APK after the Git push."
|
|
)
|
|
self._suggest_versions()
|
|
current_notes = self.release_notes.get("1.0", "end").strip()
|
|
server_default = "Healer Man Git and TrueNAS server update."
|
|
apk_default = (
|
|
f"Healer Man {self.next_version_var.get()} app and Android update."
|
|
)
|
|
generated_apk_notes = re.fullmatch(
|
|
r"Healer Man \d+\.\d+\.\d+ app and Android update\.",
|
|
current_notes,
|
|
)
|
|
if git_truenas_only and (not current_notes or generated_apk_notes):
|
|
self._set_text(self.release_notes, server_default)
|
|
elif not git_truenas_only and (
|
|
not current_notes or current_notes == server_default
|
|
):
|
|
self._set_text(self.release_notes, apk_default)
|
|
|
|
def _update_test_mode(self) -> None:
|
|
if self.run_tests_var.get():
|
|
self.test_mode_hint_var.set(
|
|
"Runs content, server, normal-application, and shelved-application tests."
|
|
)
|
|
else:
|
|
self.test_mode_hint_var.set(
|
|
"Skips those four test suites. Candidate safety checks, content publication, "
|
|
"the production build, and staged-file review still run."
|
|
)
|
|
|
|
def _command_text_widget(self, notebook: ttk.Notebook) -> tk.Text:
|
|
frame = ttk.Frame(notebook)
|
|
frame.rowconfigure(0, weight=1)
|
|
frame.columnconfigure(0, weight=1)
|
|
widget = tk.Text(
|
|
frame,
|
|
wrap="none",
|
|
bg="#071110",
|
|
fg="#cfe2dc",
|
|
relief="flat",
|
|
padx=8,
|
|
pady=8,
|
|
font=("Cascadia Mono", 8),
|
|
)
|
|
widget.grid(row=0, column=0, sticky="nsew")
|
|
return widget
|
|
|
|
def _browse_keystore(self) -> None:
|
|
selected = filedialog.askopenfilename(
|
|
title="Select Android release keystore",
|
|
filetypes=(("Java keystore", "*.jks *.keystore"), ("All files", "*.*")),
|
|
)
|
|
if selected:
|
|
self.keystore_var.set(selected)
|
|
|
|
def _forget_saved_token(self) -> None:
|
|
if not SAVED_GITEA_TOKEN_FILE.exists():
|
|
self.token_var.set("")
|
|
self.status_var.set("No saved Gitea token")
|
|
return
|
|
if not messagebox.askyesno(
|
|
"Forget saved token?",
|
|
"Remove the Windows-encrypted Gitea token from this PC?",
|
|
parent=self.root,
|
|
):
|
|
return
|
|
SAVED_GITEA_TOKEN_FILE.unlink(missing_ok=True)
|
|
self.token_var.set("")
|
|
self.status_var.set("Saved Gitea token removed")
|
|
|
|
def _set_text(self, widget: tk.Text, value: str, *, readonly: bool = False) -> None:
|
|
widget.configure(state="normal")
|
|
widget.delete("1.0", "end")
|
|
widget.insert("1.0", value)
|
|
if readonly:
|
|
widget.configure(state="disabled")
|
|
|
|
def _append_log(self, value: str) -> None:
|
|
self.log.configure(state="normal")
|
|
self.log.insert("end", value.rstrip("\n") + "\n")
|
|
self.log.see("end")
|
|
self.log.configure(state="disabled")
|
|
|
|
def _log_event(self, value: str) -> None:
|
|
self.events.put(("log", value))
|
|
|
|
def refresh(self) -> None:
|
|
if self.busy:
|
|
return
|
|
try:
|
|
package_version = read_package_version()
|
|
release_version = read_release_version()
|
|
status = git_output("status", "--short", "--branch")
|
|
branch = git_output("branch", "--show-current") or "detached HEAD"
|
|
toolchain_ready, _toolchain_detail = android_toolchain_status()
|
|
self.last_commit = current_commit()
|
|
self.current_version_var.set(package_version)
|
|
self.current_code_var.set(str(release_version.version_code))
|
|
self._update_release_mode()
|
|
change_count = len(
|
|
[line for line in status.splitlines() if not line.startswith("##")]
|
|
)
|
|
self.git_summary_var.set(
|
|
f"Branch {branch} • {change_count} changed path(s) • HEAD {self.last_commit[:12]} • "
|
|
f"Android toolchain {'ready' if toolchain_ready else 'missing'}"
|
|
)
|
|
self._set_text(
|
|
self.deploy_commands,
|
|
generate_truenas_deploy_commands(self.last_commit),
|
|
readonly=True,
|
|
)
|
|
self._set_text(
|
|
self.verify_commands,
|
|
generate_truenas_verify_commands(),
|
|
readonly=True,
|
|
)
|
|
self.status_var.set("Ready")
|
|
except Exception as exc:
|
|
self.status_var.set("Configuration error")
|
|
self.git_summary_var.set(str(exc))
|
|
|
|
def _suggest_versions(self) -> None:
|
|
try:
|
|
current = read_package_version()
|
|
release = read_release_version()
|
|
next_version = bump_semver(current, self.bump_var.get().lower())
|
|
next_code = suggest_version_code(next_version, release.version_code)
|
|
self.next_version_var.set(next_version)
|
|
self.next_code_var.set(str(next_code))
|
|
if self.git_truenas_only_var.get():
|
|
self.commit_message_var.set("Update Healer Man server")
|
|
else:
|
|
self.commit_message_var.set(f"Release Healer Man {next_version}")
|
|
except Exception as exc:
|
|
self.status_var.set(str(exc))
|
|
|
|
def _credentials(self) -> dict[str, str]:
|
|
fields = {
|
|
"ANDROID_KEYSTORE_FILE": self.keystore_var.get().strip(),
|
|
"ANDROID_KEY_ALIAS": self.alias_var.get().strip(),
|
|
"ANDROID_KEYSTORE_PASSWORD": self.keystore_password_var.get(),
|
|
"ANDROID_KEY_PASSWORD": self.key_password_var.get(),
|
|
"GITEA_TOKEN": self.token_var.get().strip(),
|
|
}
|
|
for key, value in list(fields.items()):
|
|
if not value:
|
|
fields[key] = os.environ.get(key, "")
|
|
if not fields["GITEA_TOKEN"]:
|
|
fields["GITEA_TOKEN"] = read_saved_gitea_token()
|
|
selected_keystore = os.path.normcase(
|
|
os.path.abspath(fields["ANDROID_KEYSTORE_FILE"])
|
|
)
|
|
default_keystore = os.path.normcase(os.path.abspath(DEFAULT_KEYSTORE_FILE))
|
|
if selected_keystore == default_keystore and (
|
|
not fields["ANDROID_KEYSTORE_PASSWORD"]
|
|
or not fields["ANDROID_KEY_PASSWORD"]
|
|
):
|
|
saved_password = read_saved_signing_password()
|
|
if not fields["ANDROID_KEYSTORE_PASSWORD"]:
|
|
fields["ANDROID_KEYSTORE_PASSWORD"] = saved_password
|
|
if not fields["ANDROID_KEY_PASSWORD"]:
|
|
fields["ANDROID_KEY_PASSWORD"] = saved_password
|
|
missing = [key for key, value in fields.items() if not value]
|
|
if missing:
|
|
raise ReleaseError("Missing release credentials: " + ", ".join(missing))
|
|
if self.remember_token_var.get():
|
|
write_windows_encrypted_secret(
|
|
SAVED_GITEA_TOKEN_FILE, fields["GITEA_TOKEN"], "Gitea token"
|
|
)
|
|
keystore = Path(fields["ANDROID_KEYSTORE_FILE"])
|
|
if not keystore.is_file():
|
|
raise ReleaseError(f"Keystore does not exist: {keystore}")
|
|
return fields
|
|
|
|
def _requested_version(self) -> ReleaseVersion:
|
|
version_name = self.next_version_var.get().strip()
|
|
parse_semver(version_name)
|
|
try:
|
|
version_code = int(self.next_code_var.get().strip())
|
|
except ValueError as exc:
|
|
raise ReleaseError("Android version code must be a whole number.") from exc
|
|
current = read_package_version()
|
|
tracked = read_release_version()
|
|
if parse_semver(version_name) <= parse_semver(current):
|
|
raise ReleaseError(f"Next version must be newer than {current}.")
|
|
if version_code <= tracked.version_code:
|
|
raise ReleaseError(
|
|
f"Android version code must be greater than {tracked.version_code}."
|
|
)
|
|
if version_code > 2_147_483_647:
|
|
raise ReleaseError("Android version code exceeds Android's maximum.")
|
|
return ReleaseVersion(version_name, version_code)
|
|
|
|
def start_full_release(self) -> None:
|
|
if self.busy:
|
|
return
|
|
publish_apk = not self.git_truenas_only_var.get()
|
|
run_tests = self.run_tests_var.get()
|
|
test_summary = (
|
|
"The four test suites will run."
|
|
if run_tests
|
|
else "The four test suites will be skipped; safety checks and the production build still run."
|
|
)
|
|
try:
|
|
commit_message = self.commit_message_var.get().strip()
|
|
if not commit_message:
|
|
raise ReleaseError("Commit message is required.")
|
|
version: ReleaseVersion | None = None
|
|
credentials: dict[str, str] | None = None
|
|
notes = ""
|
|
if publish_apk:
|
|
version = self._requested_version()
|
|
credentials = self._credentials()
|
|
toolchain_ready, toolchain_detail = android_toolchain_status()
|
|
if not toolchain_ready:
|
|
raise ReleaseError(toolchain_detail)
|
|
notes = self.release_notes.get("1.0", "end").strip()
|
|
if not notes:
|
|
raise ReleaseError("Release notes are required.")
|
|
except Exception as exc:
|
|
messagebox.showerror("Cannot start release", str(exc), parent=self.root)
|
|
return
|
|
if not publish_apk:
|
|
if not messagebox.askyesno(
|
|
"Start Git + TrueNAS update?",
|
|
"Run the server validation and production build, review and commit the exact "
|
|
"changed files, then push main?\n\nThe Android version will not change and no "
|
|
"APK will be built or published. The Deploy tab will be pinned to the new commit.\n\n"
|
|
+ test_summary,
|
|
parent=self.root,
|
|
):
|
|
return
|
|
self._begin_worker(
|
|
"Validating Git + TrueNAS update",
|
|
lambda: self._full_release_worker(
|
|
None,
|
|
commit_message,
|
|
"",
|
|
None,
|
|
publish_apk=False,
|
|
run_tests=run_tests,
|
|
),
|
|
(
|
|
"Git main is published without changing the Android version or publishing "
|
|
"an APK.\n\nUse the TrueNAS Deploy tab after stopping the app, then the "
|
|
"Verify tab after restarting it."
|
|
),
|
|
)
|
|
return
|
|
assert version is not None
|
|
assert credentials is not None
|
|
if not messagebox.askyesno(
|
|
"Start full release?",
|
|
f"Prepare Healer Man {version.version_name} (Android code {version.version_code})?\n\n"
|
|
"The app will bump versions, run all release checks, then show the exact staged files "
|
|
"before it commits, pushes main, and publishes the signed APK.\n\n"
|
|
+ test_summary,
|
|
parent=self.root,
|
|
):
|
|
return
|
|
self._begin_worker(
|
|
"Validating release",
|
|
lambda: self._full_release_worker(
|
|
version,
|
|
commit_message,
|
|
notes,
|
|
credentials,
|
|
publish_apk=True,
|
|
run_tests=run_tests,
|
|
),
|
|
(
|
|
"Git main and the signed APK release are published.\n\nUse the TrueNAS Deploy "
|
|
"tab after stopping the app, then the Verify tab after restarting it."
|
|
),
|
|
)
|
|
|
|
def start_apk_only(self) -> None:
|
|
if self.busy:
|
|
return
|
|
try:
|
|
package_version = read_package_version()
|
|
release_version = read_release_version()
|
|
credentials = self._credentials()
|
|
toolchain_ready, toolchain_detail = android_toolchain_status()
|
|
if not toolchain_ready:
|
|
raise ReleaseError(toolchain_detail)
|
|
if package_version != release_version.version_name:
|
|
raise ReleaseError(
|
|
"package.json and release-version.json do not describe the same version."
|
|
)
|
|
notes = self.release_notes.get("1.0", "end").strip() or (
|
|
f"Healer Man {package_version} Android update."
|
|
)
|
|
except Exception as exc:
|
|
messagebox.showerror("Cannot publish APK", str(exc), parent=self.root)
|
|
return
|
|
if not messagebox.askyesno(
|
|
"Publish current APK?",
|
|
f"Build and publish {package_version} (Android code {release_version.version_code}) "
|
|
"from the current clean, pushed commit?",
|
|
parent=self.root,
|
|
):
|
|
return
|
|
self._begin_worker(
|
|
"Publishing current APK",
|
|
lambda: self._apk_only_worker(release_version, notes, credentials),
|
|
(
|
|
"The signed APK release is published for the current Git commit.\n\nUse the "
|
|
"TrueNAS Deploy tab only if that commit is not already deployed."
|
|
),
|
|
)
|
|
|
|
def _begin_worker(
|
|
self,
|
|
label: str,
|
|
target: Callable[[], None],
|
|
success_message: str,
|
|
) -> None:
|
|
self.busy = True
|
|
self.cancel_event.clear()
|
|
self.status_var.set(label)
|
|
self.progress.start(12)
|
|
self.release_button.configure(state="disabled")
|
|
self.retry_button.configure(state="disabled")
|
|
self.refresh_button.configure(state="disabled")
|
|
self.release_mode_toggle.configure(state="disabled")
|
|
self.run_tests_toggle.configure(state="disabled")
|
|
self.cancel_button.configure(state="normal")
|
|
self._append_log(f"\n=== {label} ===")
|
|
threading.Thread(
|
|
target=self._worker_wrapper,
|
|
args=(target, success_message),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _worker_wrapper(
|
|
self,
|
|
target: Callable[[], None],
|
|
success_message: str,
|
|
) -> None:
|
|
try:
|
|
target()
|
|
except ReleaseCancelled as exc:
|
|
self.events.put(("done", "cancelled", str(exc)))
|
|
except Exception as exc:
|
|
self.events.put(("done", "error", str(exc)))
|
|
else:
|
|
self.events.put(("done", "success", success_message))
|
|
|
|
def _preflight_git(self, *, require_clean: bool) -> None:
|
|
if git_output("branch", "--show-current") != "main":
|
|
raise ReleaseError("Releases are only allowed from the main branch.")
|
|
merge_head = git_output("rev-parse", "-q", "--verify", "MERGE_HEAD", check=False)
|
|
if merge_head:
|
|
raise ReleaseError("A merge is in progress. Finish it before releasing.")
|
|
if require_clean and git_output("status", "--porcelain"):
|
|
raise ReleaseError("APK-only publication requires a clean working tree.")
|
|
if not require_clean:
|
|
staged = git_output("diff", "--cached", "--name-only")
|
|
if staged:
|
|
raise ReleaseError(
|
|
"The Git index already contains staged changes. Unstage or commit them so the "
|
|
"release manager can create an exact review checkpoint."
|
|
)
|
|
|
|
def _full_release_worker(
|
|
self,
|
|
version: ReleaseVersion | None,
|
|
commit_message: str,
|
|
notes: str,
|
|
credentials: dict[str, str] | None,
|
|
*,
|
|
publish_apk: bool,
|
|
run_tests: bool,
|
|
) -> None:
|
|
self._preflight_git(require_clean=False)
|
|
self._log_event(
|
|
"\n--- Early release-candidate check ---\n"
|
|
"$ npm run release:check"
|
|
)
|
|
candidate_paths = validate_release_candidates()
|
|
self._log_event(
|
|
f"✓ Early release-candidate check passed "
|
|
f"({len(candidate_paths)} changed path(s), including untracked files)"
|
|
)
|
|
npm = shutil.which("npm")
|
|
if not npm:
|
|
raise ReleaseError("npm must be available on PATH.")
|
|
powershell = None
|
|
if publish_apk:
|
|
if version is None or credentials is None:
|
|
raise ReleaseError("APK publication requires version and signing details.")
|
|
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
|
if not powershell:
|
|
raise ReleaseError("Windows PowerShell must be available on PATH.")
|
|
|
|
managed_files = (
|
|
(PACKAGE_FILE, PACKAGE_LOCK_FILE, RELEASE_VERSION_FILE)
|
|
if publish_apk
|
|
else ()
|
|
)
|
|
backups = {
|
|
path: path.read_bytes() if path.exists() else None for path in managed_files
|
|
}
|
|
staged = False
|
|
committed = False
|
|
base_commit = current_commit()
|
|
try:
|
|
if publish_apk:
|
|
assert version is not None
|
|
self._run_command(
|
|
[npm, "version", version.version_name, "--no-git-tag-version"],
|
|
"Bump package version",
|
|
)
|
|
write_release_version(version)
|
|
self._log_event(
|
|
f"Recorded Android version {version.version_name} / {version.version_code}."
|
|
)
|
|
else:
|
|
self._log_event(
|
|
"Git + TrueNAS mode: keeping the current Android version and "
|
|
"skipping APK publication."
|
|
)
|
|
if not run_tests:
|
|
self._log_event(
|
|
"⚠ Test suites disabled: skipping content, server, normal-application, "
|
|
"and shelved-application tests."
|
|
)
|
|
|
|
for command, label in full_release_checks(
|
|
npm,
|
|
include_android=publish_apk,
|
|
include_tests=run_tests,
|
|
):
|
|
try:
|
|
self._run_command(command, label)
|
|
except ReleaseCommandError as failure:
|
|
if not can_override_release_check(label):
|
|
raise
|
|
approved = self._request_approval(
|
|
format_failed_test_warning(failure),
|
|
title=f"Warning: {label} failed",
|
|
heading="Tests failed — continue the release?",
|
|
approve_label="Continue Anyway",
|
|
cancel_label="Stop Release",
|
|
)
|
|
if not approved:
|
|
raise ReleaseCancelled(
|
|
f"Release stopped because {label} failed."
|
|
) from failure
|
|
self._log_event(
|
|
f"⚠ Continuing after failed {label}; override approved by user."
|
|
)
|
|
self._run_command(["git", "add", "-A"], "Stage release")
|
|
staged = True
|
|
|
|
raw_paths = subprocess.run(
|
|
["git", "diff", "--cached", "--name-only", "-z"],
|
|
cwd=PROJECT_ROOT,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
check=True,
|
|
).stdout
|
|
staged_paths = [
|
|
path.decode("utf-8", errors="replace")
|
|
for path in raw_paths.split(b"\0")
|
|
if path
|
|
]
|
|
dangerous = find_dangerous_staged_paths(staged_paths)
|
|
if dangerous:
|
|
raise ReleaseError(
|
|
"Refusing to commit forbidden release output or a possible secret:\n"
|
|
+ "\n".join(f" - {path}" for path in dangerous)
|
|
)
|
|
self._run_command(
|
|
["git", "diff", "--cached", "--check"], "Staged whitespace check"
|
|
)
|
|
if not staged_paths:
|
|
raise ReleaseError("There are no staged changes to release.")
|
|
stat = git_output("diff", "--cached", "--stat")
|
|
if publish_apk:
|
|
assert version is not None
|
|
review_heading = (
|
|
f"Version: {version.version_name}\n"
|
|
f"Android code: {version.version_code}\n"
|
|
)
|
|
review_question = "Commit, push main, and publish the APK?"
|
|
approval_title = "Review staged release"
|
|
approval_heading = "Inspect every staged path before publishing"
|
|
approve_label = "Commit, Push, and Publish"
|
|
cancel_label = "Cancel release"
|
|
else:
|
|
current_android = read_release_version()
|
|
review_heading = (
|
|
"Mode: Git + TrueNAS only\n"
|
|
f"Android version remains: {current_android.version_name} "
|
|
f"(code {current_android.version_code})\n"
|
|
)
|
|
review_question = (
|
|
"Commit and push main without publishing an APK? The TrueNAS Deploy tab "
|
|
"will be pinned to the new commit."
|
|
)
|
|
approval_title = "Review staged Git + TrueNAS update"
|
|
approval_heading = "Inspect every staged path before pushing"
|
|
approve_label = "Commit and Push"
|
|
cancel_label = "Cancel update"
|
|
review_heading += (
|
|
f"Test suites: {'run' if run_tests else 'SKIPPED'}\n"
|
|
)
|
|
review = (
|
|
review_heading
|
|
+ f"Commit: {commit_message}\n\n"
|
|
f"Staged paths ({len(staged_paths)}):\n"
|
|
+ "\n".join(staged_paths)
|
|
+ f"\n\nDiff summary:\n{stat}\n\n{review_question}"
|
|
)
|
|
approved = self._request_approval(
|
|
review,
|
|
title=approval_title,
|
|
heading=approval_heading,
|
|
approve_label=approve_label,
|
|
cancel_label=cancel_label,
|
|
)
|
|
if not approved:
|
|
raise ReleaseCancelled("Release stopped at the staged-file review.")
|
|
|
|
self._run_command(["git", "commit", "-m", commit_message], "Commit release")
|
|
committed = current_commit() != base_commit
|
|
release_commit = current_commit()
|
|
self._run_command(["git", "push", "origin", "main"], "Push main")
|
|
remote_line = git_output("ls-remote", "origin", "refs/heads/main")
|
|
remote_commit = remote_line.split()[0] if remote_line else ""
|
|
if remote_commit != release_commit:
|
|
raise ReleaseError(
|
|
f"Remote main is {remote_commit or 'unknown'}, expected {release_commit}."
|
|
)
|
|
self.events.put(("commit", release_commit))
|
|
if publish_apk:
|
|
assert powershell is not None
|
|
assert version is not None
|
|
assert credentials is not None
|
|
self._publish_apk(powershell, version, notes, credentials)
|
|
except Exception:
|
|
if current_commit() != base_commit:
|
|
committed = True
|
|
if not committed:
|
|
if staged:
|
|
subprocess.run(
|
|
["git", "restore", "--staged", ":/"],
|
|
cwd=PROJECT_ROOT,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
if publish_apk:
|
|
for path, content in backups.items():
|
|
if content is None:
|
|
if path.exists():
|
|
path.unlink()
|
|
else:
|
|
path.write_bytes(content)
|
|
self._log_event(
|
|
"Restored version files because no release commit was created."
|
|
)
|
|
else:
|
|
self._log_event(
|
|
"No update commit was created; reviewed files were left in the working tree."
|
|
)
|
|
else:
|
|
if publish_apk:
|
|
self._log_event(
|
|
"The release commit exists. Version files were kept; use "
|
|
"'Publish Current APK Only' after correcting the publication problem."
|
|
)
|
|
else:
|
|
self._log_event(
|
|
"The update commit exists locally; resolve the push problem without "
|
|
"rewriting the commit."
|
|
)
|
|
raise
|
|
|
|
def _apk_only_worker(
|
|
self,
|
|
version: ReleaseVersion,
|
|
notes: str,
|
|
credentials: dict[str, str],
|
|
) -> None:
|
|
self._preflight_git(require_clean=True)
|
|
local_commit = current_commit()
|
|
remote_line = git_output("ls-remote", "origin", "refs/heads/main")
|
|
remote_commit = remote_line.split()[0] if remote_line else ""
|
|
if local_commit != remote_commit:
|
|
raise ReleaseError(
|
|
"Current HEAD does not match origin/main; push or update the checkout first."
|
|
)
|
|
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
|
if not powershell:
|
|
raise ReleaseError("Windows PowerShell must be available on PATH.")
|
|
self._publish_apk(powershell, version, notes, credentials)
|
|
self.events.put(("commit", local_commit))
|
|
|
|
def _publish_apk(
|
|
self,
|
|
powershell: str,
|
|
version: ReleaseVersion,
|
|
notes: str,
|
|
credentials: dict[str, str],
|
|
) -> None:
|
|
environment = os.environ.copy()
|
|
environment.update(credentials)
|
|
self._run_command(
|
|
[
|
|
powershell,
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-File",
|
|
str(PUBLISH_SCRIPT),
|
|
"-VersionName",
|
|
version.version_name,
|
|
"-VersionCode",
|
|
str(version.version_code),
|
|
"-ReleaseNotes",
|
|
notes,
|
|
],
|
|
"Build, sign, and publish APK",
|
|
environment=environment,
|
|
)
|
|
|
|
def _run_command(
|
|
self,
|
|
args: Sequence[str],
|
|
label: str,
|
|
*,
|
|
environment: dict[str, str] | None = None,
|
|
) -> None:
|
|
if self.cancel_event.is_set():
|
|
raise ReleaseCancelled("Release cancelled.")
|
|
self._log_event(f"\n--- {label} ---\n$ {' '.join(args)}")
|
|
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
|
process = subprocess.Popen(
|
|
list(args),
|
|
cwd=PROJECT_ROOT,
|
|
env=environment,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
bufsize=1,
|
|
creationflags=creation_flags,
|
|
)
|
|
self.current_process = process
|
|
assert process.stdout is not None
|
|
output_lines: deque[str] = deque(maxlen=FAILURE_OUTPUT_LINE_LIMIT)
|
|
try:
|
|
for line in process.stdout:
|
|
rendered = line.rstrip("\n")
|
|
output_lines.append(rendered)
|
|
self._log_event(rendered)
|
|
return_code = process.wait()
|
|
finally:
|
|
self.current_process = None
|
|
if self.cancel_event.is_set():
|
|
raise ReleaseCancelled("Release cancelled.")
|
|
if return_code != 0:
|
|
raise ReleaseCommandError(
|
|
label,
|
|
args,
|
|
return_code,
|
|
"\n".join(output_lines),
|
|
)
|
|
self._log_event(f"✓ {label} passed")
|
|
|
|
def _request_approval(
|
|
self,
|
|
review: str,
|
|
*,
|
|
title: str = "Review staged release",
|
|
heading: str = "Inspect every staged path before publishing",
|
|
approve_label: str = "Commit, Push, and Publish",
|
|
cancel_label: str = "Cancel release",
|
|
) -> bool:
|
|
response: dict[str, bool] = {}
|
|
ready = threading.Event()
|
|
self.events.put(
|
|
(
|
|
"approval",
|
|
review,
|
|
title,
|
|
heading,
|
|
approve_label,
|
|
cancel_label,
|
|
response,
|
|
ready,
|
|
)
|
|
)
|
|
while not ready.wait(0.2):
|
|
if self.cancel_event.is_set():
|
|
return False
|
|
return response.get("approved", False)
|
|
|
|
def _show_approval(
|
|
self,
|
|
review: str,
|
|
*,
|
|
title: str,
|
|
heading: str,
|
|
approve_label: str,
|
|
cancel_label: str,
|
|
) -> bool:
|
|
decision = {"approved": False}
|
|
dialog = tk.Toplevel(self.root)
|
|
dialog.title(title)
|
|
dialog.geometry("820x640")
|
|
dialog.minsize(640, 420)
|
|
dialog.configure(bg=self.BG)
|
|
dialog.transient(self.root)
|
|
dialog.grab_set()
|
|
dialog.rowconfigure(1, weight=1)
|
|
dialog.columnconfigure(0, weight=1)
|
|
ttk.Label(
|
|
dialog,
|
|
text=heading,
|
|
style="Title.TLabel",
|
|
).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10))
|
|
review_text = scrolledtext.ScrolledText(
|
|
dialog,
|
|
wrap="none",
|
|
bg="#071110",
|
|
fg="#cfe2dc",
|
|
insertbackground=self.TEXT,
|
|
relief="flat",
|
|
padx=9,
|
|
pady=9,
|
|
font=("Cascadia Mono", 9),
|
|
)
|
|
review_text.grid(row=1, column=0, sticky="nsew", padx=18)
|
|
review_text.insert("1.0", review)
|
|
review_text.configure(state="disabled")
|
|
buttons = ttk.Frame(dialog, padding=(18, 12, 18, 16))
|
|
buttons.grid(row=2, column=0, sticky="ew")
|
|
|
|
def finish(approved: bool) -> None:
|
|
decision["approved"] = approved
|
|
dialog.grab_release()
|
|
dialog.destroy()
|
|
|
|
ttk.Button(buttons, text=cancel_label, command=lambda: finish(False)).pack(
|
|
side="left"
|
|
)
|
|
ttk.Button(
|
|
buttons,
|
|
text=approve_label,
|
|
style="Accent.TButton",
|
|
command=lambda: finish(True),
|
|
).pack(side="right")
|
|
dialog.protocol("WM_DELETE_WINDOW", lambda: finish(False))
|
|
dialog.wait_window()
|
|
return decision["approved"]
|
|
|
|
def cancel(self) -> None:
|
|
if not self.busy:
|
|
return
|
|
self.cancel_event.set()
|
|
self.status_var.set("Cancelling…")
|
|
process = self.current_process
|
|
if process and process.poll() is None:
|
|
try:
|
|
if os.name == "nt":
|
|
subprocess.Popen(
|
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
)
|
|
else:
|
|
process.terminate()
|
|
except OSError:
|
|
pass
|
|
|
|
def _copy_commands(self) -> None:
|
|
selected = self.command_tabs.select()
|
|
widget = self.deploy_commands if selected == str(self.deploy_commands.master) else self.verify_commands
|
|
value = widget.get("1.0", "end").strip() + "\n"
|
|
self.root.clipboard_clear()
|
|
self.root.clipboard_append(value)
|
|
self.status_var.set("TrueNAS commands copied")
|
|
|
|
def _poll_events(self) -> None:
|
|
try:
|
|
while True:
|
|
event = self.events.get_nowait()
|
|
kind = event[0]
|
|
if kind == "log":
|
|
self._append_log(str(event[1]))
|
|
elif kind == "approval":
|
|
review, title, heading, approve_label, cancel_label = event[1:6]
|
|
response, ready = event[6], event[7]
|
|
approved = self._show_approval(
|
|
str(review),
|
|
title=str(title),
|
|
heading=str(heading),
|
|
approve_label=str(approve_label),
|
|
cancel_label=str(cancel_label),
|
|
)
|
|
assert isinstance(response, dict)
|
|
response["approved"] = approved
|
|
assert isinstance(ready, threading.Event)
|
|
ready.set()
|
|
elif kind == "commit":
|
|
self.last_commit = str(event[1])
|
|
self._set_text(
|
|
self.deploy_commands,
|
|
generate_truenas_deploy_commands(self.last_commit),
|
|
readonly=True,
|
|
)
|
|
elif kind == "done":
|
|
self._finish(str(event[1]), str(event[2]))
|
|
except queue.Empty:
|
|
pass
|
|
self.root.after(100, self._poll_events)
|
|
|
|
def _finish(self, result: str, detail: str) -> None:
|
|
self.busy = False
|
|
self.progress.stop()
|
|
self.release_button.configure(state="normal")
|
|
self.retry_button.configure(state="normal")
|
|
self.refresh_button.configure(state="normal")
|
|
self.release_mode_toggle.configure(state="normal")
|
|
self.run_tests_toggle.configure(state="normal")
|
|
self.cancel_button.configure(state="disabled")
|
|
self.keystore_password_var.set("")
|
|
self.key_password_var.set("")
|
|
if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file():
|
|
self.token_var.set(read_saved_gitea_token())
|
|
if result == "success":
|
|
self.status_var.set("Workflow completed")
|
|
self._append_log("\n✓ Workflow completed successfully.")
|
|
messagebox.showinfo(
|
|
"Workflow complete",
|
|
detail,
|
|
parent=self.root,
|
|
)
|
|
elif result == "cancelled":
|
|
self.status_var.set("Workflow cancelled")
|
|
self._append_log(f"\nCancelled: {detail}")
|
|
else:
|
|
self.status_var.set("Workflow failed")
|
|
self._append_log(f"\nERROR: {detail}")
|
|
messagebox.showerror("Workflow failed", detail, parent=self.root)
|
|
self.refresh()
|
|
|
|
def _close(self) -> None:
|
|
if self.busy:
|
|
if not messagebox.askyesno(
|
|
"Release is running",
|
|
"Cancel the active command and close the release manager?",
|
|
parent=self.root,
|
|
):
|
|
return
|
|
self.cancel()
|
|
self.root.destroy()
|
|
|
|
root = tk.Tk()
|
|
ReleaseManagerApp(root)
|
|
if smoke_test:
|
|
root.withdraw()
|
|
root.update_idletasks()
|
|
root.update()
|
|
root.destroy()
|
|
return 0
|
|
root.mainloop()
|
|
return 0
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Healer Man visual release manager")
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="print release/version/Git readiness without opening the GUI",
|
|
)
|
|
parser.add_argument(
|
|
"--check-release",
|
|
action="store_true",
|
|
help="check all release candidates, including untracked files",
|
|
)
|
|
parser.add_argument(
|
|
"--smoke-test",
|
|
action="store_true",
|
|
help=argparse.SUPPRESS,
|
|
)
|
|
args = parser.parse_args(argv)
|
|
if args.check_release:
|
|
try:
|
|
paths = validate_release_candidates()
|
|
print(
|
|
f"Release candidate check passed: {len(paths)} changed path(s), "
|
|
"including untracked files."
|
|
)
|
|
except Exception as exc:
|
|
print(f"Release candidate check failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
if args.check:
|
|
try:
|
|
print(json.dumps(check_summary(), indent=2))
|
|
except Exception as exc:
|
|
print(f"Release manager check failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
return launch_gui(smoke_test=args.smoke_test)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|