build.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. """Build script for the Bambuddy Windows installer.
  2. Stages all artifacts under ``installers/windows/build/staging/`` for the
  3. Inno Setup compiler to package. Run this on Windows (or in a Windows CI
  4. runner) — it pip-installs Bambuddy's deps against the embedded Python it
  5. downloads, which requires the matching platform.
  6. Steps:
  7. 1. Download python.org embeddable distribution for Windows x64
  8. 2. Configure embedded Python (allow site-packages)
  9. 3. Bootstrap pip into the embedded distribution
  10. 4. Install ``requirements.txt`` into the embedded Python
  11. 5. Build the React frontend (``frontend/npm run build``)
  12. 6. Stage backend source + frontend bundle
  13. 7. Download NSSM
  14. 8. Download ffmpeg static build for Windows
  15. 9. Print "ready for ISCC" message
  16. After this script succeeds, run::
  17. "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss
  18. to produce the final installer .exe under ``build/output/``.
  19. """
  20. from __future__ import annotations
  21. import argparse
  22. import os
  23. import shutil
  24. import subprocess
  25. import sys
  26. import urllib.request
  27. import zipfile
  28. from pathlib import Path
  29. # Repo root: installers/windows/build.py -> ../../
  30. REPO_ROOT = Path(__file__).resolve().parents[2]
  31. INSTALLER_DIR = Path(__file__).resolve().parent
  32. BUILD_DIR = INSTALLER_DIR / "build"
  33. STAGING = BUILD_DIR / "staging"
  34. DOWNLOADS = BUILD_DIR / "downloads"
  35. # Python 3.13 — matches Dockerfile (python:3.13-slim-trixie). Bump when
  36. # the Dockerfile bumps; the Windows installer should track production.
  37. PYTHON_VERSION = "3.13.1"
  38. PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
  39. # NSSM 2.24 is the long-time stable build (no new release since 2014).
  40. # Vendored under installers/windows/vendor/nssm.exe rather than fetched
  41. # at build time — nssm.cc has flaked with 503s mid-CI-run before, and
  42. # pinning to a checked-in binary makes builds reproducible and lets us
  43. # inspect the binary in PRs if it ever needs updating. SHA-256:
  44. # f689ee9af94b00e9e3f0bb072b34caaf207f32dcb4f5782fc9ca351df9a06c97
  45. NSSM_VERSION = "2.24"
  46. # ffmpeg static build. BtbN's gyan-equivalent build is the most reliable
  47. # automated source. Pin to a release tag so builds are reproducible.
  48. FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
  49. # get-pip.py for bootstrapping pip into the embedded distribution
  50. GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
  51. # C++ runtime DLLs the embeddable distribution does NOT ship. The python.org
  52. # embeddable zip includes vcruntime140.dll but not vcruntime140_1.dll or
  53. # msvcp140.dll. python313.dll is pure C and only needs vcruntime140.dll, so
  54. # python.exe starts fine — but greenlet's _greenlet.pyd is C++ and needs
  55. # vcruntime140_1.dll (table-based exception handling). On a fresh Windows box
  56. # that never had the VC++ 2015-2022 redistributable installed, loading greenlet
  57. # fails with "DLL load failed ... The specified module could not be found",
  58. # SQLAlchemy's async engine can't start, init_db() raises, and the app never
  59. # binds its port — the service shows "running" but the dashboard refuses the
  60. # connection (issue #2474). These runtime DLLs are redistributable, so we ship
  61. # them app-locally next to python.exe (where vcruntime140.dll already lives).
  62. VCRUNTIME_DLLS = ("vcruntime140_1.dll", "msvcp140.dll")
  63. def log(msg: str) -> None:
  64. print(f"[build] {msg}", flush=True)
  65. def download(url: str, dest: Path) -> Path:
  66. """Download ``url`` to ``dest`` if not already present."""
  67. if dest.exists():
  68. log(f"already downloaded: {dest.name}")
  69. return dest
  70. dest.parent.mkdir(parents=True, exist_ok=True)
  71. log(f"downloading {url}")
  72. with urllib.request.urlopen(url) as resp, open(dest, "wb") as f: # noqa: S310 — pinned URLs
  73. shutil.copyfileobj(resp, f)
  74. return dest
  75. def unzip(zip_path: Path, dest: Path) -> None:
  76. log(f"unzipping {zip_path.name} -> {dest}")
  77. dest.mkdir(parents=True, exist_ok=True)
  78. with zipfile.ZipFile(zip_path) as zf:
  79. zf.extractall(dest)
  80. def stage_embedded_python() -> Path:
  81. """Download and configure the embedded Python distribution."""
  82. target = STAGING / "python"
  83. if target.exists():
  84. shutil.rmtree(target)
  85. zip_path = download(
  86. PYTHON_EMBED_URL,
  87. DOWNLOADS / f"python-{PYTHON_VERSION}-embed-amd64.zip",
  88. )
  89. unzip(zip_path, target)
  90. # Edit pythonXY._pth to allow site-packages. The embedded distribution
  91. # ships with `import site` commented out — uncomment it so pip-installed
  92. # packages in Lib\site-packages are importable.
  93. pth_files = list(target.glob("python3*._pth"))
  94. if not pth_files:
  95. raise RuntimeError(f"no python3*._pth file found in {target}")
  96. pth = pth_files[0]
  97. content = pth.read_text()
  98. content = content.replace("#import site", "import site")
  99. # Also add Lib\site-packages explicitly. The embedded distribution
  100. # doesn't include this path by default even with `import site` enabled.
  101. if "Lib\\site-packages" not in content and "Lib/site-packages" not in content:
  102. content = content.rstrip() + "\nLib\\site-packages\n"
  103. pth.write_text(content)
  104. # Bootstrap pip
  105. get_pip = download(GET_PIP_URL, DOWNLOADS / "get-pip.py")
  106. log("bootstrapping pip into embedded Python")
  107. subprocess.run(
  108. [str(target / "python.exe"), str(get_pip), "--no-warn-script-location"],
  109. check=True,
  110. )
  111. # Install setuptools + wheel. The embedded distribution ships without
  112. # them, and get-pip.py installs only pip — but pip needs
  113. # ``setuptools.build_meta`` (PEP 517 backend) to build any source-only
  114. # package. Bambuddy's requirements.txt hits this with pyftpdlib 2.2.0
  115. # which is sdist-only on PyPI; other source-only packages would fail
  116. # the same way without this step.
  117. log("installing setuptools + wheel for PEP 517 builds")
  118. subprocess.run(
  119. [
  120. str(target / "python.exe"),
  121. "-m",
  122. "pip",
  123. "install",
  124. "--no-warn-script-location",
  125. "setuptools",
  126. "wheel",
  127. ],
  128. check=True,
  129. )
  130. return target
  131. def stage_vcruntime(python_dir: Path) -> None:
  132. """Ship the C++ runtime DLLs the embeddable distribution omits.
  133. Placed next to python.exe so the extension-module loader (which searches the
  134. interpreter's own directory) finds them without a redistributable install on
  135. the target machine. Prefers vendored copies under installers/windows/vendor/
  136. for reproducibility; falls back to the build runner's System32, where the
  137. redistributable runtime lives. Fails loudly if neither source has them, so a
  138. misconfigured build machine is caught here instead of by end users.
  139. """
  140. vendor = INSTALLER_DIR / "vendor"
  141. system32 = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32"
  142. for dll in VCRUNTIME_DLLS:
  143. dst = python_dir / dll
  144. if dst.exists():
  145. log(f"{dll} already present in embedded Python")
  146. continue
  147. src = vendor / dll if (vendor / dll).exists() else system32 / dll
  148. if not src.exists():
  149. raise RuntimeError(
  150. f"required C++ runtime DLL not found: looked in {vendor} and {system32} "
  151. f"for {dll}. Install the Microsoft Visual C++ 2015-2022 Redistributable "
  152. f"(x64) on the build machine, or vendor {dll} under installers/windows/vendor/."
  153. )
  154. log(f"staging {dll} from {src}")
  155. shutil.copy(src, dst)
  156. def install_requirements(python_dir: Path) -> None:
  157. """Install Bambuddy's requirements.txt into the embedded Python."""
  158. py = python_dir / "python.exe"
  159. requirements = REPO_ROOT / "requirements.txt"
  160. log(f"installing requirements.txt into {python_dir}")
  161. subprocess.run(
  162. [
  163. str(py),
  164. "-m",
  165. "pip",
  166. "install",
  167. "--no-warn-script-location",
  168. "-r",
  169. str(requirements),
  170. ],
  171. check=True,
  172. )
  173. def build_frontend() -> Path:
  174. """Run ``npm ci && npm run build`` and return the build output path.
  175. Vite is configured with ``outDir: '../static'`` (see
  176. ``frontend/vite.config.ts``), so the bundle lands at ``<repo>/static/``
  177. — NOT ``frontend/dist/``. The path matches the runtime expectation in
  178. ``backend/app/core/config.py`` (``static_dir = _app_dir / "static"``).
  179. """
  180. frontend = REPO_ROOT / "frontend"
  181. dist = REPO_ROOT / "static"
  182. log("running npm ci in frontend/")
  183. npm = shutil.which("npm")
  184. if not npm:
  185. raise RuntimeError("npm not found on PATH — install Node.js 22 LTS")
  186. subprocess.run([npm, "ci"], cwd=frontend, check=True, shell=False)
  187. log("running npm run build in frontend/")
  188. subprocess.run([npm, "run", "build"], cwd=frontend, check=True, shell=False)
  189. if not dist.exists():
  190. raise RuntimeError(f"expected frontend build output at {dist}")
  191. return dist
  192. def stage_backend(frontend_dist: Path) -> None:
  193. """Copy backend source + frontend bundle into the staging tree.
  194. The runtime layout under STAGING/app/ mirrors a Bambuddy checkout:
  195. ``backend/`` (source), ``static/`` (frontend bundle served by FastAPI).
  196. """
  197. app = STAGING / "app"
  198. if app.exists():
  199. shutil.rmtree(app)
  200. app.mkdir(parents=True)
  201. # Backend source — copy the package tree, skip caches/tests/migrations
  202. log("staging backend source")
  203. shutil.copytree(
  204. REPO_ROOT / "backend",
  205. app / "backend",
  206. ignore=shutil.ignore_patterns(
  207. "__pycache__",
  208. "*.pyc",
  209. "tests",
  210. ".pytest_cache",
  211. ),
  212. )
  213. # Frontend bundle — FastAPI's StaticFiles mounts from app/static.
  214. # Strip macOS metadata files (.DS_Store, ._.*) that the dev box leaks
  215. # in; they'd just bloat the installer and never be served anyway.
  216. log("staging frontend bundle")
  217. shutil.copytree(
  218. frontend_dist,
  219. app / "static",
  220. ignore=shutil.ignore_patterns(".DS_Store", "._*"),
  221. )
  222. # gcode_viewer/ is a vendored 3D-preview iframe served via explicit
  223. # routes in main.py (looked up via static_dir.parent / "gcode_viewer").
  224. # In the staged layout STAGING/app/static/'s sibling is STAGING/app/,
  225. # so place the directory next to static/ to match runtime resolution.
  226. gcode_viewer_src = REPO_ROOT / "gcode_viewer"
  227. if gcode_viewer_src.exists():
  228. log("staging gcode_viewer/")
  229. shutil.copytree(
  230. gcode_viewer_src,
  231. app / "gcode_viewer",
  232. ignore=shutil.ignore_patterns(".DS_Store", "._*"),
  233. )
  234. def stage_nssm() -> None:
  235. target = STAGING / "bin"
  236. target.mkdir(parents=True, exist_ok=True)
  237. # Vendored binary — no network fetch at build time
  238. src = INSTALLER_DIR / "vendor" / "nssm.exe"
  239. if not src.exists():
  240. raise RuntimeError(f"vendored NSSM binary missing at {src} — was it committed?")
  241. log(f"staging nssm.exe from {src}")
  242. shutil.copy(src, target / "nssm.exe")
  243. def stage_ffmpeg() -> None:
  244. target = STAGING / "bin"
  245. target.mkdir(parents=True, exist_ok=True)
  246. zip_path = download(FFMPEG_URL, DOWNLOADS / "ffmpeg-win64-gpl.zip")
  247. extract = DOWNLOADS / "ffmpeg-extracted"
  248. if not extract.exists():
  249. unzip(zip_path, extract)
  250. src = next(extract.rglob("bin/ffmpeg.exe"))
  251. log(f"staging ffmpeg.exe from {src}")
  252. shutil.copy(src, target / "ffmpeg.exe")
  253. # ffprobe is used by some camera/timelapse paths
  254. ffprobe = next(extract.rglob("bin/ffprobe.exe"), None)
  255. if ffprobe is not None:
  256. shutil.copy(ffprobe, target / "ffprobe.exe")
  257. def stage_service_scripts() -> None:
  258. """Copy the service install/uninstall .bat files into staging."""
  259. service_src = INSTALLER_DIR / "service"
  260. service_dst = STAGING / "service"
  261. if service_dst.exists():
  262. shutil.rmtree(service_dst)
  263. shutil.copytree(service_src, service_dst)
  264. def _read_app_version() -> str:
  265. """Read APP_VERSION from backend/app/core/config.py (the canonical
  266. source used by every other Bambuddy surface — FastAPI OpenAPI title,
  267. /system info, support bundles, spoolbuddy update check).
  268. """
  269. config_py = REPO_ROOT / "backend" / "app" / "core" / "config.py"
  270. if not config_py.exists():
  271. return "0.0.0+dev"
  272. for raw in config_py.read_text().splitlines():
  273. stripped = raw.strip()
  274. if stripped.startswith("APP_VERSION"):
  275. # APP_VERSION = "0.2.5b1" -> 0.2.5b1
  276. return stripped.split("=", 1)[1].strip().strip('"').strip("'")
  277. return "0.0.0+dev"
  278. def _resolve_installer_version() -> str:
  279. """Decide what version string the installer carries.
  280. Priority:
  281. 1. ``GITHUB_REF`` env var when set to a tag (e.g.
  282. ``refs/tags/v0.2.5b1-daily.20260610``) — the daily-beta and stable
  283. publish scripts both push tags in the ``v<APP_VERSION>[-daily.<date>]``
  284. shape, and we want the installer filename + Inno Setup AppVersion
  285. to match the GitHub release exactly so dailies stay distinguishable
  286. from each other and from the eventual stable.
  287. 2. ``APP_VERSION`` from config.py for manual workflow_dispatch runs
  288. (no tag) and for local builds.
  289. Strips the leading ``v`` from tags so the installer filename is
  290. ``bambuddy-0.2.5b1-daily.20260610-windows-x64-setup.exe``, not
  291. ``bambuddy-v0.2.5b1-...``.
  292. """
  293. ref = os.environ.get("GITHUB_REF", "")
  294. if ref.startswith("refs/tags/"):
  295. tag = ref.removeprefix("refs/tags/")
  296. if tag.startswith("v"):
  297. tag = tag[1:]
  298. return tag or _read_app_version()
  299. return _read_app_version()
  300. def write_version_file() -> None:
  301. """Write the installer version as both a plain VERSION file and an
  302. Inno Setup include file so the .iss script can pick it up at compile
  303. time without a fragile file-read hack.
  304. """
  305. version = _resolve_installer_version()
  306. (STAGING / "VERSION").write_text(version)
  307. # Inno Setup include — bambuddy.iss does `#include "build\staging\version.iss"`
  308. iss_version = STAGING / "version.iss"
  309. iss_version.write_text(f'#define MyAppVersion "{version}"\n')
  310. log(f"staged VERSION = {version}")
  311. def main() -> int:
  312. parser = argparse.ArgumentParser(description=__doc__)
  313. parser.add_argument(
  314. "--skip-frontend",
  315. action="store_true",
  316. help="Skip frontend build (use existing frontend/dist/)",
  317. )
  318. parser.add_argument(
  319. "--skip-pip",
  320. action="store_true",
  321. help="Skip pip install (use existing staged Python)",
  322. )
  323. parser.add_argument(
  324. "--allow-non-windows",
  325. action="store_true",
  326. help=(
  327. "Override the Windows-only guard. Only useful if you have a "
  328. "working wine + windows-python toolchain. Not exercised by CI."
  329. ),
  330. )
  331. args = parser.parse_args()
  332. if sys.platform != "win32" and not args.allow_non_windows:
  333. log("ERROR: this build script must run on Windows.")
  334. log("")
  335. log("It downloads a Windows embeddable Python distribution and")
  336. log("pip-installs Bambuddy's requirements.txt against it — both")
  337. log("require executing python.exe, which only runs on Windows.")
  338. log("")
  339. log("Supported build paths:")
  340. log(" 1. GitHub Actions: trigger '.github/workflows/windows-")
  341. log(" installer.yml' (Actions tab -> Windows Installer ->")
  342. log(" Run workflow). Downloads the .exe as a workflow artifact.")
  343. log(" 2. Windows VM / box: clone, install Python 3.13 + Node 22 +")
  344. log(" Inno Setup 6, run this script.")
  345. log("")
  346. log("Unsupported escape hatch (cross-build under Wine): rerun with")
  347. log("--allow-non-windows. Requires wine + a Windows Python in $PATH")
  348. log("via wine python.exe — fragile and not exercised by CI.")
  349. return 1
  350. BUILD_DIR.mkdir(parents=True, exist_ok=True)
  351. DOWNLOADS.mkdir(parents=True, exist_ok=True)
  352. STAGING.mkdir(parents=True, exist_ok=True)
  353. python_dir = stage_embedded_python()
  354. stage_vcruntime(python_dir)
  355. if not args.skip_pip:
  356. install_requirements(python_dir)
  357. if args.skip_frontend:
  358. frontend_dist = REPO_ROOT / "frontend" / "dist"
  359. if not frontend_dist.exists():
  360. raise RuntimeError("--skip-frontend given but frontend/dist/ doesn't exist")
  361. else:
  362. frontend_dist = build_frontend()
  363. stage_backend(frontend_dist)
  364. stage_nssm()
  365. stage_ffmpeg()
  366. stage_service_scripts()
  367. write_version_file()
  368. log("")
  369. log("=" * 60)
  370. log("Staging complete.")
  371. log(f"Staged tree: {STAGING}")
  372. log("")
  373. log("Next: compile the Inno Setup script:")
  374. log(' "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss')
  375. log("")
  376. log(f"Installer will be written to: {BUILD_DIR / 'output'}")
  377. log("=" * 60)
  378. return 0
  379. if __name__ == "__main__":
  380. sys.exit(main())