Update Healer Man server
This commit is contained in:
1 parent
a05a79eb83
commit
f8314e7de1
14 files changed
+493
-72
No files matched your search
+14
-6
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.56",
|
||||
"versionCode": 1056
|
||||
"versionName": "0.1.57",
|
||||
"versionCode": 1057
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,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"},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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/**",
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user