From 09df754b1ec48f6846862aedfd7dc391bce2231e Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 03:22:25 +0000 Subject: [PATCH 1/5] build the Mac and Windows Sandbox downloads in CI, smoke-check them, publish a Release by dispatch --- .github/workflows/sandbox-app.yml | 94 ++++++++++++++++++ .gitignore | 3 + AGENTS.md | 1 + node/src/storm_shield/cache_pack.py | 2 +- node/tests/test_sandbox_app_smoke.py | 35 +++++++ scripts/AGENTS.md | 1 + scripts/sandbox-app-release-notes.md | 18 ++++ scripts/sandbox_app_build.py | 90 +++++++++++++++++ scripts/sandbox_app_entry.py | 5 + scripts/sandbox_app_smoke.py | 139 +++++++++++++++++++++++++++ 10 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sandbox-app.yml create mode 100644 node/tests/test_sandbox_app_smoke.py create mode 100644 scripts/sandbox-app-release-notes.md create mode 100644 scripts/sandbox_app_build.py create mode 100644 scripts/sandbox_app_entry.py create mode 100644 scripts/sandbox_app_smoke.py diff --git a/.github/workflows/sandbox-app.yml b/.github/workflows/sandbox-app.yml new file mode 100644 index 00000000..26327ddb --- /dev/null +++ b/.github/workflows/sandbox-app.yml @@ -0,0 +1,94 @@ +# Builds the Sandbox app for macOS (Apple Silicon) and Windows (x64) and smoke-checks each +# download. Runs on any pull request touching the packaging; by manual dispatch with a version +# number it also publishes the Release `sandbox-app-v`. Kept apart from ci.yml because +# `scripts/check.sh` stays quick and platform-free. Spec: docs/specs/general-sensors.md, decision 7. +name: Sandbox app + +on: + pull_request: + paths: + - .github/workflows/sandbox-app.yml + - scripts/sandbox_app_*.py + - scripts/sandbox-app-release-notes.md + - node/src/storm_shield/app_files.py + - node/src/storm_shield/cache_pack.py + - node/src/storm_shield/sandbox_app.py + - node/pyproject.toml + workflow_dispatch: + inputs: + version: + description: "Release number N: publishes sandbox-app-vN (not marked latest)" + required: true + +concurrency: + group: sandbox-app-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: Build and smoke check (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - platform: macos-arm64 + runner: macos-14 + - platform: windows-x64 + runner: windows-2022 + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.12" + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: dashboard/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile && pnpm build + working-directory: dashboard + - run: uv run --project node --with "pyinstaller>=6,<7" python scripts/sandbox_app_build.py + - run: uv run --project node python scripts/sandbox_app_smoke.py dist-sandbox-app/Storm-Shield-Sandbox-${{ matrix.platform }}.zip + - uses: actions/upload-artifact@v4 + with: + name: sandbox-app-${{ matrix.platform }} + path: dist-sandbox-app/Storm-Shield-Sandbox-${{ matrix.platform }}.zip + if-no-files-found: error + + publish: + name: Publish the Release + if: github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: assets + merge-multiple: true + - name: Create the Release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + case "$VERSION" in + ''|*[!0-9]*) echo "version must be a number, got '$VERSION'"; exit 1 ;; + esac + gh release create "sandbox-app-v$VERSION" assets/*.zip \ + --title "Storm Shield Sandbox app v$VERSION" \ + --notes-file scripts/sandbox-app-release-notes.md \ + --latest=false --target "$GITHUB_SHA" diff --git a/.gitignore b/.gitignore index bd7f7bb1..5c5a124f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ node/data/ # local demo state (data folders, logs, downloaded pack) /.demo/ /.join-demo/ + +# Sandbox app build output +/dist-sandbox-app/ diff --git a/AGENTS.md b/AGENTS.md index 5fc68536..0315e103 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ Run these before calling any issue done. An agent must not claim "done" without | Build dashboard | `cd dashboard && pnpm build` | | Run a Node | `cd node && uv run storm-shield-node` (serves the built dashboard; `NODE_ROLE=hub` for the Hub, default `sensor`; `NODE_REGION=` picks the Hub's Region, and its Cache Pack is downloaded from the Release `cache-pack--v` unless `CACHE_PACK_URL` says otherwise) | | Try the app with no setup | `scripts/sandbox.sh` from anywhere (a Hub and four registered simulated Sensors in one process, then the browser; `--port`, `--pack`; it uses the Region's pack from `node/build//` or `.demo/pack//`, else downloads it from the Release once, and says `scripts/build-cache-pack.sh ` when neither works; it builds the dashboard on the first run, so it needs `pnpm`) | +| Sandbox app (Mac/Windows downloads) | built by the "Sandbox app" workflow (`.github/workflows/sandbox-app.yml`), not by `scripts/check.sh`: a pull request touching the packaging builds and smoke-checks both; dispatch it with a number to publish the Release `sandbox-app-v`. Try the smoke check on your own machine: `uv run --project node --with "pyinstaller>=6,<7" python scripts/sandbox_app_build.py`, then `uv run --project node python scripts/sandbox_app_smoke.py dist-sandbox-app/` (Mac or Windows only) | | Keep the Pi's Hub on your checkout | `scripts/run-live.sh` (a Hub by default; rebuilds whenever `HEAD` moves, e.g. after `git pull`, and swaps the Node only when the build worked) | | Build a Region's Cache Pack | `scripts/build-cache-pack.sh nepal` (reads `regions//region.json`; needs the internet and `uv`; writes `node/build//cache-pack.zip`; notes in `scripts/cache-pack/README.md`) | | Dashboard dev server | `cd dashboard && pnpm dev` (proxies `/api` to port 8000) | diff --git a/node/src/storm_shield/cache_pack.py b/node/src/storm_shield/cache_pack.py index d8c4b4b3..34d7cf87 100644 --- a/node/src/storm_shield/cache_pack.py +++ b/node/src/storm_shield/cache_pack.py @@ -19,7 +19,7 @@ RELEASES_URL = "https://github.com/StormShields/Stormshield/releases/download" # The version of each built-in Region's pack published as the Release `cache-pack--v` # (scripts/cache-pack/README.md): publishing a new version means changing its number here. -RELEASED_PACKS = {"nepal": 1, "fort-myers": 2} +RELEASED_PACKS = {"nepal": 2, "fort-myers": 2} class InvalidCachePack(Exception): diff --git a/node/tests/test_sandbox_app_smoke.py b/node/tests/test_sandbox_app_smoke.py new file mode 100644 index 00000000..6791bbb8 --- /dev/null +++ b/node/tests/test_sandbox_app_smoke.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from sandbox_app_smoke import bundle_problems + + +def _bundle(root: Path, regions: tuple[str, ...] = ("nepal", "fort-myers")) -> Path: + for region in regions: + pack = root / "_internal" / "node" / "build" / region / "cache-pack.zip" + pack.parent.mkdir(parents=True) + pack.write_bytes(b"pack") + return root + + +def test_a_download_with_both_packs_and_no_env_file_has_no_problems(tmp_path: Path) -> None: + assert bundle_problems(_bundle(tmp_path)) == [] + + +def test_an_env_file_anywhere_in_the_download_is_a_problem(tmp_path: Path) -> None: + _bundle(tmp_path) + (tmp_path / "_internal" / ".env").write_text("SECRET=1") + (tmp_path / ".env.production").write_text("SECRET=2") + (tmp_path / ".env.example").write_text("") + + problems = bundle_problems(tmp_path) + + assert sorted(problems) == [ + ".env.production must not be in the download", + "_internal/.env must not be in the download", + ] + + +def test_a_missing_region_pack_is_a_problem(tmp_path: Path) -> None: + assert bundle_problems(_bundle(tmp_path, ("nepal",))) == [ + "the fort-myers Cache Pack is not in the download" + ] diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 725630e8..3945c5c9 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -14,3 +14,4 @@ Area gotchas that are still true. Claude Code loads this file (through `CLAUDE.m - **`pack_ian_event.py` writes the committed `regions/fort-myers/events/ian-2022.json`**; a test fails when the file is not what the sources build, so rerun the script after touching `region.json` (the gauge datum offset) or `sources/`. - **Every httpx builder fetches through `http_retry.get_with_retries`** (the one streamed OpenStreetMap extract download aside, which fails the build with the error type). It prints only the error type and status, never `str(error)`: an httpx message holds the URL, which can carry a key. - **`stn.wim.usgs.gov/STNServices` is gone: it redirects to a web page.** The STN data is at `api.waterdata.usgs.gov/stnservices/` (GET needs no key), and the sensors' water level is a NetCDF file; the recipe is in `regions/fort-myers/sources/README.md`. Keep every 30 s reading: the data swings at about the 6-minute filter period, so thinning to the gauge's 6-minute cadence misses the peaks. +- **The Sandbox app bundles the Region's *published* Cache Pack (`RELEASED_PACKS`), so a Region file change that adds a required field needs a new pack Release and a bumped number** or the app's Region switch fails with a 500 (Nepal v1 lacked `hub_site`). The smoke check in `.github/workflows/sandbox-app.yml` catches it. diff --git a/scripts/sandbox-app-release-notes.md b/scripts/sandbox-app-release-notes.md new file mode 100644 index 00000000..58592f9f --- /dev/null +++ b/scripts/sandbox-app-release-notes.md @@ -0,0 +1,18 @@ +Try Storm Shield on your own computer: nothing to install, and no terminal. Everything you see is **SIMULATED**: a made-up Mesh of Sensors replaying a real flood. + +## Download + +- **Mac (Apple Silicon: M1 or newer):** `Storm-Shield-Sandbox-macos-arm64.zip` +- **Windows (64-bit):** `Storm-Shield-Sandbox-windows-x64.zip` + +Unzip it, then open **Storm Shield Sandbox**. Your browser opens the dashboard. Press **Stop** in the Sandbox Panel when you are done; it also ends by itself ten minutes after you close the tab. It is reachable only from your own computer. + +## The first time, your computer will warn you + +The app is not signed (signing needs paid accounts), so Windows and macOS ask you to confirm. It is safe to continue, and the source of everything in it is in this repository. + +**Windows:** if "Windows protected your PC" appears, click **More info**, then **Run anyway**. + +**Mac:** if it says the app "can't be opened", close that message, open **System Settings → Privacy & Security**, scroll down to the message about Storm Shield Sandbox and click **Open Anyway**, then confirm. + +If nothing seems to happen, look for `storm-shield-sandbox-failed.html` or `storm-shield-sandbox.log` in your temporary folder; the app opens a page saying what went wrong when it cannot start. diff --git a/scripts/sandbox_app_build.py b/scripts/sandbox_app_build.py new file mode 100644 index 00000000..49527b83 --- /dev/null +++ b/scripts/sandbox_app_build.py @@ -0,0 +1,90 @@ +"""Builds the Sandbox app for the platform this runs on: one folder PyInstaller makes, zipped. + +It holds Python, the Node package, the built dashboard, `regions/` and the current Cache Pack of +each built-in Region, so the app downloads nothing on its first run. PyInstaller cannot +cross-build, so each platform runs this on its own machine (.github/workflows/sandbox-app.yml). + + uv run --project node --with "pyinstaller>=6,<7" python scripts/sandbox_app_build.py +""" + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import httpx + +from storm_shield.cache_pack import RELEASED_PACKS, ZIP_NAME, release_url + +REPO = Path(__file__).resolve().parents[1] +APP_NAME = "Storm Shield Sandbox" +ENTRY_POINT = REPO / "scripts" / "sandbox_app_entry.py" +OUTPUT = REPO / "dist-sandbox-app" +MAC = sys.platform == "darwin" +PLATFORM = "macos-arm64" if MAC else "windows-x64" +ZIP_PATH = OUTPUT / f"Storm-Shield-Sandbox-{PLATFORM}.zip" + + +def download_packs(into: Path) -> None: + """Each built-in Region's published pack, laid out where the Sandbox looks for a built one.""" + with httpx.Client(timeout=120, follow_redirects=True) as client: + for region in RELEASED_PACKS: + response = client.get(str(release_url(region))) + response.raise_for_status() + pack = into / "node" / "build" / region / ZIP_NAME + pack.parent.mkdir(parents=True) + pack.write_bytes(response.content) + + +def bundle(packs: Path) -> Path: + dashboard = REPO / "dashboard" / "dist" + if not (dashboard / "index.html").is_file(): + raise SystemExit("build the dashboard first: cd dashboard && pnpm install && pnpm build") + work = OUTPUT / "work" + # Only these folders go in: nothing else of the checkout, so nothing like a `.env` can. + data = [ + (dashboard, "dashboard/dist"), + (REPO / "regions", "regions"), + (packs / "node" / "build", "node/build"), + ] + command = [ + sys.executable, "-m", "PyInstaller", str(ENTRY_POINT), + "--noconfirm", "--clean", "--windowed", "--name", APP_NAME, + "--distpath", str(OUTPUT / "dist"), "--workpath", str(work), "--specpath", str(work), + "--paths", str(REPO / "node" / "src"), + # Reached by name at run time (uvicorn's loops, Reticulum's interfaces), so not found by + # reading the imports. + "--collect-submodules", "storm_shield", + "--collect-submodules", "uvicorn", + "--collect-all", "RNS", + ] # fmt: skip + for source, inside in data: + command += ["--add-data", f"{source}{';' if sys.platform == 'win32' else ':'}{inside}"] + subprocess.run(command, check=True) + return OUTPUT / "dist" / (f"{APP_NAME}.app" if MAC else APP_NAME) + + +def zip_bundle(built: Path) -> None: + ZIP_PATH.unlink(missing_ok=True) + if MAC: + # `ditto` keeps the symlinks and permissions a .app needs; `zip` modules do not. + subprocess.run( + ["ditto", "-c", "-k", "--keepParent", str(built), str(ZIP_PATH)], check=True + ) + else: + archive = shutil.make_archive(str(ZIP_PATH.with_suffix("")), "zip", built.parent, built.name) + assert Path(archive) == ZIP_PATH + + +def main() -> None: + shutil.rmtree(OUTPUT, ignore_errors=True) + OUTPUT.mkdir() + with tempfile.TemporaryDirectory() as packs: + download_packs(Path(packs)) + zip_bundle(bundle(Path(packs))) + print(f"built {ZIP_PATH}") + + +if __name__ == "__main__": + main() diff --git a/scripts/sandbox_app_entry.py b/scripts/sandbox_app_entry.py new file mode 100644 index 00000000..15f77a0d --- /dev/null +++ b/scripts/sandbox_app_entry.py @@ -0,0 +1,5 @@ +"""What PyInstaller starts: the Sandbox app, with no arguments.""" + +from storm_shield.sandbox_app import main + +main() diff --git a/scripts/sandbox_app_smoke.py b/scripts/sandbox_app_smoke.py new file mode 100644 index 00000000..dd218f0a --- /dev/null +++ b/scripts/sandbox_app_smoke.py @@ -0,0 +1,139 @@ +"""Starts the built Sandbox app from its zip, as CI's only test of the bundle. + +Checks that no `.env` is inside and both Regions' Cache Packs are, that the Hub reports a Sandbox +with Sensors, that switching Region works on the bundled pack, and that Stop ends the process and +leaves no temp folder behind. Any failure exits non-zero with the app's log. + + uv run --project node python scripts/sandbox_app_smoke.py dist-sandbox-app/ +""" + +import os +import re +import subprocess +import sys +import tempfile +import time +import zipfile +from pathlib import Path + +import httpx + +from storm_shield.cache_pack import RELEASED_PACKS, ZIP_NAME + +APP_NAME = "Storm Shield Sandbox" +WINDOWS = sys.platform == "win32" +LOG_NAME = "storm-shield-sandbox.log" +TEMP_FOLDER_PREFIX = "storm-shield-sandbox-" +START_SECONDS = 180 +STOP_SECONDS = 60 + + +def bundle_problems(unpacked: Path) -> list[str]: + """What is wrong with the unpacked download: a `.env` file in it, or a Region's pack missing.""" + problems = [ + f"{path.relative_to(unpacked)} must not be in the download" + for path in unpacked.rglob(".env*") + if path.name != ".env.example" + ] + for region in RELEASED_PACKS: + if not any(unpacked.rglob(f"build/{region}/{ZIP_NAME}")): + problems.append(f"the {region} Cache Pack is not in the download") + return problems + + +def unpack(zip_path: Path, into: Path) -> Path: + if sys.platform == "darwin": + subprocess.run(["ditto", "-x", "-k", str(zip_path), str(into)], check=True) + return into / f"{APP_NAME}.app" / "Contents" / "MacOS" / APP_NAME + with zipfile.ZipFile(zip_path) as archive: + archive.extractall(into) + return into / APP_NAME / f"{APP_NAME}.exe" + + +def wait_for_port(app: subprocess.Popen[bytes], log: Path) -> int: + deadline = time.monotonic() + START_SECONDS + while time.monotonic() < deadline: + if app.poll() is not None: + raise SystemExit(f"the app ended on its own with exit code {app.returncode}") + found = re.search(r"Sandbox dashboard: http://127\.0\.0\.1:(\d+)", _read(log)) + if found: + return int(found.group(1)) + time.sleep(1) + raise SystemExit("the app never said where its dashboard is") + + +def wait_until_answering(client: httpx.Client, base: str) -> None: + deadline = time.monotonic() + START_SECONDS + while time.monotonic() < deadline: + try: + client.get(f"{base}/api/state").raise_for_status() + return + except httpx.HTTPError: + time.sleep(1) + raise SystemExit("the dashboard never answered /api/state") + + +def check_running_app(client: httpx.Client, base: str) -> None: + state = client.get(f"{base}/api/state").json() + if not state.get("sandbox"): + raise SystemExit(f"/api/state reports no sandbox: {state.get('sandbox')!r}") + if not client.get(f"{base}/api/sandbox/nodes").json()["nodes"]: + raise SystemExit("the Sandbox has no Sensors") + regions = client.get(f"{base}/api/sandbox/regions").json() + other = next(r["name"] for r in regions["regions"] if r["name"] != regions["current"]) + switched = client.post(f"{base}/api/sandbox/region", json={"region": other}) + if switched.status_code != 200 or switched.json()["current"] != other: + raise SystemExit(f"switching to {other} failed: {switched.status_code} {switched.text}") + + +def stop(client: httpx.Client, base: str, app: subprocess.Popen[bytes]) -> None: + # The Origin a page of the Sandbox's own sends; without it the route refuses. + client.post(f"{base}/api/sandbox/stop", headers={"Origin": base}).raise_for_status() + try: + app.wait(STOP_SECONDS) + except subprocess.TimeoutExpired: + raise SystemExit(f"Stop did not end the app within {STOP_SECONDS} s") from None + + +def _read(path: Path) -> str: + return path.read_text(errors="replace") if path.is_file() else "" + + +def main(zip_path: Path) -> None: + with tempfile.TemporaryDirectory() as work_dir: + work = Path(work_dir) + unpacked, temp = work / "unpacked", work / "temp" + unpacked.mkdir() + temp.mkdir() + executable = unpack(zip_path, unpacked) + problems = bundle_problems(unpacked) + if problems: + raise SystemExit("\n".join(problems)) + stub = "python -c pass" if WINDOWS else "python3 -c pass" + # The app's temp folder is ours, so "the folder is gone" is a plain look inside it; and + # `BROWSER` is how Python's `webbrowser` is told to open something else than a browser. + env = {**os.environ, "BROWSER": stub, "TMPDIR": str(temp), "TEMP": str(temp), "TMP": str(temp)} + app = subprocess.Popen([str(executable)], env=env, stdout=subprocess.DEVNULL) + log = temp / LOG_NAME + try: + base = f"http://127.0.0.1:{wait_for_port(app, log)}" + with httpx.Client(timeout=30) as client: + wait_until_answering(client, base) + check_running_app(client, base) + if "downloading the" in _read(log): + raise SystemExit("the app downloaded a Cache Pack instead of using its own") + stop(client, base, app) + left = [p.name for p in temp.iterdir() if p.name.startswith(TEMP_FOLDER_PREFIX)] + if left: + raise SystemExit(f"Stop left its temp folder behind: {left}") + except BaseException: + print(f"--- the app's log ---\n{_read(log)}") + raise + finally: + if app.poll() is None: + app.kill() + print("smoke check passed") + + +if __name__ == "__main__": + main(Path(sys.argv[1])) From 7df4f39293ac4b7b77d3dfba9524660e2e2c1a29 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 03:23:35 +0000 Subject: [PATCH 2/5] wrap long lines, drop the assert in zip_bundle --- scripts/sandbox_app_build.py | 4 ++-- scripts/sandbox_app_smoke.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/sandbox_app_build.py b/scripts/sandbox_app_build.py index 49527b83..51769f88 100644 --- a/scripts/sandbox_app_build.py +++ b/scripts/sandbox_app_build.py @@ -73,8 +73,8 @@ def zip_bundle(built: Path) -> None: ["ditto", "-c", "-k", "--keepParent", str(built), str(ZIP_PATH)], check=True ) else: - archive = shutil.make_archive(str(ZIP_PATH.with_suffix("")), "zip", built.parent, built.name) - assert Path(archive) == ZIP_PATH + base = str(ZIP_PATH.with_suffix("")) + shutil.make_archive(base, "zip", built.parent, built.name) def main() -> None: diff --git a/scripts/sandbox_app_smoke.py b/scripts/sandbox_app_smoke.py index dd218f0a..1d76111c 100644 --- a/scripts/sandbox_app_smoke.py +++ b/scripts/sandbox_app_smoke.py @@ -112,7 +112,8 @@ def main(zip_path: Path) -> None: stub = "python -c pass" if WINDOWS else "python3 -c pass" # The app's temp folder is ours, so "the folder is gone" is a plain look inside it; and # `BROWSER` is how Python's `webbrowser` is told to open something else than a browser. - env = {**os.environ, "BROWSER": stub, "TMPDIR": str(temp), "TEMP": str(temp), "TMP": str(temp)} + folders = {name: str(temp) for name in ("TMPDIR", "TEMP", "TMP")} + env = {**os.environ, "BROWSER": stub, **folders} app = subprocess.Popen([str(executable)], env=env, stdout=subprocess.DEVNULL) log = temp / LOG_NAME try: From 90cae831afe3f8536163f623cece4686421e5d3c Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 03:23:39 +0000 Subject: [PATCH 3/5] publish only from main; wider path filter; clearer Windows step --- .github/workflows/sandbox-app.yml | 5 ++++- scripts/sandbox-app-release-notes.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sandbox-app.yml b/.github/workflows/sandbox-app.yml index 26327ddb..f25b44e1 100644 --- a/.github/workflows/sandbox-app.yml +++ b/.github/workflows/sandbox-app.yml @@ -14,6 +14,9 @@ on: - node/src/storm_shield/cache_pack.py - node/src/storm_shield/sandbox_app.py - node/pyproject.toml + - node/uv.lock + - dashboard/** + - regions/** workflow_dispatch: inputs: version: @@ -68,7 +71,7 @@ jobs: publish: name: Publish the Release - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/scripts/sandbox-app-release-notes.md b/scripts/sandbox-app-release-notes.md index 58592f9f..10973f7d 100644 --- a/scripts/sandbox-app-release-notes.md +++ b/scripts/sandbox-app-release-notes.md @@ -5,7 +5,7 @@ Try Storm Shield on your own computer: nothing to install, and no terminal. Ever - **Mac (Apple Silicon: M1 or newer):** `Storm-Shield-Sandbox-macos-arm64.zip` - **Windows (64-bit):** `Storm-Shield-Sandbox-windows-x64.zip` -Unzip it, then open **Storm Shield Sandbox**. Your browser opens the dashboard. Press **Stop** in the Sandbox Panel when you are done; it also ends by itself ten minutes after you close the tab. It is reachable only from your own computer. +Unzip it, then open **Storm Shield Sandbox** (on Windows, the `Storm Shield Sandbox.exe` inside the unzipped folder). Your browser opens the dashboard. Press **Stop** in the Sandbox Panel when you are done; it also ends by itself ten minutes after you close the tab. It is reachable only from your own computer. ## The first time, your computer will warn you From d6348c1e957d8d0a6c8fab948189b8bbfd230796 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 03:28:03 +0000 Subject: [PATCH 4/5] bundle tzdata: Windows has no time zone database --- .github/workflows/sandbox-app.yml | 2 +- AGENTS.md | 2 +- scripts/sandbox_app_build.py | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sandbox-app.yml b/.github/workflows/sandbox-app.yml index f25b44e1..8ec7ecce 100644 --- a/.github/workflows/sandbox-app.yml +++ b/.github/workflows/sandbox-app.yml @@ -61,7 +61,7 @@ jobs: cache-dependency-path: dashboard/pnpm-lock.yaml - run: pnpm install --frozen-lockfile && pnpm build working-directory: dashboard - - run: uv run --project node --with "pyinstaller>=6,<7" python scripts/sandbox_app_build.py + - run: uv run --project node --with "pyinstaller>=6,<7" --with tzdata python scripts/sandbox_app_build.py - run: uv run --project node python scripts/sandbox_app_smoke.py dist-sandbox-app/Storm-Shield-Sandbox-${{ matrix.platform }}.zip - uses: actions/upload-artifact@v4 with: diff --git a/AGENTS.md b/AGENTS.md index 0315e103..5a074893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Run these before calling any issue done. An agent must not claim "done" without | Build dashboard | `cd dashboard && pnpm build` | | Run a Node | `cd node && uv run storm-shield-node` (serves the built dashboard; `NODE_ROLE=hub` for the Hub, default `sensor`; `NODE_REGION=` picks the Hub's Region, and its Cache Pack is downloaded from the Release `cache-pack--v` unless `CACHE_PACK_URL` says otherwise) | | Try the app with no setup | `scripts/sandbox.sh` from anywhere (a Hub and four registered simulated Sensors in one process, then the browser; `--port`, `--pack`; it uses the Region's pack from `node/build//` or `.demo/pack//`, else downloads it from the Release once, and says `scripts/build-cache-pack.sh ` when neither works; it builds the dashboard on the first run, so it needs `pnpm`) | -| Sandbox app (Mac/Windows downloads) | built by the "Sandbox app" workflow (`.github/workflows/sandbox-app.yml`), not by `scripts/check.sh`: a pull request touching the packaging builds and smoke-checks both; dispatch it with a number to publish the Release `sandbox-app-v`. Try the smoke check on your own machine: `uv run --project node --with "pyinstaller>=6,<7" python scripts/sandbox_app_build.py`, then `uv run --project node python scripts/sandbox_app_smoke.py dist-sandbox-app/` (Mac or Windows only) | +| Sandbox app (Mac/Windows downloads) | built by the "Sandbox app" workflow (`.github/workflows/sandbox-app.yml`), not by `scripts/check.sh`: a pull request touching the packaging builds and smoke-checks both; dispatch it with a number to publish the Release `sandbox-app-v`. Try the smoke check on your own machine: `uv run --project node --with "pyinstaller>=6,<7" --with tzdata python scripts/sandbox_app_build.py`, then `uv run --project node python scripts/sandbox_app_smoke.py dist-sandbox-app/` (Mac or Windows only) | | Keep the Pi's Hub on your checkout | `scripts/run-live.sh` (a Hub by default; rebuilds whenever `HEAD` moves, e.g. after `git pull`, and swaps the Node only when the build worked) | | Build a Region's Cache Pack | `scripts/build-cache-pack.sh nepal` (reads `regions//region.json`; needs the internet and `uv`; writes `node/build//cache-pack.zip`; notes in `scripts/cache-pack/README.md`) | | Dashboard dev server | `cd dashboard && pnpm dev` (proxies `/api` to port 8000) | diff --git a/scripts/sandbox_app_build.py b/scripts/sandbox_app_build.py index 51769f88..45c73adf 100644 --- a/scripts/sandbox_app_build.py +++ b/scripts/sandbox_app_build.py @@ -4,7 +4,7 @@ each built-in Region, so the app downloads nothing on its first run. PyInstaller cannot cross-build, so each platform runs this on its own machine (.github/workflows/sandbox-app.yml). - uv run --project node --with "pyinstaller>=6,<7" python scripts/sandbox_app_build.py + uv run --project node --with "pyinstaller>=6,<7" --with tzdata python scripts/sandbox_app_build.py """ import shutil @@ -58,6 +58,8 @@ def bundle(packs: Path) -> Path: "--collect-submodules", "storm_shield", "--collect-submodules", "uvicorn", "--collect-all", "RNS", + # Windows has no time zone database of its own; Regions name IANA zones. + "--collect-data", "tzdata", ] # fmt: skip for source, inside in data: command += ["--add-data", f"{source}{';' if sys.platform == 'win32' else ':'}{inside}"] From 43376295cae177668bdc3e588267b65958959d37 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 03:31:09 +0000 Subject: [PATCH 5/5] smoke check: do not fail on Windows holding a scratch file open --- scripts/sandbox_app_smoke.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sandbox_app_smoke.py b/scripts/sandbox_app_smoke.py index 1d76111c..212f71ba 100644 --- a/scripts/sandbox_app_smoke.py +++ b/scripts/sandbox_app_smoke.py @@ -100,7 +100,7 @@ def _read(path: Path) -> str: def main(zip_path: Path) -> None: - with tempfile.TemporaryDirectory() as work_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as work_dir: work = Path(work_dir) unpacked, temp = work / "unpacked", work / "temp" unpacked.mkdir()