From 5bb170039a229fb6efd6210107ab480776b2b976 Mon Sep 17 00:00:00 2001 From: phenom Date: Mon, 17 Aug 2026 17:12:02 -0400 Subject: [PATCH] Release Healer Man 0.1.3 --- .gitignore | 2 + DEPLOYMENT.md | 31 +- RELEASE_RUNBOOK.md | 58 +- android/app/build.gradle | 4 +- package-lock.json | 4 +- package.json | 5 +- release-version.json | 4 + scripts/publish-gitea-release.ps1 | 21 + scripts/release-manager.cmd | 11 + scripts/release_manager.py | 1391 +++++++++++++++++++++++++++++ scripts/release_manager_test.py | 49 + scripts/runAndroidGradle.ps1 | 12 +- 12 files changed, 1569 insertions(+), 23 deletions(-) create mode 100644 release-version.json create mode 100644 scripts/release-manager.cmd create mode 100644 scripts/release_manager.py create mode 100644 scripts/release_manager_test.py diff --git a/.gitignore b/.gitignore index a431d3f5..1a74ee56 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ releases/ /content/ dist-android/ .android-public/ +__pycache__/ +*.pyc diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index a8e226b9..a7e4b8d7 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -176,17 +176,28 @@ schema-compatible with Healer Man and must not be copied over the new one. ## Update workflow -For a code update that includes a new APK, choose the next version first. The -example below upgrades `0.1.0` to `0.1.1`; replace `2` if it is not the next -unused Android `versionCode`. Then run these commands in **Windows PowerShell** +For a code update that includes a new APK, the visual workflow in +`RELEASE_RUNBOOK.md` is recommended. For a manual release, choose the next +version first. The example below upgrades `0.1.2` to `0.1.3`; replace `1003` if +it is not greater than every prior Android `versionCode`. Then run these +commands in **Windows PowerShell** on the development PC. Stop if any test or build fails: ```powershell Set-Location F:\Projects\HealerMan -$VersionName = '0.1.1' -$VersionCode = 2 +$VersionName = '0.1.3' +$VersionCode = 1003 npm version $VersionName --no-git-tag-version +$ReleaseVersion = [ordered]@{ + versionName = $VersionName + versionCode = $VersionCode +} | ConvertTo-Json +[IO.File]::WriteAllText( + (Join-Path $PWD 'release-version.json'), + "$ReleaseVersion`n", + [Text.UTF8Encoding]::new($false) +) npm run content:test npm run server:test @@ -298,17 +309,17 @@ Gitea release/tag, and upload both attachments with: ```powershell powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/publish-gitea-release.ps1 ` - -VersionName 0.1.1 ` - -VersionCode 2 ` + -VersionName 0.1.3 ` + -VersionCode 1003 ` -ReleaseNotes 'Healer Man client update.' ``` The result contains: ```text -Tag: v0.1.1 -APK: healer-man-0.1.1-release.apk -Checksum: healer-man-0.1.1-release.apk.sha256 +Tag: v0.1.3 +APK: healer-man-0.1.3-release.apk +Checksum: healer-man-0.1.3-release.apk.sha256 ``` Obtainium should monitor: diff --git a/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md index 114319af..1619902f 100644 --- a/RELEASE_RUNBOOK.md +++ b/RELEASE_RUNBOOK.md @@ -3,7 +3,42 @@ Use this checklist whenever game changes need to be pushed to Gitea, deployed to the TrueNAS server, and released as an Obtainium-compatible APK. -The examples below use Android version `0.1.1` and `versionCode` `2`. Replace +## Visual release manager + +On the development PC, double-click `scripts\release-manager.cmd` or run: + +```powershell +npm run release:gui +``` + +The release manager suggests the next semantic patch version and a strictly +increasing Android `versionCode`, runs the complete validation suite, and +stages the release. It then shows the exact staged paths and diff summary for +approval before it commits, pushes `main`, builds the signed APK, and publishes +the Gitea release. Signing passwords and the Gitea token are passed to the +child process in memory rather than on its command line. + +The local release key is `F:\secure\healer-man-release.jks`. When the companion +`healer-man-release-password.dpapi` file is present, blank signing-password +fields are filled from that Windows user-encrypted credential in memory. The +encrypted credential is usable only by the Windows account that created it; +back up the keystore and preserve its password separately for disaster recovery. +The **Remember Gitea token on this PC** option stores the token the same way at +`F:\secure\healer-man-gitea-token.dpapi`, loads it on later launches, and keeps +it outside the repository. Use **Forget saved token** to remove it. + +After the push, the GUI's **Deploy** tab contains the backup and fast-forward +pull block for the TrueNAS shell, pinned to the new commit. Stop the app in the +TrueNAS Apps UI before running it. Start the app afterward, watch its startup +log, and use the **Verify** tab for the health checks. + +The canonical Android version is stored in `release-version.json`. The initial +`1002` code is intentionally above the historical codes in this runbook; the +GUI will suggest `1003` for version `0.1.3`. If APK publication fails after the +Git push, fix the release-side problem and use **Publish Current APK Only** so +the source version is not bumped a second time. + +The examples below use Android version `0.1.3` and `versionCode` `1003`. Replace both with the next values for the release you are making. Every APK must use a larger `versionCode` than every APK previously released. @@ -150,8 +185,8 @@ other secrets. Choose both values before building: ```powershell -$VersionName = '0.1.1' -$VersionCode = 2 +$VersionName = '0.1.3' +$VersionCode = 1003 ``` - `VersionName` is the player-facing semantic version. @@ -159,10 +194,20 @@ $VersionCode = 2 - Record the last used `versionCode` in the Gitea release notes or your release records so it is never accidentally reused. -Update `package.json` and `package-lock.json` to the same version: +Update `package.json`, `package-lock.json`, and the canonical Android release +metadata to the same version: ```powershell npm version $VersionName --no-git-tag-version +$ReleaseVersion = [ordered]@{ + versionName = $VersionName + versionCode = $VersionCode +} | ConvertTo-Json +[IO.File]::WriteAllText( + (Join-Path $PWD 'release-version.json'), + "$ReleaseVersion`n", + [Text.UTF8Encoding]::new($false) +) ``` Do not create a local Git tag with `npm version`; the release script creates the @@ -544,6 +589,11 @@ An already-installed Android APK cannot be rolled back in place to a lower ## Common failures +- **Android Vite build reports `JavaScript heap out of memory`:** the checked-in + `build:android:web` command gives Vite an 8 GB Node heap. Make sure the current + `package.json` contains that command and close other memory-heavy applications + before retrying. A failed GUI preparation restores the version files when no + release commit was created, so retry the same suggested version. - **TrueNAS says the bare repository is not a Git repository:** run the pull with `sudo`. The normal `truenas_admin` account cannot read Gitea's repository storage. diff --git a/android/app/build.gradle b/android/app/build.gradle index 46b5b6f3..50b685e0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -3,8 +3,10 @@ import groovy.json.JsonSlurper apply plugin: 'com.android.application' def packageJson = new JsonSlurper().parse(file('../../package.json')) +def releaseVersionFile = file('../../release-version.json') +def releaseVersion = releaseVersionFile.exists() ? new JsonSlurper().parse(releaseVersionFile) : [:] def releaseVersionName = System.getenv('ANDROID_VERSION_NAME') ?: packageJson.version -def releaseVersionCode = (System.getenv('ANDROID_VERSION_CODE') ?: '1').toInteger() +def releaseVersionCode = (System.getenv('ANDROID_VERSION_CODE') ?: releaseVersion.versionCode ?: '1').toInteger() def releaseKeystoreFile = System.getenv('ANDROID_KEYSTORE_FILE') def releaseKeystorePassword = System.getenv('ANDROID_KEYSTORE_PASSWORD') def releaseKeyAlias = System.getenv('ANDROID_KEY_ALIAS') diff --git a/package-lock.json b/package-lock.json index babb56ce..7a75f8ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index c4523af4..3e08bf7c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.2", + "version": "0.1.3", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", @@ -90,11 +90,12 @@ "assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs", "prebuild": "npm run loot:generate && npm run assets:ktx2:audit", "build": "tsc -b && vite build", - "build:android:web": "npm run prebuild && npm run android:prepare && tsc -b && vite build --mode android", + "build:android:web": "npm run prebuild && npm run android:prepare && tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android", "android:sync": "npm run build:android:web && cap sync android", "android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug", "android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease", "android:install": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 installDebug", + "release:gui": "python scripts/release_manager.py", "preview": "vite preview --host 0.0.0.0", "pretest": "npm run loot:generate", "test": "vitest run", diff --git a/release-version.json b/release-version.json new file mode 100644 index 00000000..37f186d0 --- /dev/null +++ b/release-version.json @@ -0,0 +1,4 @@ +{ + "versionName": "0.1.3", + "versionCode": 1003 +} diff --git a/scripts/publish-gitea-release.ps1 b/scripts/publish-gitea-release.ps1 index f0dbcd94..85a1b88b 100644 --- a/scripts/publish-gitea-release.ps1 +++ b/scripts/publish-gitea-release.ps1 @@ -28,13 +28,34 @@ foreach ($secret in $requiredSecrets) { } $projectRoot = Split-Path -Parent $PSScriptRoot +$packageVersion = (Get-Content -LiteralPath (Join-Path $projectRoot 'package.json') -Raw | ConvertFrom-Json).version +$releaseVersionPath = Join-Path $projectRoot 'release-version.json' +if (-not (Test-Path -LiteralPath $releaseVersionPath)) { + throw "Canonical release metadata is missing: $releaseVersionPath" +} +$releaseVersion = Get-Content -LiteralPath $releaseVersionPath -Raw | ConvertFrom-Json +if ($packageVersion -ne $VersionName -or $releaseVersion.versionName -ne $VersionName) { + throw "VersionName $VersionName does not match package.json and release-version.json." +} +if ([int]$releaseVersion.versionCode -ne $VersionCode) { + throw "VersionCode $VersionCode does not match release-version.json ($($releaseVersion.versionCode))." +} $env:ANDROID_VERSION_NAME = $VersionName $env:ANDROID_VERSION_CODE = [string]$VersionCode Push-Location $projectRoot try { + $initialGitStatus = & git status --porcelain + if ($LASTEXITCODE -ne 0) { throw "Unable to inspect the Git working tree." } + if ($initialGitStatus) { throw "APK publication requires a clean Git working tree." } + & npm run android:sync if ($LASTEXITCODE -ne 0) { throw "Android web build and Capacitor sync failed with exit code $LASTEXITCODE" } + $postSyncGitStatus = & git status --porcelain --untracked-files=no + if ($LASTEXITCODE -ne 0) { throw "Unable to inspect Android sync changes." } + if ($postSyncGitStatus) { + throw "Android sync changed tracked files. Review and commit them before publishing the APK." + } & powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease if ($LASTEXITCODE -ne 0) { throw "Android release build failed with exit code $LASTEXITCODE" } diff --git a/scripts/release-manager.cmd b/scripts/release-manager.cmd new file mode 100644 index 00000000..4697cacd --- /dev/null +++ b/scripts/release-manager.cmd @@ -0,0 +1,11 @@ +@echo off +setlocal +cd /d "%~dp0.." +where py >nul 2>&1 +if errorlevel 1 goto use_python +py -3 scripts\release_manager.py %* +exit /b %errorlevel% + +:use_python +python scripts\release_manager.py %* +exit /b %errorlevel% diff --git a/scripts/release_manager.py b/scripts/release_manager.py new file mode 100644 index 00000000..da56dd10 --- /dev/null +++ b/scripts/release_manager.py @@ -0,0 +1,1391 @@ +"""Visual, fail-closed release manager for Healer Man. + +The GUI intentionally leaves the TrueNAS portion as copyable shell commands: +the app must be stopped and started from the TrueNAS Apps UI around that block. +Only Python's standard library is required. +""" + +from __future__ import annotations + +import argparse +import json +import os +import queue +import re +import shutil +import subprocess +import sys +import threading +import webbrowser +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable, Sequence + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PACKAGE_FILE = PROJECT_ROOT / "package.json" +PACKAGE_LOCK_FILE = PROJECT_ROOT / "package-lock.json" +RELEASE_VERSION_FILE = PROJECT_ROOT / "release-version.json" +PUBLISH_SCRIPT = PROJECT_ROOT / "scripts" / "publish-gitea-release.ps1" +RELEASES_URL = "https://git.whoagland.com/phenom/healer-man/releases" +DEFAULT_KEYSTORE_FILE = Path(r"F:\secure\healer-man-release.jks") +SAVED_SIGNING_PASSWORD_FILE = Path(r"F:\secure\healer-man-release-password.dpapi") +SAVED_GITEA_TOKEN_FILE = Path(r"F:\secure\healer-man-gitea-token.dpapi") +SEMVER_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + +FORBIDDEN_DIRECTORIES = { + ".android-public", + "artifacts", + "backups", + "content", + "dist", + "dist-android", + "releases", +} +FORBIDDEN_SUFFIXES = { + ".aab", + ".apk", + ".db", + ".jks", + ".key", + ".keystore", + ".p12", + ".pem", + ".pfx", + ".sqlite", + ".sqlite3", +} +ALLOWED_GENERATED_FILES = { + "public/data/healer-man-loot.sqlite", +} + + +class ReleaseError(RuntimeError): + """A release precondition or command failed.""" + + +class ReleaseCancelled(ReleaseError): + """The user cancelled an active release.""" + + +@dataclass(frozen=True) +class ReleaseVersion: + version_name: str + version_code: int + + +def parse_semver(value: str) -> tuple[int, int, int]: + match = SEMVER_PATTERN.fullmatch(value.strip()) + if not match: + raise ValueError("Version must contain three numbers, such as 0.1.3.") + return tuple(int(part) for part in match.groups()) # type: ignore[return-value] + + +def bump_semver(value: str, part: str = "patch") -> str: + major, minor, patch = parse_semver(value) + if part == "major": + return f"{major + 1}.0.0" + if part == "minor": + return f"{major}.{minor + 1}.0" + if part == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"Unknown version component: {part}") + + +def semantic_version_code(value: str) -> int: + """Map SemVer to a monotonic Android code with room for 0-999 components.""" + major, minor, patch = parse_semver(value) + if minor > 999 or patch > 999: + raise ValueError("Minor and patch version components must be 999 or less.") + code = major * 1_000_000 + minor * 1_000 + patch + if not 1 <= code <= 2_147_483_647: + raise ValueError("The calculated Android version code is out of range.") + return code + + +def suggest_version_code(next_version: str, previous_code: int) -> int: + return max(previous_code + 1, semantic_version_code(next_version)) + + +def read_package_version() -> str: + raw = json.loads(PACKAGE_FILE.read_text(encoding="utf-8")) + version = str(raw.get("version", "")) + parse_semver(version) + return version + + +def read_release_version() -> ReleaseVersion: + if not RELEASE_VERSION_FILE.exists(): + version = read_package_version() + return ReleaseVersion(version, semantic_version_code(version)) + raw = json.loads(RELEASE_VERSION_FILE.read_text(encoding="utf-8")) + version = str(raw.get("versionName", "")) + code = int(raw.get("versionCode", 0)) + parse_semver(version) + if not 1 <= code <= 2_147_483_647: + raise ValueError("release-version.json contains an invalid versionCode.") + return ReleaseVersion(version, code) + + +def write_release_version(version: ReleaseVersion) -> None: + payload = { + "versionName": version.version_name, + "versionCode": version.version_code, + } + temporary = RELEASE_VERSION_FILE.with_suffix(".json.tmp") + temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + temporary.replace(RELEASE_VERSION_FILE) + + +def find_dangerous_staged_paths(paths: Iterable[str]) -> list[str]: + dangerous: list[str] = [] + for raw_path in paths: + normalized = raw_path.replace("\\", "/").strip("/") + parts = [part.lower() for part in normalized.split("/") if part] + name = parts[-1] if parts else "" + suffix = Path(name).suffix.lower() + is_environment_file = name == ".env" or name.startswith(".env.") + is_allowed_generated_file = normalized.lower() in ALLOWED_GENERATED_FILES + if ( + (parts and parts[0] in FORBIDDEN_DIRECTORIES) + or (suffix in FORBIDDEN_SUFFIXES and not is_allowed_generated_file) + or is_environment_file + ): + dangerous.append(raw_path) + return dangerous + + +def generate_truenas_deploy_commands(expected_commit: str) -> str: + commit = expected_commit.strip() or "PASTE_RELEASE_COMMIT_HERE" + return f"""# Stop healer-man in the TrueNAS Apps UI before running this block. +set -eu +EXPECTED_COMMIT='{commit}' + +sudo mkdir -p /mnt/usbssds/apps/healer-man/backups +if sudo test -f /mnt/usbssds/apps/healer-man/data/game.db; then + sudo cp -p \\ + /mnt/usbssds/apps/healer-man/data/game.db \\ + /mnt/usbssds/apps/healer-man/backups/game-$(date +%Y%m%d-%H%M%S).db +fi + +sudo git config --global --add safe.directory \\ + /mnt/usbssds/apps/healer-man/app +sudo git config --global --add safe.directory \\ + /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git +sudo git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \\ + /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \\ + main +sudo chown -R truenas_admin:truenas_admin \\ + /mnt/usbssds/apps/healer-man/app + +ACTUAL_COMMIT=$(sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD) +printf 'Expected: %s\\nActual: %s\\n' "$EXPECTED_COMMIT" "$ACTUAL_COMMIT" +test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT" +df -h /mnt/usbssds/apps/healer-man + +# Now start healer-man in the TrueNAS Apps UI and watch its startup log. +""" + + +def generate_truenas_verify_commands() -> str: + return """# Run after healer-man has started and its log says the server is listening. +set -eu +curl -fI http://127.0.0.1:4173/ +curl -fsS http://127.0.0.1:4173/api/health +curl -fsS http://127.0.0.1:4173/content/manifest.json | head -c 500 +printf '\\nTrueNAS verification completed.\\n' +""" + + +def command_output(args: Sequence[str], *, check: bool = True) -> str: + result = subprocess.run( + list(args), + cwd=PROJECT_ROOT, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if check and result.returncode != 0: + raise ReleaseError( + f"Command failed ({result.returncode}): {' '.join(args)}\n{result.stdout.strip()}" + ) + return result.stdout.strip() + + +def git_output(*args: str, check: bool = True) -> str: + return command_output(("git", *args), check=check) + + +def current_commit() -> str: + try: + return git_output("rev-parse", "HEAD") + except ReleaseError: + return "" + + +def windows_powershell_environment(powershell: str) -> dict[str, str]: + environment = os.environ.copy() + if Path(powershell).name.lower() == "powershell.exe": + user_profile = Path(environment.get("USERPROFILE", str(Path.home()))) + windows_root = Path(environment.get("WINDIR", r"C:\Windows")) + program_files = Path(environment.get("ProgramFiles", r"C:\Program Files")) + environment["PSModulePath"] = os.pathsep.join( + ( + str(user_profile / "Documents" / "WindowsPowerShell" / "Modules"), + str(program_files / "WindowsPowerShell" / "Modules"), + str(windows_root / "System32" / "WindowsPowerShell" / "v1.0" / "Modules"), + ) + ) + return environment + + +def read_windows_encrypted_secret(path: Path, label: str) -> str: + """Decrypt a local DPAPI credential through the current Windows account.""" + if os.name != "nt" or not path.is_file(): + return "" + powershell = shutil.which("powershell.exe") or shutil.which("powershell") + if not powershell: + raise ReleaseError(f"Windows PowerShell is required to read the saved {label}.") + script = ( + "$secure = Get-Content -LiteralPath $env:HEALERMAN_CREDENTIAL_FILE -Raw " + "| ConvertTo-SecureString; " + "$plain = [System.Net.NetworkCredential]::new('', $secure).Password; " + "Write-Output $plain" + ) + environment = windows_powershell_environment(powershell) + environment["HEALERMAN_CREDENTIAL_FILE"] = str(path) + result = subprocess.run( + [powershell, "-NoProfile", "-Command", script], + cwd=PROJECT_ROOT, + env=environment, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + password = result.stdout.rstrip("\r\n") + if result.returncode != 0 or not password: + raise ReleaseError( + f"The saved {label} could not be decrypted by this Windows account." + ) + return password + + +def write_windows_encrypted_secret(path: Path, value: str, label: str) -> None: + """Encrypt a secret with Windows DPAPI without putting it on the command line.""" + if os.name != "nt": + raise ReleaseError(f"Saving the {label} is supported only on Windows.") + powershell = shutil.which("powershell.exe") or shutil.which("powershell") + if not powershell: + raise ReleaseError(f"Windows PowerShell is required to save the {label}.") + path.parent.mkdir(parents=True, exist_ok=True) + script = ( + "$ErrorActionPreference = 'Stop'; " + "$secure = ConvertTo-SecureString $env:HEALERMAN_SECRET_VALUE -AsPlainText -Force; " + "$encrypted = ConvertFrom-SecureString $secure; " + "[IO.File]::WriteAllText($env:HEALERMAN_CREDENTIAL_FILE, $encrypted, " + "[Text.UTF8Encoding]::new($false))" + ) + environment = windows_powershell_environment(powershell) + environment["HEALERMAN_CREDENTIAL_FILE"] = str(path) + environment["HEALERMAN_SECRET_VALUE"] = value + result = subprocess.run( + [powershell, "-NoProfile", "-Command", script], + cwd=PROJECT_ROOT, + env=environment, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + environment["HEALERMAN_SECRET_VALUE"] = "" + if result.returncode != 0 or not path.is_file(): + raise ReleaseError(f"The {label} could not be saved with Windows encryption.") + + +def read_saved_signing_password() -> str: + return read_windows_encrypted_secret( + SAVED_SIGNING_PASSWORD_FILE, "signing password" + ) + + +def read_saved_gitea_token() -> str: + return read_windows_encrypted_secret(SAVED_GITEA_TOKEN_FILE, "Gitea token") + + +def android_toolchain_status() -> tuple[bool, str]: + workspace_root = PROJECT_ROOT.parent + wow_root = workspace_root / "wow335a" + configured_java = os.environ.get("JAVA_HOME", "").strip() + java_candidates: list[Path] = [] + if configured_java: + java_candidates.append(Path(configured_java)) + portable_java_roots = ( + PROJECT_ROOT / ".tools" / "temurin-21", + wow_root / "dungeon-export-work" / "tools" / "temurin-21", + ) + for portable_java_root in portable_java_roots: + if portable_java_root.is_dir(): + java_candidates.extend( + sorted( + (path for path in portable_java_root.iterdir() if path.is_dir()), + reverse=True, + ) + ) + java_home = next( + (path for path in java_candidates if (path / "bin" / "java.exe").is_file()), + None, + ) + + configured_sdk = ( + os.environ.get("ANDROID_SDK_ROOT", "").strip() + or os.environ.get("ANDROID_HOME", "").strip() + ) + sdk_candidates = ([Path(configured_sdk)] if configured_sdk else []) + [ + wow_root / ".android-sdk" + ] + sdk_root = next( + ( + path + for path in sdk_candidates + if (path / "platforms" / "android-36" / "android.jar").is_file() + ), + None, + ) + missing: list[str] = [] + if java_home is None: + missing.append("JDK 21 (set JAVA_HOME or install the documented portable JDK)") + if sdk_root is None: + missing.append("Android SDK Platform 36 (set ANDROID_SDK_ROOT)") + if missing: + return False, "; ".join(missing) + return True, f"Java: {java_home}; Android SDK: {sdk_root}" + + +def check_summary() -> dict[str, object]: + package_version = read_package_version() + release_version = read_release_version() + next_version = bump_semver(package_version) + toolchain_ready, toolchain_detail = android_toolchain_status() + return { + "project": str(PROJECT_ROOT), + "branch": git_output("branch", "--show-current"), + "commit": current_commit(), + "packageVersion": package_version, + "trackedAndroidVersion": release_version.version_name, + "trackedAndroidVersionCode": release_version.version_code, + "suggestedNextVersion": next_version, + "suggestedNextVersionCode": suggest_version_code( + next_version, release_version.version_code + ), + "androidToolchainReady": toolchain_ready, + "androidToolchain": toolchain_detail, + "releaseKeystoreReady": DEFAULT_KEYSTORE_FILE.is_file(), + "savedSigningCredentialReady": SAVED_SIGNING_PASSWORD_FILE.is_file(), + "savedGiteaTokenReady": SAVED_GITEA_TOKEN_FILE.is_file(), + "workingTree": git_output("status", "--short", "--branch"), + } + + +def launch_gui(*, smoke_test: bool = False) -> int: + import tkinter as tk + from tkinter import filedialog, messagebox, scrolledtext, ttk + + class ReleaseManagerApp: + BG = "#0c1515" + PANEL = "#122222" + PANEL_2 = "#172b2a" + TEXT = "#e9f4ef" + MUTED = "#91aaa2" + ACCENT = "#63d9a4" + ACCENT_DARK = "#183b31" + WARNING = "#f2c66d" + ERROR = "#ff8b83" + + def __init__(self, root: tk.Tk) -> None: + self.root = root + self.events: queue.Queue[tuple[object, ...]] = queue.Queue() + self.cancel_event = threading.Event() + self.current_process: subprocess.Popen[str] | None = None + self.busy = False + self.last_commit = current_commit() + self._configure_window() + self._build_ui() + self.refresh() + self.root.after(100, self._poll_events) + self.root.protocol("WM_DELETE_WINDOW", self._close) + + def _configure_window(self) -> None: + self.root.title("Healer Man Release Manager") + self.root.geometry("1120x820") + self.root.minsize(960, 700) + self.root.configure(bg=self.BG) + style = ttk.Style(self.root) + try: + style.theme_use("clam") + except tk.TclError: + pass + style.configure(".", background=self.BG, foreground=self.TEXT) + style.configure("TFrame", background=self.BG) + style.configure("Panel.TFrame", background=self.PANEL) + style.configure( + "Title.TLabel", + background=self.BG, + foreground=self.TEXT, + font=("Segoe UI Semibold", 20), + ) + style.configure( + "Sub.TLabel", + background=self.BG, + foreground=self.MUTED, + font=("Segoe UI", 10), + ) + style.configure( + "Panel.TLabel", background=self.PANEL, foreground=self.TEXT + ) + style.configure( + "Muted.Panel.TLabel", background=self.PANEL, foreground=self.MUTED + ) + style.configure( + "Accent.TButton", + background=self.ACCENT, + foreground="#06110d", + font=("Segoe UI Semibold", 10), + padding=(13, 9), + ) + style.map("Accent.TButton", background=[("active", "#84e7bb")]) + style.configure("TButton", padding=(10, 7)) + style.configure( + "TEntry", + fieldbackground="#0a1716", + foreground=self.TEXT, + insertcolor=self.TEXT, + bordercolor="#31504a", + ) + style.configure( + "TCombobox", + fieldbackground="#0a1716", + foreground=self.TEXT, + arrowcolor=self.TEXT, + ) + style.configure("TNotebook", background=self.PANEL, borderwidth=0) + style.configure( + "TNotebook.Tab", + background=self.PANEL_2, + foreground=self.MUTED, + padding=(12, 7), + ) + style.map( + "TNotebook.Tab", + background=[("selected", self.ACCENT_DARK)], + foreground=[("selected", self.ACCENT)], + ) + + def _build_ui(self) -> None: + shell = ttk.Frame(self.root, padding=18) + shell.pack(fill="both", expand=True) + shell.columnconfigure(0, weight=3) + shell.columnconfigure(1, weight=2) + shell.rowconfigure(2, weight=1) + + title = ttk.Frame(shell) + title.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 14)) + title.columnconfigure(0, weight=1) + ttk.Label(title, text="Healer Man Release Manager", style="Title.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label( + title, + text="Validate → inspect → commit → push → publish signed APK", + style="Sub.TLabel", + ).grid(row=1, column=0, sticky="w", pady=(2, 0)) + self.refresh_button = ttk.Button(title, text="Refresh", command=self.refresh) + self.refresh_button.grid(row=0, column=1, rowspan=2, sticky="e") + + release_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15) + release_panel.grid(row=1, column=0, sticky="nsew", padx=(0, 8)) + release_panel.columnconfigure(1, weight=1) + ttk.Label( + release_panel, text="Release details", style="Panel.TLabel", + font=("Segoe UI Semibold", 12), + ).grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 11)) + + self.current_version_var = tk.StringVar(value="—") + self.current_code_var = tk.StringVar(value="—") + self.bump_var = tk.StringVar(value="Patch") + self.next_version_var = tk.StringVar() + self.next_code_var = tk.StringVar() + self.commit_message_var = tk.StringVar() + + ttk.Label(release_panel, text="Current", style="Muted.Panel.TLabel").grid( + row=1, column=0, sticky="w" + ) + ttk.Label(release_panel, textvariable=self.current_version_var, style="Panel.TLabel").grid( + row=1, column=1, sticky="w", padx=(8, 20) + ) + ttk.Label(release_panel, text="Android code", style="Muted.Panel.TLabel").grid( + row=1, column=2, sticky="w" + ) + ttk.Label(release_panel, textvariable=self.current_code_var, style="Panel.TLabel").grid( + row=1, column=3, sticky="w", padx=(8, 0) + ) + + ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid( + row=2, column=0, sticky="w", pady=(12, 0) + ) + bump_box = ttk.Combobox( + release_panel, + textvariable=self.bump_var, + values=("Patch", "Minor", "Major"), + state="readonly", + width=10, + ) + bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0)) + bump_box.bind("<>", lambda _event: self._suggest_versions()) + ttk.Label(release_panel, text="Next version", style="Muted.Panel.TLabel").grid( + row=3, column=0, sticky="w", pady=(9, 0) + ) + ttk.Entry(release_panel, textvariable=self.next_version_var, width=15).grid( + row=3, column=1, sticky="ew", padx=(8, 20), pady=(9, 0) + ) + ttk.Label(release_panel, text="Next code", style="Muted.Panel.TLabel").grid( + row=3, column=2, sticky="w", pady=(9, 0) + ) + ttk.Entry(release_panel, textvariable=self.next_code_var, width=12).grid( + row=3, column=3, sticky="ew", padx=(8, 0), pady=(9, 0) + ) + ttk.Label(release_panel, text="Commit message", style="Muted.Panel.TLabel").grid( + row=4, column=0, sticky="w", pady=(9, 0) + ) + ttk.Entry(release_panel, textvariable=self.commit_message_var).grid( + row=4, column=1, columnspan=3, sticky="ew", padx=(8, 0), pady=(9, 0) + ) + ttk.Label(release_panel, text="Release notes", style="Muted.Panel.TLabel").grid( + row=5, column=0, sticky="nw", pady=(9, 0) + ) + self.release_notes = tk.Text( + release_panel, + height=4, + wrap="word", + bg="#0a1716", + fg=self.TEXT, + insertbackground=self.TEXT, + relief="flat", + padx=7, + pady=6, + font=("Segoe UI", 9), + ) + self.release_notes.grid( + row=5, column=1, columnspan=3, sticky="nsew", padx=(8, 0), pady=(9, 0) + ) + + secrets_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15) + secrets_panel.grid(row=1, column=1, sticky="nsew", padx=(8, 0)) + secrets_panel.columnconfigure(1, weight=1) + ttk.Label( + secrets_panel, text="Signing and Gitea", style="Panel.TLabel", + font=("Segoe UI Semibold", 12), + ).grid(row=0, column=0, columnspan=3, sticky="w") + ttk.Label( + secrets_panel, + text="Signing password and saved token are Windows-encrypted outside Git.", + style="Muted.Panel.TLabel", + ).grid(row=1, column=0, columnspan=3, sticky="w", pady=(2, 10)) + + self.keystore_var = tk.StringVar(value=str(DEFAULT_KEYSTORE_FILE)) + self.alias_var = tk.StringVar(value="healer-man") + self.keystore_password_var = tk.StringVar() + self.key_password_var = tk.StringVar() + try: + saved_token = read_saved_gitea_token() + except ReleaseError: + saved_token = "" + self.token_var = tk.StringVar(value=saved_token) + self.remember_token_var = tk.BooleanVar(value=True) + credential_rows = ( + ("Keystore", self.keystore_var, False), + ("Key alias", self.alias_var, False), + ("Store password", self.keystore_password_var, True), + ("Key password", self.key_password_var, True), + ("Gitea token", self.token_var, True), + ) + for row, (label, variable, secret) in enumerate(credential_rows, start=2): + ttk.Label(secrets_panel, text=label, style="Muted.Panel.TLabel").grid( + row=row, column=0, sticky="w", pady=4 + ) + ttk.Entry(secrets_panel, textvariable=variable, show="•" if secret else "").grid( + row=row, column=1, sticky="ew", padx=(8, 5), pady=4 + ) + if row == 2: + ttk.Button(secrets_panel, text="…", width=3, command=self._browse_keystore).grid( + row=row, column=2, pady=4 + ) + ttk.Checkbutton( + secrets_panel, + text="Remember Gitea token on this PC", + variable=self.remember_token_var, + ).grid(row=7, column=0, columnspan=2, sticky="w", pady=(7, 0)) + ttk.Button( + secrets_panel, + text="Forget saved token", + command=self._forget_saved_token, + ).grid(row=7, column=2, sticky="e", pady=(7, 0)) + ttk.Label( + secrets_panel, + text=( + "Blank password fields use the saved Windows-encrypted signing credential; " + "enter the Gitea token once and it will load automatically." + ), + style="Muted.Panel.TLabel", + wraplength=360, + ).grid(row=8, column=0, columnspan=3, sticky="w", pady=(7, 0)) + + lower = ttk.Panedwindow(shell, orient="horizontal") + lower.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(16, 0)) + + log_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12) + commands_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12) + lower.add(log_panel, weight=3) + lower.add(commands_panel, weight=2) + log_panel.rowconfigure(2, weight=1) + log_panel.columnconfigure(0, weight=1) + commands_panel.rowconfigure(1, weight=1) + commands_panel.columnconfigure(0, weight=1) + + status_row = ttk.Frame(log_panel, style="Panel.TFrame") + status_row.grid(row=0, column=0, sticky="ew") + status_row.columnconfigure(0, weight=1) + self.status_var = tk.StringVar(value="Ready") + ttk.Label( + status_row, textvariable=self.status_var, style="Panel.TLabel", + font=("Segoe UI Semibold", 11), + ).grid(row=0, column=0, sticky="w") + self.progress = ttk.Progressbar(status_row, mode="indeterminate", length=150) + self.progress.grid(row=0, column=1, sticky="e") + self.git_summary_var = tk.StringVar(value="Loading Git status…") + ttk.Label( + log_panel, + textvariable=self.git_summary_var, + style="Muted.Panel.TLabel", + wraplength=600, + ).grid(row=1, column=0, sticky="ew", pady=(4, 8)) + self.log = scrolledtext.ScrolledText( + log_panel, + wrap="word", + bg="#071110", + fg="#cfe2dc", + insertbackground=self.TEXT, + relief="flat", + font=("Cascadia Mono", 9), + state="disabled", + ) + self.log.grid(row=2, column=0, sticky="nsew") + + ttk.Label( + commands_panel, + text="TrueNAS shell commands", + style="Panel.TLabel", + font=("Segoe UI Semibold", 11), + ).grid(row=0, column=0, sticky="w", pady=(0, 7)) + self.command_tabs = ttk.Notebook(commands_panel) + self.command_tabs.grid(row=1, column=0, sticky="nsew") + self.deploy_commands = self._command_text_widget(self.command_tabs) + self.verify_commands = self._command_text_widget(self.command_tabs) + self.command_tabs.add(self.deploy_commands.master, text="Deploy") + self.command_tabs.add(self.verify_commands.master, text="Verify") + command_buttons = ttk.Frame(commands_panel, style="Panel.TFrame") + command_buttons.grid(row=2, column=0, sticky="ew", pady=(8, 0)) + ttk.Button(command_buttons, text="Copy selected tab", command=self._copy_commands).pack( + side="left" + ) + ttk.Button(command_buttons, text="Open releases", command=lambda: webbrowser.open(RELEASES_URL)).pack( + side="right" + ) + + actions = ttk.Frame(shell) + actions.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(14, 0)) + actions.columnconfigure(0, weight=1) + self.release_button = ttk.Button( + actions, + text="Validate, Push, and Publish APK", + style="Accent.TButton", + command=self.start_full_release, + ) + self.release_button.grid(row=0, column=0, sticky="ew", padx=(0, 7)) + self.retry_button = ttk.Button( + actions, + text="Publish Current APK Only", + command=self.start_apk_only, + ) + self.retry_button.grid(row=0, column=1, padx=7) + self.cancel_button = ttk.Button(actions, text="Cancel", command=self.cancel, state="disabled") + self.cancel_button.grid(row=0, column=2, padx=(7, 0)) + + def _command_text_widget(self, notebook: ttk.Notebook) -> tk.Text: + frame = ttk.Frame(notebook) + frame.rowconfigure(0, weight=1) + frame.columnconfigure(0, weight=1) + widget = tk.Text( + frame, + wrap="none", + bg="#071110", + fg="#cfe2dc", + relief="flat", + padx=8, + pady=8, + font=("Cascadia Mono", 8), + ) + widget.grid(row=0, column=0, sticky="nsew") + return widget + + def _browse_keystore(self) -> None: + selected = filedialog.askopenfilename( + title="Select Android release keystore", + filetypes=(("Java keystore", "*.jks *.keystore"), ("All files", "*.*")), + ) + if selected: + self.keystore_var.set(selected) + + def _forget_saved_token(self) -> None: + if not SAVED_GITEA_TOKEN_FILE.exists(): + self.token_var.set("") + self.status_var.set("No saved Gitea token") + return + if not messagebox.askyesno( + "Forget saved token?", + "Remove the Windows-encrypted Gitea token from this PC?", + parent=self.root, + ): + return + SAVED_GITEA_TOKEN_FILE.unlink(missing_ok=True) + self.token_var.set("") + self.status_var.set("Saved Gitea token removed") + + def _set_text(self, widget: tk.Text, value: str, *, readonly: bool = False) -> None: + widget.configure(state="normal") + widget.delete("1.0", "end") + widget.insert("1.0", value) + if readonly: + widget.configure(state="disabled") + + def _append_log(self, value: str) -> None: + self.log.configure(state="normal") + self.log.insert("end", value.rstrip("\n") + "\n") + self.log.see("end") + self.log.configure(state="disabled") + + def _log_event(self, value: str) -> None: + self.events.put(("log", value)) + + def refresh(self) -> None: + if self.busy: + return + try: + package_version = read_package_version() + release_version = read_release_version() + status = git_output("status", "--short", "--branch") + branch = git_output("branch", "--show-current") or "detached HEAD" + toolchain_ready, _toolchain_detail = android_toolchain_status() + self.last_commit = current_commit() + self.current_version_var.set(package_version) + self.current_code_var.set(str(release_version.version_code)) + self._suggest_versions() + change_count = len( + [line for line in status.splitlines() if not line.startswith("##")] + ) + self.git_summary_var.set( + f"Branch {branch} • {change_count} changed path(s) • HEAD {self.last_commit[:12]} • " + f"Android toolchain {'ready' if toolchain_ready else 'missing'}" + ) + self._set_text( + self.deploy_commands, + generate_truenas_deploy_commands(self.last_commit), + readonly=True, + ) + self._set_text( + self.verify_commands, + generate_truenas_verify_commands(), + readonly=True, + ) + if not self.release_notes.get("1.0", "end").strip(): + self.release_notes.insert( + "1.0", f"Healer Man {self.next_version_var.get()} app and Android update." + ) + self.status_var.set("Ready") + except Exception as exc: + self.status_var.set("Configuration error") + self.git_summary_var.set(str(exc)) + + def _suggest_versions(self) -> None: + try: + current = read_package_version() + release = read_release_version() + next_version = bump_semver(current, self.bump_var.get().lower()) + next_code = suggest_version_code(next_version, release.version_code) + self.next_version_var.set(next_version) + self.next_code_var.set(str(next_code)) + self.commit_message_var.set(f"Release Healer Man {next_version}") + except Exception as exc: + self.status_var.set(str(exc)) + + def _credentials(self) -> dict[str, str]: + fields = { + "ANDROID_KEYSTORE_FILE": self.keystore_var.get().strip(), + "ANDROID_KEY_ALIAS": self.alias_var.get().strip(), + "ANDROID_KEYSTORE_PASSWORD": self.keystore_password_var.get(), + "ANDROID_KEY_PASSWORD": self.key_password_var.get(), + "GITEA_TOKEN": self.token_var.get(), + } + for key, value in list(fields.items()): + if not value: + fields[key] = os.environ.get(key, "") + if not fields["GITEA_TOKEN"]: + fields["GITEA_TOKEN"] = read_saved_gitea_token() + selected_keystore = os.path.normcase( + os.path.abspath(fields["ANDROID_KEYSTORE_FILE"]) + ) + default_keystore = os.path.normcase(os.path.abspath(DEFAULT_KEYSTORE_FILE)) + if selected_keystore == default_keystore and ( + not fields["ANDROID_KEYSTORE_PASSWORD"] + or not fields["ANDROID_KEY_PASSWORD"] + ): + saved_password = read_saved_signing_password() + if not fields["ANDROID_KEYSTORE_PASSWORD"]: + fields["ANDROID_KEYSTORE_PASSWORD"] = saved_password + if not fields["ANDROID_KEY_PASSWORD"]: + fields["ANDROID_KEY_PASSWORD"] = saved_password + missing = [key for key, value in fields.items() if not value] + if missing: + raise ReleaseError("Missing release credentials: " + ", ".join(missing)) + if self.remember_token_var.get(): + write_windows_encrypted_secret( + SAVED_GITEA_TOKEN_FILE, fields["GITEA_TOKEN"], "Gitea token" + ) + keystore = Path(fields["ANDROID_KEYSTORE_FILE"]) + if not keystore.is_file(): + raise ReleaseError(f"Keystore does not exist: {keystore}") + return fields + + def _requested_version(self) -> ReleaseVersion: + version_name = self.next_version_var.get().strip() + parse_semver(version_name) + try: + version_code = int(self.next_code_var.get().strip()) + except ValueError as exc: + raise ReleaseError("Android version code must be a whole number.") from exc + current = read_package_version() + tracked = read_release_version() + if parse_semver(version_name) <= parse_semver(current): + raise ReleaseError(f"Next version must be newer than {current}.") + if version_code <= tracked.version_code: + raise ReleaseError( + f"Android version code must be greater than {tracked.version_code}." + ) + if version_code > 2_147_483_647: + raise ReleaseError("Android version code exceeds Android's maximum.") + return ReleaseVersion(version_name, version_code) + + def start_full_release(self) -> None: + if self.busy: + return + try: + version = self._requested_version() + credentials = self._credentials() + toolchain_ready, toolchain_detail = android_toolchain_status() + if not toolchain_ready: + raise ReleaseError(toolchain_detail) + commit_message = self.commit_message_var.get().strip() + notes = self.release_notes.get("1.0", "end").strip() + if not commit_message: + raise ReleaseError("Commit message is required.") + if not notes: + raise ReleaseError("Release notes are required.") + except Exception as exc: + messagebox.showerror("Cannot start release", str(exc), parent=self.root) + return + if not messagebox.askyesno( + "Start full release?", + f"Prepare Healer Man {version.version_name} (Android code {version.version_code})?\n\n" + "The app will bump versions, run all release checks, then show the exact staged files " + "before it commits, pushes main, and publishes the signed APK.", + parent=self.root, + ): + return + self._begin_worker( + "Validating release", + lambda: self._full_release_worker(version, commit_message, notes, credentials), + ) + + def start_apk_only(self) -> None: + if self.busy: + return + try: + package_version = read_package_version() + release_version = read_release_version() + credentials = self._credentials() + toolchain_ready, toolchain_detail = android_toolchain_status() + if not toolchain_ready: + raise ReleaseError(toolchain_detail) + if package_version != release_version.version_name: + raise ReleaseError( + "package.json and release-version.json do not describe the same version." + ) + notes = self.release_notes.get("1.0", "end").strip() or ( + f"Healer Man {package_version} Android update." + ) + except Exception as exc: + messagebox.showerror("Cannot publish APK", str(exc), parent=self.root) + return + if not messagebox.askyesno( + "Publish current APK?", + f"Build and publish {package_version} (Android code {release_version.version_code}) " + "from the current clean, pushed commit?", + parent=self.root, + ): + return + self._begin_worker( + "Publishing current APK", + lambda: self._apk_only_worker(release_version, notes, credentials), + ) + + def _begin_worker(self, label: str, target: Callable[[], None]) -> None: + self.busy = True + self.cancel_event.clear() + self.status_var.set(label) + self.progress.start(12) + self.release_button.configure(state="disabled") + self.retry_button.configure(state="disabled") + self.refresh_button.configure(state="disabled") + self.cancel_button.configure(state="normal") + self._append_log(f"\n=== {label} ===") + threading.Thread(target=self._worker_wrapper, args=(target,), daemon=True).start() + + def _worker_wrapper(self, target: Callable[[], None]) -> None: + try: + target() + except ReleaseCancelled as exc: + self.events.put(("done", "cancelled", str(exc))) + except Exception as exc: + self.events.put(("done", "error", str(exc))) + else: + self.events.put(("done", "success", "Release workflow completed.")) + + def _preflight_git(self, *, require_clean: bool) -> None: + if git_output("branch", "--show-current") != "main": + raise ReleaseError("Releases are only allowed from the main branch.") + merge_head = git_output("rev-parse", "-q", "--verify", "MERGE_HEAD", check=False) + if merge_head: + raise ReleaseError("A merge is in progress. Finish it before releasing.") + if require_clean and git_output("status", "--porcelain"): + raise ReleaseError("APK-only publication requires a clean working tree.") + if not require_clean: + staged = git_output("diff", "--cached", "--name-only") + if staged: + raise ReleaseError( + "The Git index already contains staged changes. Unstage or commit them so the " + "release manager can create an exact review checkpoint." + ) + + def _full_release_worker( + self, + version: ReleaseVersion, + commit_message: str, + notes: str, + credentials: dict[str, str], + ) -> None: + self._preflight_git(require_clean=False) + npm = shutil.which("npm") + powershell = shutil.which("powershell.exe") or shutil.which("powershell") + if not npm or not powershell: + raise ReleaseError("npm and Windows PowerShell must be available on PATH.") + + managed_files = (PACKAGE_FILE, PACKAGE_LOCK_FILE, RELEASE_VERSION_FILE) + backups = { + path: path.read_bytes() if path.exists() else None for path in managed_files + } + staged = False + committed = False + base_commit = current_commit() + try: + self._run_command( + [npm, "version", version.version_name, "--no-git-tag-version"], + "Bump package version", + ) + write_release_version(version) + self._log_event( + f"Recorded Android version {version.version_name} / {version.version_code}." + ) + + checks = ( + ([npm, "run", "content:test"], "Content tests"), + ([npm, "run", "server:test"], "Server tests"), + ([npm, "test"], "Application tests"), + ([npm, "run", "build"], "Production build"), + ([npm, "run", "android:sync"], "Android web build and Capacitor sync"), + ) + for command, label in checks: + self._run_command(command, label) + self._run_command(["git", "diff", "--check"], "Whitespace check") + self._run_command(["git", "add", "-A"], "Stage release") + staged = True + + raw_paths = subprocess.run( + ["git", "diff", "--cached", "--name-only", "-z"], + cwd=PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ).stdout + staged_paths = [ + path.decode("utf-8", errors="replace") + for path in raw_paths.split(b"\0") + if path + ] + dangerous = find_dangerous_staged_paths(staged_paths) + if dangerous: + raise ReleaseError( + "Refusing to commit forbidden release output or a possible secret:\n" + + "\n".join(f" - {path}" for path in dangerous) + ) + self._run_command( + ["git", "diff", "--cached", "--check"], "Staged whitespace check" + ) + if not staged_paths: + raise ReleaseError("There are no staged changes to release.") + stat = git_output("diff", "--cached", "--stat") + review = ( + f"Version: {version.version_name}\n" + f"Android code: {version.version_code}\n" + f"Commit: {commit_message}\n\n" + f"Staged paths ({len(staged_paths)}):\n" + + "\n".join(staged_paths) + + f"\n\nDiff summary:\n{stat}\n\nCommit, push main, and publish the APK?" + ) + approved = self._request_approval(review) + if not approved: + raise ReleaseCancelled("Release stopped at the staged-file review.") + + self._run_command(["git", "commit", "-m", commit_message], "Commit release") + committed = current_commit() != base_commit + release_commit = current_commit() + self._run_command(["git", "push", "origin", "main"], "Push main") + remote_line = git_output("ls-remote", "origin", "refs/heads/main") + remote_commit = remote_line.split()[0] if remote_line else "" + if remote_commit != release_commit: + raise ReleaseError( + f"Remote main is {remote_commit or 'unknown'}, expected {release_commit}." + ) + self.events.put(("commit", release_commit)) + self._publish_apk(powershell, version, notes, credentials) + except Exception: + if current_commit() != base_commit: + committed = True + if not committed: + if staged: + subprocess.run( + ["git", "restore", "--staged", ":/"], + cwd=PROJECT_ROOT, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + for path, content in backups.items(): + if content is None: + if path.exists(): + path.unlink() + else: + path.write_bytes(content) + self._log_event("Restored version files because no release commit was created.") + else: + self._log_event( + "The release commit exists. Version files were kept; use " + "'Publish Current APK Only' after correcting the publication problem." + ) + raise + + def _apk_only_worker( + self, + version: ReleaseVersion, + notes: str, + credentials: dict[str, str], + ) -> None: + self._preflight_git(require_clean=True) + local_commit = current_commit() + remote_line = git_output("ls-remote", "origin", "refs/heads/main") + remote_commit = remote_line.split()[0] if remote_line else "" + if local_commit != remote_commit: + raise ReleaseError( + "Current HEAD does not match origin/main; push or update the checkout first." + ) + powershell = shutil.which("powershell.exe") or shutil.which("powershell") + if not powershell: + raise ReleaseError("Windows PowerShell must be available on PATH.") + self._publish_apk(powershell, version, notes, credentials) + self.events.put(("commit", local_commit)) + + def _publish_apk( + self, + powershell: str, + version: ReleaseVersion, + notes: str, + credentials: dict[str, str], + ) -> None: + environment = os.environ.copy() + environment.update(credentials) + self._run_command( + [ + powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(PUBLISH_SCRIPT), + "-VersionName", + version.version_name, + "-VersionCode", + str(version.version_code), + "-ReleaseNotes", + notes, + ], + "Build, sign, and publish APK", + environment=environment, + ) + + def _run_command( + self, + args: Sequence[str], + label: str, + *, + environment: dict[str, str] | None = None, + ) -> None: + if self.cancel_event.is_set(): + raise ReleaseCancelled("Release cancelled.") + self._log_event(f"\n--- {label} ---\n$ {' '.join(args)}") + creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0 + process = subprocess.Popen( + list(args), + cwd=PROJECT_ROOT, + env=environment, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + creationflags=creation_flags, + ) + self.current_process = process + assert process.stdout is not None + try: + for line in process.stdout: + self._log_event(line.rstrip("\n")) + return_code = process.wait() + finally: + self.current_process = None + if self.cancel_event.is_set(): + raise ReleaseCancelled("Release cancelled.") + if return_code != 0: + raise ReleaseError(f"{label} failed with exit code {return_code}.") + self._log_event(f"✓ {label} passed") + + def _request_approval(self, review: str) -> bool: + response: dict[str, bool] = {} + ready = threading.Event() + self.events.put(("approval", review, response, ready)) + while not ready.wait(0.2): + if self.cancel_event.is_set(): + return False + return response.get("approved", False) + + def _show_staged_review(self, review: str) -> bool: + decision = {"approved": False} + dialog = tk.Toplevel(self.root) + dialog.title("Review staged release") + dialog.geometry("820x640") + dialog.minsize(640, 420) + dialog.configure(bg=self.BG) + dialog.transient(self.root) + dialog.grab_set() + dialog.rowconfigure(1, weight=1) + dialog.columnconfigure(0, weight=1) + ttk.Label( + dialog, + text="Inspect every staged path before publishing", + style="Title.TLabel", + ).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10)) + review_text = scrolledtext.ScrolledText( + dialog, + wrap="none", + bg="#071110", + fg="#cfe2dc", + insertbackground=self.TEXT, + relief="flat", + padx=9, + pady=9, + font=("Cascadia Mono", 9), + ) + review_text.grid(row=1, column=0, sticky="nsew", padx=18) + review_text.insert("1.0", review) + review_text.configure(state="disabled") + buttons = ttk.Frame(dialog, padding=(18, 12, 18, 16)) + buttons.grid(row=2, column=0, sticky="ew") + + def finish(approved: bool) -> None: + decision["approved"] = approved + dialog.grab_release() + dialog.destroy() + + ttk.Button(buttons, text="Cancel release", command=lambda: finish(False)).pack( + side="left" + ) + ttk.Button( + buttons, + text="Commit, Push, and Publish", + style="Accent.TButton", + command=lambda: finish(True), + ).pack(side="right") + dialog.protocol("WM_DELETE_WINDOW", lambda: finish(False)) + dialog.wait_window() + return decision["approved"] + + def cancel(self) -> None: + if not self.busy: + return + self.cancel_event.set() + self.status_var.set("Cancelling…") + process = self.current_process + if process and process.poll() is None: + try: + if os.name == "nt": + subprocess.Popen( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + else: + process.terminate() + except OSError: + pass + + def _copy_commands(self) -> None: + selected = self.command_tabs.select() + widget = self.deploy_commands if selected == str(self.deploy_commands.master) else self.verify_commands + value = widget.get("1.0", "end").strip() + "\n" + self.root.clipboard_clear() + self.root.clipboard_append(value) + self.status_var.set("TrueNAS commands copied") + + def _poll_events(self) -> None: + try: + while True: + event = self.events.get_nowait() + kind = event[0] + if kind == "log": + self._append_log(str(event[1])) + elif kind == "approval": + review, response, ready = event[1], event[2], event[3] + approved = self._show_staged_review(str(review)) + assert isinstance(response, dict) + response["approved"] = approved + assert isinstance(ready, threading.Event) + ready.set() + elif kind == "commit": + self.last_commit = str(event[1]) + self._set_text( + self.deploy_commands, + generate_truenas_deploy_commands(self.last_commit), + readonly=True, + ) + elif kind == "done": + self._finish(str(event[1]), str(event[2])) + except queue.Empty: + pass + self.root.after(100, self._poll_events) + + def _finish(self, result: str, detail: str) -> None: + self.busy = False + self.progress.stop() + self.release_button.configure(state="normal") + self.retry_button.configure(state="normal") + self.refresh_button.configure(state="normal") + self.cancel_button.configure(state="disabled") + self.keystore_password_var.set("") + self.key_password_var.set("") + if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file(): + self.token_var.set(read_saved_gitea_token()) + if result == "success": + self.status_var.set("Release completed") + self._append_log("\n✓ Release workflow completed successfully.") + messagebox.showinfo( + "Release complete", + "Git main and the signed APK release are published.\n\n" + "Use the TrueNAS Deploy tab after stopping the app, then the Verify tab after restarting it.", + parent=self.root, + ) + elif result == "cancelled": + self.status_var.set("Release cancelled") + self._append_log(f"\nCancelled: {detail}") + else: + self.status_var.set("Release failed") + self._append_log(f"\nERROR: {detail}") + messagebox.showerror("Release failed", detail, parent=self.root) + self.refresh() + + def _close(self) -> None: + if self.busy: + if not messagebox.askyesno( + "Release is running", + "Cancel the active command and close the release manager?", + parent=self.root, + ): + return + self.cancel() + self.root.destroy() + + root = tk.Tk() + ReleaseManagerApp(root) + if smoke_test: + root.withdraw() + root.update_idletasks() + root.update() + root.destroy() + return 0 + root.mainloop() + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Healer Man visual release manager") + parser.add_argument( + "--check", + action="store_true", + help="print release/version/Git readiness without opening the GUI", + ) + parser.add_argument( + "--smoke-test", + action="store_true", + help=argparse.SUPPRESS, + ) + args = parser.parse_args(argv) + if args.check: + try: + print(json.dumps(check_summary(), indent=2)) + except Exception as exc: + print(f"Release manager check failed: {exc}", file=sys.stderr) + return 1 + return 0 + return launch_gui(smoke_test=args.smoke_test) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_manager_test.py b/scripts/release_manager_test.py new file mode 100644 index 00000000..0702b11d --- /dev/null +++ b/scripts/release_manager_test.py @@ -0,0 +1,49 @@ +import unittest + +import release_manager + + +class VersionTests(unittest.TestCase): + def test_bumps_each_semver_component(self): + self.assertEqual(release_manager.bump_semver("0.1.2", "patch"), "0.1.3") + self.assertEqual(release_manager.bump_semver("0.1.2", "minor"), "0.2.0") + self.assertEqual(release_manager.bump_semver("0.1.2", "major"), "1.0.0") + + def test_android_code_is_monotonic(self): + self.assertEqual(release_manager.semantic_version_code("0.1.3"), 1003) + self.assertEqual(release_manager.suggest_version_code("0.1.3", 5000), 5001) + + def test_rejects_non_release_semver(self): + with self.assertRaises(ValueError): + release_manager.parse_semver("1.2") + + +class SafetyTests(unittest.TestCase): + def test_detects_outputs_and_secrets(self): + paths = [ + "src/game/example.ts", + "dist-android/assets/app.js", + "android/app/release.apk", + ".env.production", + "FINE.key.ts", + "scripts/content/publish-content.mjs", + "public/data/healer-man-loot.sqlite", + ] + self.assertEqual( + release_manager.find_dangerous_staged_paths(paths), + [ + "dist-android/assets/app.js", + "android/app/release.apk", + ".env.production", + ], + ) + + def test_truenas_commands_pin_expected_commit(self): + block = release_manager.generate_truenas_deploy_commands("abc123") + self.assertIn("EXPECTED_COMMIT='abc123'", block) + self.assertIn("pull --ff-only", block) + self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/runAndroidGradle.ps1 b/scripts/runAndroidGradle.ps1 index 74d53b86..09956589 100644 --- a/scripts/runAndroidGradle.ps1 +++ b/scripts/runAndroidGradle.ps1 @@ -8,7 +8,8 @@ $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent $PSScriptRoot $documentsRoot = Split-Path -Parent $projectRoot $wowRoot = Join-Path $documentsRoot 'wow335a' -$jdkRoot = Join-Path $wowRoot 'dungeon-export-work\tools\temurin-21' +$projectJdkRoot = Join-Path $projectRoot '.tools\temurin-21' +$legacyJdkRoot = Join-Path $wowRoot 'dungeon-export-work\tools\temurin-21' $portableSdkRoot = Join-Path $wowRoot '.android-sdk' $jdkHome = $null @@ -16,14 +17,17 @@ $configuredJavaHome = [Environment]::GetEnvironmentVariable('JAVA_HOME') if ($configuredJavaHome -and (Test-Path -LiteralPath (Join-Path $configuredJavaHome 'bin\java.exe'))) { $jdkHome = Get-Item -LiteralPath $configuredJavaHome } else { - $jdkHome = Get-ChildItem -LiteralPath $jdkRoot -Directory -ErrorAction SilentlyContinue | - Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'bin\java.exe') } | + $jdkHome = @($projectJdkRoot, $legacyJdkRoot) | + ForEach-Object { + Get-ChildItem -LiteralPath $_ -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'bin\java.exe') } + } | Sort-Object Name -Descending | Select-Object -First 1 } if (-not $jdkHome) { - throw "Java 21 was not found. Set JAVA_HOME or install the portable JDK under $jdkRoot" + throw "Java 21 was not found. Set JAVA_HOME or install the portable JDK under $projectJdkRoot" } $configuredSdkRoot = [Environment]::GetEnvironmentVariable('ANDROID_SDK_ROOT')