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
|
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 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
|
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
|
line beginning with `Python 3.`. Reopen Balance Lab; the setup panel must show
|
||||||
**Server runtime ready** before **Run on server** becomes available. The server
|
**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
|
## Update workflow
|
||||||
|
|
||||||
For a code update that includes a new APK, the visual workflow in
|
The visual release manager described in `RELEASE_RUNBOOK.md` defaults to **Git
|
||||||
`RELEASE_RUNBOOK.md` is recommended. For a manual release, choose the next
|
+ TrueNAS only**, which commits and pushes a server update without changing the
|
||||||
version first. The example below upgrades `0.1.2` to `0.1.3`; replace `1003` if
|
Android version or publishing an APK. Clear that toggle when a code update also
|
||||||
it is not greater than every prior Android `versionCode`. Then run these
|
requires a new APK. For a manual APK release, choose the next version first.
|
||||||
commands in **Windows PowerShell**
|
The example below upgrades `0.1.2` to `0.1.3`; replace `1003` if it is not
|
||||||
on the development PC. Stop if any test or build fails:
|
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
|
```powershell
|
||||||
Set-Location F:\Projects\HealerMan
|
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/02-level-batch-config.png`,
|
||||||
`playtest-artifacts/balance-lab/04-server-job-complete.png`, and
|
`playtest-artifacts/balance-lab/04-server-job-complete.png`, and
|
||||||
`playtest-artifacts/balance-lab/06-mobile-analytics.png`.
|
`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
|
npm run release:gui
|
||||||
```
|
```
|
||||||
|
|
||||||
The release manager suggests the next semantic patch version and a strictly
|
The release manager opens with **Git + TrueNAS only** enabled. In this default
|
||||||
increasing Android `versionCode`, runs the complete validation suite, and
|
mode it runs the server validation and production build, shows the exact staged
|
||||||
stages the release. It then shows the exact staged paths and diff summary for
|
paths and diff summary for approval, commits, pushes `main`, and pins the
|
||||||
approval before it commits, pushes `main`, builds the signed APK, and publishes
|
TrueNAS **Deploy** tab to the new commit. It does not change `package.json` or
|
||||||
the Gitea release. Signing passwords and the Gitea token are passed to the
|
`release-version.json`, run Android sync, read signing credentials, build an
|
||||||
child process in memory rather than on its command line.
|
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
|
If a content, server, normal-application, or shelved-application test command
|
||||||
fails, the visual manager pauses with the failed command, exit code, and recent
|
fails, the visual manager pauses with the failed command, exit code, and recent
|
||||||
test output. **Stop Release** remains the safe default, but **Continue Anyway**
|
test output. **Stop Release** remains the safe default, but **Continue Anyway**
|
||||||
allows an intentional override and records that decision in the release log.
|
allows an intentional override and records that decision in the release log.
|
||||||
Build, content-publication, Git, signing, and APK-publication failures are not
|
Build, content-publication, Git, signing, and APK-publication failures are not
|
||||||
overridable and still stop the workflow. The manual procedure below remains
|
overridable and still stop the workflow. Android sync, signing, and APK
|
||||||
fail-closed.
|
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
|
Before changing versions or running the longer test/build sequence, the manager
|
||||||
runs an isolated release-candidate check. It validates tracked and untracked
|
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
|
allow-listed, and every job and artifact endpoint authenticates the account's
|
||||||
server-issued GM role.
|
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
|
## Repeatable command-line runs
|
||||||
|
|
||||||
List exact party build IDs at a level:
|
List exact party build IDs at a level:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.56",
|
"version": "0.1.57",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.56",
|
"version": "0.1.57",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.56",
|
"versionName": "0.1.57",
|
||||||
"versionCode": 1056
|
"versionCode": 1057
|
||||||
}
|
}
|
||||||
@@ -41,10 +41,11 @@ def vite_node_command() -> Path:
|
|||||||
return executable
|
return executable
|
||||||
|
|
||||||
|
|
||||||
class RuntimeSession:
|
def runtime_session_command(source: str, level: int) -> list[str]:
|
||||||
def __init__(self, source: str, level: int, workspace: Path) -> None:
|
return [
|
||||||
command = [
|
|
||||||
str(vite_node_command()),
|
str(vite_node_command()),
|
||||||
|
"--mode",
|
||||||
|
"balance-lab",
|
||||||
str(RUNTIME),
|
str(RUNTIME),
|
||||||
"session",
|
"session",
|
||||||
"--source",
|
"--source",
|
||||||
@@ -52,6 +53,11 @@ class RuntimeSession:
|
|||||||
"--level",
|
"--level",
|
||||||
str(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(
|
self.process = subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
cwd=ROOT,
|
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 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.
|
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-?]*[ -/]*[@-~]")
|
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||||
FAILURE_OUTPUT_LINE_LIMIT = 160
|
FAILURE_OUTPUT_LINE_LIMIT = 160
|
||||||
FAILURE_OUTPUT_CHARACTER_LIMIT = 20_000
|
FAILURE_OUTPUT_CHARACTER_LIMIT = 20_000
|
||||||
|
DEFAULT_GIT_TRUENAS_ONLY = True
|
||||||
|
DEFAULT_RUN_TESTS = True
|
||||||
|
|
||||||
|
|
||||||
class ReleaseError(RuntimeError):
|
class ReleaseError(RuntimeError):
|
||||||
@@ -104,15 +106,27 @@ class ReleaseVersion:
|
|||||||
version_code: int
|
version_code: int
|
||||||
|
|
||||||
|
|
||||||
def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
|
def full_release_checks(
|
||||||
"""Commands that must succeed before a release can be staged."""
|
npm: str,
|
||||||
return (
|
*,
|
||||||
|
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", "content:test"], "Content tests"),
|
||||||
([npm, "run", "server:test"], "Server tests"),
|
([npm, "run", "server:test"], "Server tests"),
|
||||||
([npm, "test"], "Application tests"),
|
([npm, "test"], "Application tests"),
|
||||||
([npm, "run", "test:shelved"], "Shelved-mode application tests"),
|
([npm, "run", "test:shelved"], "Shelved-mode application tests"),
|
||||||
|
)
|
||||||
|
required_checks = (
|
||||||
([npm, "run", "content:publish"], "Publish verified 54-dungeon content manifest"),
|
([npm, "run", "content:publish"], "Publish verified 54-dungeon content manifest"),
|
||||||
([npm, "run", "build"], "Production build"),
|
([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"),
|
([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -645,7 +659,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
)
|
)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
title,
|
title,
|
||||||
text="Validate → inspect → commit → push → publish signed APK",
|
text="Validate → inspect → commit → push → deploy, with optional signed APK",
|
||||||
style="Sub.TLabel",
|
style="Sub.TLabel",
|
||||||
).grid(row=1, column=0, sticky="w", pady=(2, 0))
|
).grid(row=1, column=0, sticky="w", pady=(2, 0))
|
||||||
self.refresh_button = ttk.Button(title, text="Refresh", command=self.refresh)
|
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_version_var = tk.StringVar()
|
||||||
self.next_code_var = tk.StringVar()
|
self.next_code_var = tk.StringVar()
|
||||||
self.commit_message_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(
|
ttk.Label(release_panel, text="Current", style="Muted.Panel.TLabel").grid(
|
||||||
row=1, column=0, sticky="w"
|
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(
|
ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid(
|
||||||
row=2, column=0, sticky="w", pady=(12, 0)
|
row=2, column=0, sticky="w", pady=(12, 0)
|
||||||
)
|
)
|
||||||
bump_box = ttk.Combobox(
|
self.bump_box = ttk.Combobox(
|
||||||
release_panel,
|
release_panel,
|
||||||
textvariable=self.bump_var,
|
textvariable=self.bump_var,
|
||||||
values=("Patch", "Minor", "Major"),
|
values=("Patch", "Minor", "Major"),
|
||||||
state="readonly",
|
state="readonly",
|
||||||
width=10,
|
width=10,
|
||||||
)
|
)
|
||||||
bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0))
|
self.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.bind("<<ComboboxSelected>>", lambda _event: self._suggest_versions())
|
||||||
ttk.Label(release_panel, text="Next version", style="Muted.Panel.TLabel").grid(
|
ttk.Label(release_panel, text="Next version", style="Muted.Panel.TLabel").grid(
|
||||||
row=3, column=0, sticky="w", pady=(9, 0)
|
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)
|
row=3, column=1, sticky="ew", padx=(8, 20), pady=(9, 0)
|
||||||
)
|
)
|
||||||
ttk.Label(release_panel, text="Next code", style="Muted.Panel.TLabel").grid(
|
ttk.Label(release_panel, text="Next code", style="Muted.Panel.TLabel").grid(
|
||||||
row=3, column=2, sticky="w", pady=(9, 0)
|
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)
|
row=3, column=3, sticky="ew", padx=(8, 0), pady=(9, 0)
|
||||||
)
|
)
|
||||||
ttk.Label(release_panel, text="Commit message", style="Muted.Panel.TLabel").grid(
|
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(
|
ttk.Entry(release_panel, textvariable=self.commit_message_var).grid(
|
||||||
row=4, column=1, columnspan=3, sticky="ew", padx=(8, 0), pady=(9, 0)
|
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)
|
row=5, column=0, sticky="nw", pady=(9, 0)
|
||||||
)
|
)
|
||||||
self.release_notes = tk.Text(
|
self.release_notes = tk.Text(
|
||||||
@@ -727,12 +753,42 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
self.release_notes.grid(
|
self.release_notes.grid(
|
||||||
row=5, column=1, columnspan=3, sticky="nsew", padx=(8, 0), pady=(9, 0)
|
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 = ttk.Frame(shell, style="Panel.TFrame", padding=15)
|
||||||
secrets_panel.grid(row=1, column=1, sticky="nsew", padx=(8, 0))
|
secrets_panel.grid(row=1, column=1, sticky="nsew", padx=(8, 0))
|
||||||
secrets_panel.columnconfigure(1, weight=1)
|
secrets_panel.columnconfigure(1, weight=1)
|
||||||
ttk.Label(
|
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),
|
font=("Segoe UI Semibold", 12),
|
||||||
).grid(row=0, column=0, columnspan=3, sticky="w")
|
).grid(row=0, column=0, columnspan=3, sticky="w")
|
||||||
ttk.Label(
|
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.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 = ttk.Button(actions, text="Cancel", command=self.cancel, state="disabled")
|
||||||
self.cancel_button.grid(row=0, column=2, padx=(7, 0))
|
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:
|
def _command_text_widget(self, notebook: ttk.Notebook) -> tk.Text:
|
||||||
frame = ttk.Frame(notebook)
|
frame = ttk.Frame(notebook)
|
||||||
@@ -938,7 +1042,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
self.last_commit = current_commit()
|
self.last_commit = current_commit()
|
||||||
self.current_version_var.set(package_version)
|
self.current_version_var.set(package_version)
|
||||||
self.current_code_var.set(str(release_version.version_code))
|
self.current_code_var.set(str(release_version.version_code))
|
||||||
self._suggest_versions()
|
self._update_release_mode()
|
||||||
change_count = len(
|
change_count = len(
|
||||||
[line for line in status.splitlines() if not line.startswith("##")]
|
[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(),
|
generate_truenas_verify_commands(),
|
||||||
readonly=True,
|
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")
|
self.status_var.set("Ready")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.status_var.set("Configuration error")
|
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)
|
next_code = suggest_version_code(next_version, release.version_code)
|
||||||
self.next_version_var.set(next_version)
|
self.next_version_var.set(next_version)
|
||||||
self.next_code_var.set(str(next_code))
|
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}")
|
self.commit_message_var.set(f"Release Healer Man {next_version}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.status_var.set(str(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:
|
def start_full_release(self) -> None:
|
||||||
if self.busy:
|
if self.busy:
|
||||||
return
|
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:
|
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()
|
version = self._requested_version()
|
||||||
credentials = self._credentials()
|
credentials = self._credentials()
|
||||||
toolchain_ready, toolchain_detail = android_toolchain_status()
|
toolchain_ready, toolchain_detail = android_toolchain_status()
|
||||||
if not toolchain_ready:
|
if not toolchain_ready:
|
||||||
raise ReleaseError(toolchain_detail)
|
raise ReleaseError(toolchain_detail)
|
||||||
commit_message = self.commit_message_var.get().strip()
|
|
||||||
notes = self.release_notes.get("1.0", "end").strip()
|
notes = self.release_notes.get("1.0", "end").strip()
|
||||||
if not commit_message:
|
|
||||||
raise ReleaseError("Commit message is required.")
|
|
||||||
if not notes:
|
if not notes:
|
||||||
raise ReleaseError("Release notes are required.")
|
raise ReleaseError("Release notes are required.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
messagebox.showerror("Cannot start release", str(exc), parent=self.root)
|
messagebox.showerror("Cannot start release", str(exc), parent=self.root)
|
||||||
return
|
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(
|
if not messagebox.askyesno(
|
||||||
"Start full release?",
|
"Start full release?",
|
||||||
f"Prepare Healer Man {version.version_name} (Android code {version.version_code})?\n\n"
|
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 "
|
"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,
|
parent=self.root,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
self._begin_worker(
|
self._begin_worker(
|
||||||
"Validating release",
|
"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:
|
def start_apk_only(self) -> None:
|
||||||
@@ -1095,9 +1250,18 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
self._begin_worker(
|
self._begin_worker(
|
||||||
"Publishing current APK",
|
"Publishing current APK",
|
||||||
lambda: self._apk_only_worker(release_version, notes, credentials),
|
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.busy = True
|
||||||
self.cancel_event.clear()
|
self.cancel_event.clear()
|
||||||
self.status_var.set(label)
|
self.status_var.set(label)
|
||||||
@@ -1105,11 +1269,21 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
self.release_button.configure(state="disabled")
|
self.release_button.configure(state="disabled")
|
||||||
self.retry_button.configure(state="disabled")
|
self.retry_button.configure(state="disabled")
|
||||||
self.refresh_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.cancel_button.configure(state="normal")
|
||||||
self._append_log(f"\n=== {label} ===")
|
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:
|
try:
|
||||||
target()
|
target()
|
||||||
except ReleaseCancelled as exc:
|
except ReleaseCancelled as exc:
|
||||||
@@ -1117,7 +1291,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.events.put(("done", "error", str(exc)))
|
self.events.put(("done", "error", str(exc)))
|
||||||
else:
|
else:
|
||||||
self.events.put(("done", "success", "Release workflow completed."))
|
self.events.put(("done", "success", success_message))
|
||||||
|
|
||||||
def _preflight_git(self, *, require_clean: bool) -> None:
|
def _preflight_git(self, *, require_clean: bool) -> None:
|
||||||
if git_output("branch", "--show-current") != "main":
|
if git_output("branch", "--show-current") != "main":
|
||||||
@@ -1137,10 +1311,13 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
|
|
||||||
def _full_release_worker(
|
def _full_release_worker(
|
||||||
self,
|
self,
|
||||||
version: ReleaseVersion,
|
version: ReleaseVersion | None,
|
||||||
commit_message: str,
|
commit_message: str,
|
||||||
notes: str,
|
notes: str,
|
||||||
credentials: dict[str, str],
|
credentials: dict[str, str] | None,
|
||||||
|
*,
|
||||||
|
publish_apk: bool,
|
||||||
|
run_tests: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._preflight_git(require_clean=False)
|
self._preflight_git(require_clean=False)
|
||||||
self._log_event(
|
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)"
|
f"({len(candidate_paths)} changed path(s), including untracked files)"
|
||||||
)
|
)
|
||||||
npm = shutil.which("npm")
|
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")
|
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||||
if not npm or not powershell:
|
if not powershell:
|
||||||
raise ReleaseError("npm and Windows PowerShell must be available on PATH.")
|
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 = {
|
backups = {
|
||||||
path: path.read_bytes() if path.exists() else None for path in managed_files
|
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
|
committed = False
|
||||||
base_commit = current_commit()
|
base_commit = current_commit()
|
||||||
try:
|
try:
|
||||||
|
if publish_apk:
|
||||||
|
assert version is not None
|
||||||
self._run_command(
|
self._run_command(
|
||||||
[npm, "version", version.version_name, "--no-git-tag-version"],
|
[npm, "version", version.version_name, "--no-git-tag-version"],
|
||||||
"Bump package version",
|
"Bump package version",
|
||||||
@@ -1173,8 +1362,22 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
self._log_event(
|
self._log_event(
|
||||||
f"Recorded Android version {version.version_name} / {version.version_code}."
|
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:
|
try:
|
||||||
self._run_command(command, label)
|
self._run_command(command, label)
|
||||||
except ReleaseCommandError as failure:
|
except ReleaseCommandError as failure:
|
||||||
@@ -1221,15 +1424,49 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
if not staged_paths:
|
if not staged_paths:
|
||||||
raise ReleaseError("There are no staged changes to release.")
|
raise ReleaseError("There are no staged changes to release.")
|
||||||
stat = git_output("diff", "--cached", "--stat")
|
stat = git_output("diff", "--cached", "--stat")
|
||||||
review = (
|
if publish_apk:
|
||||||
|
assert version is not None
|
||||||
|
review_heading = (
|
||||||
f"Version: {version.version_name}\n"
|
f"Version: {version.version_name}\n"
|
||||||
f"Android code: {version.version_code}\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"
|
f"Staged paths ({len(staged_paths)}):\n"
|
||||||
+ "\n".join(staged_paths)
|
+ "\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:
|
if not approved:
|
||||||
raise ReleaseCancelled("Release stopped at the staged-file review.")
|
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}."
|
f"Remote main is {remote_commit or 'unknown'}, expected {release_commit}."
|
||||||
)
|
)
|
||||||
self.events.put(("commit", 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)
|
self._publish_apk(powershell, version, notes, credentials)
|
||||||
except Exception:
|
except Exception:
|
||||||
if current_commit() != base_commit:
|
if current_commit() != base_commit:
|
||||||
@@ -1257,18 +1498,31 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
if publish_apk:
|
||||||
for path, content in backups.items():
|
for path, content in backups.items():
|
||||||
if content is None:
|
if content is None:
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
else:
|
else:
|
||||||
path.write_bytes(content)
|
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:
|
else:
|
||||||
|
self._log_event(
|
||||||
|
"No update commit was created; reviewed files were left in the working tree."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if publish_apk:
|
||||||
self._log_event(
|
self._log_event(
|
||||||
"The release commit exists. Version files were kept; use "
|
"The release commit exists. Version files were kept; use "
|
||||||
"'Publish Current APK Only' after correcting the publication problem."
|
"'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
|
raise
|
||||||
|
|
||||||
def _apk_only_worker(
|
def _apk_only_worker(
|
||||||
@@ -1519,27 +1773,28 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
self.release_button.configure(state="normal")
|
self.release_button.configure(state="normal")
|
||||||
self.retry_button.configure(state="normal")
|
self.retry_button.configure(state="normal")
|
||||||
self.refresh_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.cancel_button.configure(state="disabled")
|
||||||
self.keystore_password_var.set("")
|
self.keystore_password_var.set("")
|
||||||
self.key_password_var.set("")
|
self.key_password_var.set("")
|
||||||
if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file():
|
if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file():
|
||||||
self.token_var.set(read_saved_gitea_token())
|
self.token_var.set(read_saved_gitea_token())
|
||||||
if result == "success":
|
if result == "success":
|
||||||
self.status_var.set("Release completed")
|
self.status_var.set("Workflow completed")
|
||||||
self._append_log("\n✓ Release workflow completed successfully.")
|
self._append_log("\n✓ Workflow completed successfully.")
|
||||||
messagebox.showinfo(
|
messagebox.showinfo(
|
||||||
"Release complete",
|
"Workflow complete",
|
||||||
"Git main and the signed APK release are published.\n\n"
|
detail,
|
||||||
"Use the TrueNAS Deploy tab after stopping the app, then the Verify tab after restarting it.",
|
|
||||||
parent=self.root,
|
parent=self.root,
|
||||||
)
|
)
|
||||||
elif result == "cancelled":
|
elif result == "cancelled":
|
||||||
self.status_var.set("Release cancelled")
|
self.status_var.set("Workflow cancelled")
|
||||||
self._append_log(f"\nCancelled: {detail}")
|
self._append_log(f"\nCancelled: {detail}")
|
||||||
else:
|
else:
|
||||||
self.status_var.set("Release failed")
|
self.status_var.set("Workflow failed")
|
||||||
self._append_log(f"\nERROR: {detail}")
|
self._append_log(f"\nERROR: {detail}")
|
||||||
messagebox.showerror("Release failed", detail, parent=self.root)
|
messagebox.showerror("Workflow failed", detail, parent=self.root)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def _close(self) -> None:
|
def _close(self) -> None:
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class VersionTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class SafetyTests(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):
|
def test_detects_outputs_and_secrets(self):
|
||||||
paths = [
|
paths = [
|
||||||
"src/game/example.ts",
|
"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", "build"]))
|
||||||
self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"]))
|
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):
|
def test_only_test_failures_may_be_overridden(self):
|
||||||
checks = release_manager.full_release_checks("npm")
|
checks = release_manager.full_release_checks("npm")
|
||||||
overridable = {
|
overridable = {
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ from scripts.balance_lab import (
|
|||||||
automatic_parties,
|
automatic_parties,
|
||||||
coverage_parties,
|
coverage_parties,
|
||||||
random_party,
|
random_party,
|
||||||
|
runtime_session_command,
|
||||||
search_bosses,
|
search_bosses,
|
||||||
tuning_label,
|
tuning_label,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BalanceLabControllerTests(unittest.TestCase):
|
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:
|
def test_random_party_is_repeatable_and_complete(self) -> None:
|
||||||
builds = [
|
builds = [
|
||||||
{"id": "tank-a", "role": "tank"},
|
{"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 = {}) {
|
export function createBalanceLabJobService(options = {}) {
|
||||||
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
|
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
|
||||||
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
|
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
|
||||||
@@ -411,9 +420,7 @@ export function createBalanceLabJobService(options = {}) {
|
|||||||
} else {
|
} else {
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
job.error = error?.message
|
job.error = error?.message
|
||||||
?? (code === 0
|
?? processFailureMessage(job, code);
|
||||||
? "Balance Lab ended without producing its suite report."
|
|
||||||
: `Balance Lab process exited with code ${code ?? "unknown"}.`);
|
|
||||||
}
|
}
|
||||||
persistJob(job);
|
persistJob(job);
|
||||||
pump();
|
pump();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import { resolveConfig } from "vite";
|
||||||
import {
|
import {
|
||||||
balanceLabCommand,
|
balanceLabCommand,
|
||||||
createBalanceLabJobService,
|
createBalanceLabJobService,
|
||||||
@@ -13,6 +14,12 @@ import {
|
|||||||
normalizeBalanceLabJobSpec,
|
normalizeBalanceLabJobSpec,
|
||||||
} from "./balance-lab-jobs.mjs";
|
} 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", () => {
|
test("normalizes and bounds server Balance Lab requests", () => {
|
||||||
const spec = normalizeBalanceLabJobSpec({
|
const spec = normalizeBalanceLabJobSpec({
|
||||||
source: "ALL",
|
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 () => {
|
test("restores completed reports as previous runs after a server restart", async () => {
|
||||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-history-"));
|
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-history-"));
|
||||||
try {
|
try {
|
||||||
|
|||||||
+5
-2
@@ -10,6 +10,7 @@ const packageVersion = (JSON.parse(readFileSync(new URL("./package.json", import
|
|||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const androidBuild = mode === "android";
|
const androidBuild = mode === "android";
|
||||||
const shelvedModesEnabled = mode === "shelved";
|
const shelvedModesEnabled = mode === "shelved";
|
||||||
|
const balanceLabRuntime = mode === "balance-lab";
|
||||||
const downloadableBuild = androidBuild || mode === "production" || shelvedModesEnabled;
|
const downloadableBuild = androidBuild || mode === "production" || shelvedModesEnabled;
|
||||||
return {
|
return {
|
||||||
plugins: [react(), {
|
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: {
|
define: {
|
||||||
"import.meta.env.VITE_APP_VERSION": JSON.stringify(packageVersion),
|
"import.meta.env.VITE_APP_VERSION": JSON.stringify(packageVersion),
|
||||||
"import.meta.env.VITE_CONTENT_MODE": JSON.stringify(downloadableBuild ? "downloadable" : "bundled"),
|
"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
|
// while iCloud or conversion tools are writing them. They are not app
|
||||||
// source, so watching them only risks taking the dev server down with
|
// source, so watching them only risks taking the dev server down with
|
||||||
// EBUSY on Windows.
|
// EBUSY on Windows.
|
||||||
ignored: [
|
ignored: balanceLabRuntime
|
||||||
|
? ["**/*"]
|
||||||
|
: [
|
||||||
"**/dungeon-pipeline/**",
|
"**/dungeon-pipeline/**",
|
||||||
"**/playtest-artifacts/**",
|
"**/playtest-artifacts/**",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user