Update Healer Man server

This commit is contained in:
phenom committed 2026-09-19 14:33:15 -04:00
1 parent a05a79eb83
commit f8314e7de1
14 files changed
+493 -72

No files matched your search

+14 -6
View File
@@ -199,6 +199,13 @@ job returns as failed after restart, while any job with a complete
unless the server has enough spare CPU for multiple full combat sweeps. All
Balance Lab routes independently require a server-authorized GM account.
Balance Lab's one-shot `vite-node` workers run in the dedicated `balance-lab`
Vite mode, which disables the public directory and ignores all filesystem-watch
paths. This is required for parallel sweeps: without it, concurrent workers can
exhaust Linux's file-watcher limit while scanning the shipped asset tree and
fail immediately with `ENOSPC`. Do not use a larger inotify allowance as the
primary fix; deploy the current runtime configuration.
After saving the YAML, restart the TrueNAS app and watch its startup log for a
line beginning with `Python 3.`. Reopen Balance Lab; the setup panel must show
**Server runtime ready** before **Run on server** becomes available. The server
@@ -224,12 +231,13 @@ 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, 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:
The visual release manager described in `RELEASE_RUNBOOK.md` defaults to **Git
+ TrueNAS only**, which commits and pushes a server update without changing the
Android version or publishing an APK. Clear that toggle when a code update also
requires a new APK. For a manual APK 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
+44
View File
@@ -3639,3 +3639,47 @@ Vite's existing large-chunk advisory remains informational.
`playtest-artifacts/balance-lab/02-level-batch-config.png`,
`playtest-artifacts/balance-lab/04-server-job-complete.png`, and
`playtest-artifacts/balance-lab/06-mobile-analytics.png`.
## Balance Lab parallel watcher isolation (2026-09-19)
- A production all-WoW, every-10-level Stockades batch completed only the level
1 and level 80 jobs. Levels 10 through 70 failed during startup before a
party ran. Every failed job recorded `ENOSPC: System limit for number of file
watchers reached` beneath Vite's watcher stack, while the Balance Lab runtime
reported zero active jobs and normal queue capacity after the failures.
- Each parallel Python worker had launched its own `vite-node` process. The
inherited Vite development configuration watched the large shipped public
asset tree, so ten simultaneous jobs exhausted the container's Linux watcher
allowance. Which two levels completed was a startup resource race, not a
class, level, boss, or combat-model failure.
- The headless bridge now launches with `--mode balance-lab`. That Vite mode
sets `publicDir: false` and ignores every watch path, eliminating hot-reload
watchers that one-shot simulations do not use. Server job failures also
translate the known watcher-limit signature into a specific GM-facing error.
- The Python controller tests, all 14 Balance Lab service tests, the complete
84-test server suite, and the 62-test benchmark/party suite passed. A resolved
Vite configuration check returned an empty public directory and
`watch.ignored: ["**/*"]`; a real level-10 WoW catalog invocation completed
through the new mode and wrote its 72,723-byte catalog. The production build
passed, and the authenticated GM smoke flow completed a real server job,
loaded its analytics and ability breakdown, and passed desktop/mobile checks.
## Default Git and TrueNAS release path (2026-09-19)
- The visual release manager now starts with **Git + TrueNAS only** enabled.
This mode retains the candidate check, content/server/application tests,
content publication, production build, staged-file review, commit,
remote-main verification, and pinned TrueNAS deployment commands.
- The default path does not bump package or Android versions, request signing
credentials, run Android sync, build an APK, or publish a Gitea release.
Clearing the toggle restores the existing full APK workflow; **Publish
Current APK Only** remains available for retrying publication from an already
versioned and pushed commit.
- **Run test suites** is independently enabled by default. Clearing it skips
only the content, server, normal-application, and shelved-application test
commands. The candidate check, content publication, production build,
staged review, Git verification, and selected deployment/APK stages remain.
Both the log and final staged review identify the skipped suites.
- All 12 release-manager unit tests and Python compilation passed. The real
Windows Tk smoke launch also initialized the default-on mode and closed
cleanly.
+22 -8
View File
@@ -11,20 +11,34 @@ On the development PC, double-click `scripts\release-manager.cmd` or run:
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 release manager opens with **Git + TrueNAS only** enabled. In this default
mode it runs the server validation and production build, shows the exact staged
paths and diff summary for approval, commits, pushes `main`, and pins the
TrueNAS **Deploy** tab to the new commit. It does not change `package.json` or
`release-version.json`, run Android sync, read signing credentials, build an
APK, or create a Gitea APK release.
Clear **Git + TrueNAS only** when the update also needs a new Android release.
APK mode suggests the next semantic version and a strictly increasing Android
`versionCode`, then performs Android sync and publishes the signed APK after the
Git push. Signing passwords and the Gitea token are passed to the child process
in memory rather than on its command line.
**Run test suites** is also enabled by default. Clear it for a narrowly scoped
update that does not need the content, server, normal-application, or
shelved-application test suites. The manager records the skip in its log and
staged-review dialog. This toggle does not skip the isolated release-candidate
check, content publication, production build, staged whitespace/secret review,
Git push verification, or any Android stages selected by APK mode.
If a content, server, normal-application, or shelved-application test command
fails, the visual manager pauses with the failed command, exit code, and recent
test output. **Stop Release** remains the safe default, but **Continue Anyway**
allows an intentional override and records that decision in the release log.
Build, content-publication, Git, signing, and APK-publication failures are not
overridable and still stop the workflow. The manual procedure below remains
fail-closed.
overridable and still stop the workflow. Android sync, signing, and APK
publication are omitted entirely in Git + TrueNAS mode. The manual procedure
below remains fail-closed.
Before changing versions or running the longer test/build sequence, the manager
runs an isolated release-candidate check. It validates tracked and untracked
+9
View File
@@ -152,6 +152,15 @@ arguments are passed as an array without a shell, artifact names are
allow-listed, and every job and artifact endpoint authenticates the account's
server-issued GM role.
Each Python worker starts the TypeScript bridge with `vite-node --mode
balance-lab`. That dedicated mode disables Vite's public directory and ignores
all filesystem-watch paths because a headless, one-shot simulation does not
need hot reload. Keep this isolation in place when changing `vite.config.ts`:
parallel workers must not consume one watcher per shipped asset. If an older
deployment exhausts the host watcher limit, the job now reports that specific
startup failure instead of only the subprocess exit code; deploy the no-watch
runtime fix rather than increasing the host's watcher allowance.
## Repeatable command-line runs
List exact party build IDs at a level:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.56",
"version": "0.1.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.56",
"version": "0.1.57",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.56",
"version": "0.1.57",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.56",
"versionCode": 1056
"versionName": "0.1.57",
"versionCode": 1057
}
+9 -3
View File
@@ -41,10 +41,11 @@ def vite_node_command() -> Path:
return executable
class RuntimeSession:
def __init__(self, source: str, level: int, workspace: Path) -> None:
command = [
def runtime_session_command(source: str, level: int) -> list[str]:
return [
str(vite_node_command()),
"--mode",
"balance-lab",
str(RUNTIME),
"session",
"--source",
@@ -52,6 +53,11 @@ class RuntimeSession:
"--level",
str(level),
]
class RuntimeSession:
def __init__(self, source: str, level: int, workspace: Path) -> None:
command = runtime_session_command(source, level)
self.process = subprocess.Popen(
command,
cwd=ROOT,
+300 -45
View File
@@ -1,4 +1,4 @@
"""Visual release manager for Healer Man with explicit safety gates.
"""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.
@@ -71,6 +71,8 @@ OVERRIDABLE_TEST_LABELS = frozenset(
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):
@@ -104,15 +106,27 @@ class ReleaseVersion:
version_code: int
def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
"""Commands that must succeed before a release can be staged."""
return (
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"),
)
@@ -645,7 +659,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
)
ttk.Label(
title,
text="Validate → inspect → commit → push → publish signed APK",
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)
@@ -665,6 +679,12 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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"
@@ -682,25 +702,31 @@ def launch_gui(*, smoke_test: bool = False) -> int:
ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid(
row=2, column=0, sticky="w", pady=(12, 0)
)
bump_box = ttk.Combobox(
self.bump_box = ttk.Combobox(
release_panel,
textvariable=self.bump_var,
values=("Patch", "Minor", "Major"),
state="readonly",
width=10,
)
bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0))
bump_box.bind("<<ComboboxSelected>>", lambda _event: self._suggest_versions())
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)
)
ttk.Entry(release_panel, textvariable=self.next_version_var, width=15).grid(
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)
)
ttk.Entry(release_panel, textvariable=self.next_code_var, width=12).grid(
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(
@@ -709,7 +735,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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(
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(
@@ -727,12 +753,42 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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", style="Panel.TLabel",
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(
@@ -869,6 +925,54 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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)
@@ -938,7 +1042,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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()
self._update_release_mode()
change_count = len(
[line for line in status.splitlines() if not line.startswith("##")]
)
@@ -956,10 +1060,6 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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")
@@ -973,6 +1073,9 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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))
@@ -1037,32 +1140,84 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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)
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 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.",
"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),
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:
@@ -1095,9 +1250,18 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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]) -> None:
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)
@@ -1105,11 +1269,21 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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,), daemon=True).start()
threading.Thread(
target=self._worker_wrapper,
args=(target, success_message),
daemon=True,
).start()
def _worker_wrapper(self, target: Callable[[], None]) -> None:
def _worker_wrapper(
self,
target: Callable[[], None],
success_message: str,
) -> None:
try:
target()
except ReleaseCancelled as exc:
@@ -1117,7 +1291,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
except Exception as exc:
self.events.put(("done", "error", str(exc)))
else:
self.events.put(("done", "success", "Release workflow completed."))
self.events.put(("done", "success", success_message))
def _preflight_git(self, *, require_clean: bool) -> None:
if git_output("branch", "--show-current") != "main":
@@ -1137,10 +1311,13 @@ def launch_gui(*, smoke_test: bool = False) -> int:
def _full_release_worker(
self,
version: ReleaseVersion,
version: ReleaseVersion | None,
commit_message: str,
notes: str,
credentials: dict[str, str],
credentials: dict[str, str] | None,
*,
publish_apk: bool,
run_tests: bool,
) -> None:
self._preflight_git(require_clean=False)
self._log_event(
@@ -1153,11 +1330,21 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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 npm or not powershell:
raise ReleaseError("npm and Windows PowerShell must be available on PATH.")
if not powershell:
raise ReleaseError("Windows PowerShell must be available on PATH.")
managed_files = (PACKAGE_FILE, PACKAGE_LOCK_FILE, RELEASE_VERSION_FILE)
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
}
@@ -1165,6 +1352,8 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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",
@@ -1173,8 +1362,22 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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):
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:
@@ -1221,15 +1424,49 @@ def launch_gui(*, smoke_test: bool = False) -> int:
if not staged_paths:
raise ReleaseError("There are no staged changes to release.")
stat = git_output("diff", "--cached", "--stat")
review = (
if publish_apk:
assert version is not None
review_heading = (
f"Version: {version.version_name}\n"
f"Android code: {version.version_code}\n"
f"Commit: {commit_message}\n\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\nCommit, push main, and publish the APK?"
+ 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,
)
approved = self._request_approval(review)
if not approved:
raise ReleaseCancelled("Release stopped at the staged-file review.")
@@ -1244,6 +1481,10 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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:
@@ -1257,18 +1498,31 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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.")
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(
@@ -1519,27 +1773,28 @@ def launch_gui(*, smoke_test: bool = False) -> int:
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("Release completed")
self._append_log("\n✓ Release workflow completed successfully.")
self.status_var.set("Workflow completed")
self._append_log("\n✓ 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.",
"Workflow complete",
detail,
parent=self.root,
)
elif result == "cancelled":
self.status_var.set("Release cancelled")
self.status_var.set("Workflow cancelled")
self._append_log(f"\nCancelled: {detail}")
else:
self.status_var.set("Release failed")
self.status_var.set("Workflow failed")
self._append_log(f"\nERROR: {detail}")
messagebox.showerror("Release failed", detail, parent=self.root)
messagebox.showerror("Workflow failed", detail, parent=self.root)
self.refresh()
def _close(self) -> None:
+26
View File
@@ -22,6 +22,10 @@ class VersionTests(unittest.TestCase):
class SafetyTests(unittest.TestCase):
def test_safe_update_options_are_enabled_by_default(self):
self.assertTrue(release_manager.DEFAULT_GIT_TRUENAS_ONLY)
self.assertTrue(release_manager.DEFAULT_RUN_TESTS)
def test_detects_outputs_and_secrets(self):
paths = [
"src/game/example.ts",
@@ -54,6 +58,28 @@ class SafetyTests(unittest.TestCase):
self.assertLess(publish_index, commands.index(["npm", "run", "build"]))
self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"]))
def test_git_and_truenas_checks_skip_only_the_android_stage(self):
checks = release_manager.full_release_checks("npm", include_android=False)
commands = [command for command, _label in checks]
self.assertIn(["npm", "run", "content:publish"], commands)
self.assertIn(["npm", "run", "build"], commands)
self.assertNotIn(["npm", "run", "android:sync"], commands)
def test_disabled_test_suites_keep_safety_and_build_checks(self):
checks = release_manager.full_release_checks(
"npm",
include_android=False,
include_tests=False,
)
commands = [command for command, _label in checks]
self.assertEqual(
commands,
[
["npm", "run", "content:publish"],
["npm", "run", "build"],
],
)
def test_only_test_failures_may_be_overridden(self):
checks = release_manager.full_release_checks("npm")
overridable = {
+6
View File
@@ -6,12 +6,18 @@ from scripts.balance_lab import (
automatic_parties,
coverage_parties,
random_party,
runtime_session_command,
search_bosses,
tuning_label,
)
class BalanceLabControllerTests(unittest.TestCase):
def test_headless_runtime_uses_the_no_watch_vite_mode(self) -> None:
command = runtime_session_command("wow", 40)
self.assertEqual(command[1:3], ["--mode", "balance-lab"])
self.assertEqual(command[-4:], ["--source", "wow", "--level", "40"])
def test_random_party_is_repeatable_and_complete(self) -> None:
builds = [
{"id": "tank-a", "role": "tank"},
+10 -3
View File
@@ -227,6 +227,15 @@ function lineCollector(onLine) {
};
}
function processFailureMessage(job, code) {
if (job.logTail.some((line) => line.includes("ENOSPC: System limit for number of file watchers reached"))) {
return "Balance Lab could not start because the server file-watcher limit was exhausted.";
}
return code === 0
? "Balance Lab ended without producing its suite report."
: `Balance Lab process exited with code ${code ?? "unknown"}.`;
}
export function createBalanceLabJobService(options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
@@ -411,9 +420,7 @@ export function createBalanceLabJobService(options = {}) {
} else {
job.status = "failed";
job.error = error?.message
?? (code === 0
? "Balance Lab ended without producing its suite report."
: `Balance Lab process exited with code ${code ?? "unknown"}.`);
?? processFailureMessage(job, code);
}
persistJob(job);
pump();
+43
View File
@@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import test from "node:test";
import { resolveConfig } from "vite";
import {
balanceLabCommand,
createBalanceLabJobService,
@@ -13,6 +14,12 @@ import {
normalizeBalanceLabJobSpec,
} from "./balance-lab-jobs.mjs";
test("disables public assets and filesystem watching in Balance Lab Vite mode", async () => {
const config = await resolveConfig({ server: { watch: null } }, "serve", "balance-lab");
assert.equal(config.publicDir, "");
assert.deepEqual(config.server.watch?.ignored, ["**/*"]);
});
test("normalizes and bounds server Balance Lab requests", () => {
const spec = normalizeBalanceLabJobSpec({
source: "ALL",
@@ -155,6 +162,42 @@ test("queues a job, reports progress, and exposes only allow-listed artifacts",
}
});
test("reports exhausted file watchers as a specific startup failure", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-watchers-"));
try {
const spawnProcess = () => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
setImmediate(() => {
child.stderr.write("Error: ENOSPC: System limit for number of file watchers reached, watch '/app/public/assets/example'\n");
child.stdout.end();
child.stderr.end();
child.emit("close", 1);
});
return child;
};
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess,
pythonExecutable: "python-test",
});
const started = service.start({
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}, { username: "Phenom" });
await new Promise((resolve) => setImmediate(resolve));
const failed = service.get(started.id);
assert.equal(failed.status, "failed");
assert.match(failed.error, /file-watcher limit was exhausted/i);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("restores completed reports as previous runs after a server restart", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-history-"));
try {
+5 -2
View File
@@ -10,6 +10,7 @@ const packageVersion = (JSON.parse(readFileSync(new URL("./package.json", import
export default defineConfig(({ mode }) => {
const androidBuild = mode === "android";
const shelvedModesEnabled = mode === "shelved";
const balanceLabRuntime = mode === "balance-lab";
const downloadableBuild = androidBuild || mode === "production" || shelvedModesEnabled;
return {
plugins: [react(), {
@@ -40,7 +41,7 @@ export default defineConfig(({ mode }) => {
});
},
}],
publicDir: androidBuild ? ".android-public" : downloadableBuild ? ".web-public" : "public",
publicDir: balanceLabRuntime ? false : androidBuild ? ".android-public" : downloadableBuild ? ".web-public" : "public",
define: {
"import.meta.env.VITE_APP_VERSION": JSON.stringify(packageVersion),
"import.meta.env.VITE_CONTENT_MODE": JSON.stringify(downloadableBuild ? "downloadable" : "bundled"),
@@ -84,7 +85,9 @@ export default defineConfig(({ mode }) => {
// while iCloud or conversion tools are writing them. They are not app
// source, so watching them only risks taking the dev server down with
// EBUSY on Windows.
ignored: [
ignored: balanceLabRuntime
? ["**/*"]
: [
"**/dungeon-pipeline/**",
"**/playtest-artifacts/**",
],