Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/sandbox-app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# 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<N>`. 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
- node/uv.lock
- dashboard/**
- regions/**
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" --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:
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' && github.ref == 'refs/heads/main'
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"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ node/data/
# local demo state (data folders, logs, downloaded pack)
/.demo/
/.join-demo/

# Sandbox app build output
/dist-sandbox-app/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<folder under regions/>` picks the Hub's Region, and its Cache Pack is downloaded from the Release `cache-pack-<region>-v<N>` 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/<region>/` or `.demo/pack/<region>/`, else downloads it from the Release once, and says `scripts/build-cache-pack.sh <region>` 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<N>`. 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/<zip>` (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/<name>/region.json`; needs the internet and `uv`; writes `node/build/<name>/cache-pack.zip`; notes in `scripts/cache-pack/README.md`) |
| Dashboard dev server | `cd dashboard && pnpm dev` (proxies `/api` to port 8000) |
Expand Down
2 changes: 1 addition & 1 deletion node/src/storm_shield/cache_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<region>-v<N>`
# (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):
Expand Down
35 changes: 35 additions & 0 deletions node/tests/test_sandbox_app_smoke.py
Original file line number Diff line number Diff line change
@@ -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"
]
1 change: 1 addition & 0 deletions scripts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 18 additions & 0 deletions scripts/sandbox-app-release-notes.md
Original file line number Diff line number Diff line change
@@ -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** (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

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.
92 changes: 92 additions & 0 deletions scripts/sandbox_app_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""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" --with tzdata 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",
# 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}"]
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:
base = str(ZIP_PATH.with_suffix(""))
shutil.make_archive(base, "zip", built.parent, built.name)


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()
5 changes: 5 additions & 0 deletions scripts/sandbox_app_entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""What PyInstaller starts: the Sandbox app, with no arguments."""

from storm_shield.sandbox_app import main

main()
Loading
Loading