build: add single-command release script + portable zip artifacts
One-developer workflow: ``python build/make_release.py`` on each target OS produces both the installer and a portable .zip for that platform. Preflight checks PyInstaller / Pillow / iscc / hdiutil / ditto / appimagetool and bails with install hints if anything is missing — no half-built dist/. New scripts: - build/make_release.py — orchestrator, auto-detects host OS. - build/generate_icons.py — icon.ico / icon.icns / icon.png from src/gui/assets/datatools_icon_256.png (Pillow ships ICO + ICNS writers; no platform tooling needed). - build/build_portable_zip.py — Win/Linux portable zip via stdlib. - build/macos/build_zip.sh — Mac portable .app via ditto so bundle metadata survives. installer.iss now adds: Quick Launch task (opt-in, legacy Win 7), App Paths registry entry (Win+R "DataTools" works), SetupIconFile, UninstallDisplayIcon, AppSupportURL, AppUpdatesURL. CI workflow uploads installer + portable per platform and attaches both to GitHub Releases on tag push. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
348
build/make_release.py
Normal file
348
build/make_release.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""Single-command release builder for DataTools.
|
||||
|
||||
PyInstaller can't cross-compile — to produce a Windows .exe you run
|
||||
this on Windows, for a Mac .dmg you run it on macOS, for a Linux
|
||||
AppImage you run it on Linux. One script, one OS at a time.
|
||||
|
||||
What this script does (in order):
|
||||
1. Preflight — checks PyInstaller, Pillow, and the platform's
|
||||
packager (Inno Setup on Win / hdiutil + ditto on Mac /
|
||||
appimagetool on Linux) are reachable. Bails with install
|
||||
instructions if anything is missing.
|
||||
2. Generates icon.ico / icon.icns / icon.png from the PNG asset.
|
||||
3. Runs PyInstaller against build/datatools.spec.
|
||||
4. Wraps the PyInstaller output into:
|
||||
* Windows: DataTools-<ver>-win-setup.exe (Inno Setup)
|
||||
+ DataTools-<ver>-win-portable.zip
|
||||
* macOS: DataTools-<ver>-mac.dmg
|
||||
+ DataTools-<ver>-mac-portable.zip
|
||||
* Linux: DataTools-<ver>-linux-x86_64.AppImage
|
||||
5. Prints what landed in dist/ and the byte sizes.
|
||||
|
||||
Usage:
|
||||
python build/make_release.py # build everything for this OS
|
||||
python build/make_release.py --preflight # check tooling, don't build
|
||||
python build/make_release.py --skip-installer # only the portable zip
|
||||
python build/make_release.py --skip-portable # only the installer
|
||||
python build/make_release.py --clean # wipe dist/ first
|
||||
|
||||
Run from the repo root or from build/ — either works.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
BUILD = REPO / "build"
|
||||
DIST = REPO / "dist"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output helpers — colourless so logs stay readable in any terminal/CI tail.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _step(msg: str) -> None:
|
||||
print(f"\n==> {msg}", flush=True)
|
||||
|
||||
|
||||
def _ok(msg: str) -> None:
|
||||
print(f" ok: {msg}", flush=True)
|
||||
|
||||
|
||||
def _warn(msg: str) -> None:
|
||||
print(f" warn: {msg}", flush=True)
|
||||
|
||||
|
||||
def _err(msg: str) -> None:
|
||||
print(f" ERROR: {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path | None = None, env: dict | None = None) -> None:
|
||||
"""Run *cmd*, stream output, exit on failure with a useful banner."""
|
||||
printable = " ".join(map(str, cmd))
|
||||
print(f" $ {printable}", flush=True)
|
||||
try:
|
||||
subprocess.run(cmd, check=True, cwd=cwd or REPO, env=env)
|
||||
except subprocess.CalledProcessError as e:
|
||||
_err(f"command failed (exit {e.returncode}): {printable}")
|
||||
sys.exit(e.returncode)
|
||||
except FileNotFoundError:
|
||||
_err(f"command not found: {cmd[0]}")
|
||||
sys.exit(127)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_platform() -> str:
|
||||
"""Return ``win`` / ``mac`` / ``linux`` based on sys.platform."""
|
||||
p = sys.platform
|
||||
if p.startswith("win"):
|
||||
return "win"
|
||||
if p == "darwin":
|
||||
return "mac"
|
||||
if p.startswith("linux"):
|
||||
return "linux"
|
||||
_err(f"unsupported platform {p!r}; this script handles win/mac/linux only.")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version — single source of truth in src/__init__.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
init_py = (REPO / "src" / "__init__.py").read_text(encoding="utf-8")
|
||||
m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', init_py)
|
||||
if not m:
|
||||
_err("could not parse __version__ from src/__init__.py")
|
||||
sys.exit(1)
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preflight — check tooling before doing anything destructive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _have_module(name: str) -> bool:
|
||||
try:
|
||||
__import__(name)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _have_command(name: str) -> bool:
|
||||
return shutil.which(name) is not None
|
||||
|
||||
|
||||
# Per-platform install hints. The error messages quote these so a buyer
|
||||
# building from source isn't left guessing what to install next.
|
||||
_INSTALL_HINTS = {
|
||||
"pyinstaller": "pip install pyinstaller",
|
||||
"pil": "pip install pillow",
|
||||
"iscc": "Inno Setup (Windows): https://jrsoftware.org/isdl.php — install, then re-open the shell so iscc lands on PATH.",
|
||||
"hdiutil": "ships with macOS — if it's missing your Mac install is broken.",
|
||||
"ditto": "ships with macOS — if it's missing your Mac install is broken.",
|
||||
"appimagetool": "Linux: download appimagetool-x86_64.AppImage from https://github.com/AppImage/AppImageKit/releases, chmod +x, drop on PATH.",
|
||||
}
|
||||
|
||||
|
||||
def preflight(target: str) -> None:
|
||||
"""Verify every tool the target build needs is reachable; exit if not."""
|
||||
_step(f"preflight ({target})")
|
||||
|
||||
missing: list[tuple[str, str]] = []
|
||||
|
||||
# Python-side deps — same on every platform. The ``_INSTALL_HINTS``
|
||||
# lookup uses lowercase keys so module name capitalization doesn't
|
||||
# need to match.
|
||||
for mod in ("PyInstaller", "PIL"):
|
||||
if not _have_module(mod):
|
||||
hint = _INSTALL_HINTS.get(mod.lower(), f"pip install {mod}")
|
||||
missing.append((mod.lower(), hint))
|
||||
else:
|
||||
_ok(f"{mod} importable")
|
||||
|
||||
# PyInstaller's CLI must also be reachable as a binary, not just as
|
||||
# an importable module — the spec is invoked via the ``pyinstaller``
|
||||
# command. ``python -m PyInstaller`` is a fine fallback so don't
|
||||
# hard-fail if only the CLI binary is missing.
|
||||
if _have_command("pyinstaller"):
|
||||
_ok("pyinstaller on PATH")
|
||||
else:
|
||||
_warn("pyinstaller binary not on PATH — will fall back to `python -m PyInstaller`")
|
||||
|
||||
# Platform-specific packagers.
|
||||
if target == "win":
|
||||
if _have_command("iscc"):
|
||||
_ok("Inno Setup (iscc) on PATH")
|
||||
else:
|
||||
missing.append(("iscc", _INSTALL_HINTS["iscc"]))
|
||||
elif target == "mac":
|
||||
for tool in ("hdiutil", "ditto"):
|
||||
if _have_command(tool):
|
||||
_ok(f"{tool} on PATH")
|
||||
else:
|
||||
missing.append((tool, _INSTALL_HINTS[tool]))
|
||||
elif target == "linux":
|
||||
if _have_command("appimagetool"):
|
||||
_ok("appimagetool on PATH")
|
||||
else:
|
||||
missing.append(("appimagetool", _INSTALL_HINTS["appimagetool"]))
|
||||
|
||||
if missing:
|
||||
_err("missing prerequisites:")
|
||||
for name, hint in missing:
|
||||
print(f" - {name}: {hint}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
_ok("all prerequisites present")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def step_generate_icons() -> None:
|
||||
_step("generate icons")
|
||||
_run([sys.executable, str(BUILD / "generate_icons.py")])
|
||||
|
||||
|
||||
def step_pyinstaller(clean: bool) -> None:
|
||||
_step("pyinstaller bundle")
|
||||
# Use ``python -m PyInstaller`` so we don't depend on the binary
|
||||
# being on PATH (Windows users frequently see this — pip's
|
||||
# Scripts/ dir isn't auto-added).
|
||||
cmd = [sys.executable, "-m", "PyInstaller",
|
||||
str(BUILD / "datatools.spec"),
|
||||
"--noconfirm"]
|
||||
if clean:
|
||||
cmd.append("--clean")
|
||||
_run(cmd)
|
||||
|
||||
|
||||
def step_package_win(version: str, do_installer: bool, do_portable: bool) -> list[Path]:
|
||||
out: list[Path] = []
|
||||
if do_installer:
|
||||
_step("Windows installer (Inno Setup)")
|
||||
_run(["iscc", f"/DAppVersion={version}", str(BUILD / "installer.iss")])
|
||||
out.append(DIST / f"DataTools-{version}-win-setup.exe")
|
||||
if do_portable:
|
||||
_step("Windows portable .zip")
|
||||
_run([sys.executable, str(BUILD / "build_portable_zip.py"), "win", version])
|
||||
out.append(DIST / f"DataTools-{version}-win-portable.zip")
|
||||
return out
|
||||
|
||||
|
||||
def step_package_mac(version: str, do_installer: bool, do_portable: bool) -> list[Path]:
|
||||
out: list[Path] = []
|
||||
if do_installer:
|
||||
_step("macOS DMG (installer)")
|
||||
_run(["bash", str(BUILD / "macos" / "build_dmg.sh"), version])
|
||||
out.append(DIST / f"DataTools-{version}-mac.dmg")
|
||||
if do_portable:
|
||||
_step("macOS portable .zip")
|
||||
_run(["bash", str(BUILD / "macos" / "build_zip.sh"), version])
|
||||
out.append(DIST / f"DataTools-{version}-mac-portable.zip")
|
||||
return out
|
||||
|
||||
|
||||
def step_package_linux(version: str, do_installer: bool, do_portable: bool) -> list[Path]:
|
||||
# On Linux the AppImage IS the portable. We ignore the two flags
|
||||
# and always produce the single file — splitting wouldn't add
|
||||
# value.
|
||||
if not (do_installer or do_portable):
|
||||
return []
|
||||
_step("Linux AppImage")
|
||||
_run(["bash", str(BUILD / "appimage" / "build.sh"), version])
|
||||
return [DIST / f"DataTools-{version}-linux-x86_64.AppImage"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _summarise(outputs: list[Path]) -> None:
|
||||
_step("done — outputs")
|
||||
if not outputs:
|
||||
_warn("no files produced (everything skipped via flags)")
|
||||
return
|
||||
for p in outputs:
|
||||
if p.exists():
|
||||
size_mb = p.stat().st_size / (1024 * 1024)
|
||||
print(f" {p.relative_to(REPO)} ({size_mb:.1f} MB)")
|
||||
else:
|
||||
_warn(f"expected output missing: {p.relative_to(REPO)}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="make_release.py",
|
||||
description=(
|
||||
"Build the installer + portable zip for the current OS. "
|
||||
"Cross-compilation isn't supported by PyInstaller — run "
|
||||
"this once per platform you want to target."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--platform", choices=("auto", "win", "mac", "linux"), default="auto",
|
||||
help="Override OS detection (mostly for testing). Default: auto.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preflight", action="store_true",
|
||||
help="Check tooling and exit without building.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean", action="store_true",
|
||||
help="Wipe dist/ before building.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-installer", action="store_true",
|
||||
help="Don't build the OS installer (.exe / .dmg).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-portable", action="store_true",
|
||||
help="Don't build the portable .zip.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
target = _detect_platform() if args.platform == "auto" else args.platform
|
||||
version = _read_version()
|
||||
do_installer = not args.skip_installer
|
||||
do_portable = not args.skip_portable
|
||||
|
||||
print(f"DataTools release builder")
|
||||
print(f" target: {target} (host: {platform.platform()})")
|
||||
print(f" version: {version}")
|
||||
print(f" installer: {'yes' if do_installer else 'no'}")
|
||||
print(f" portable: {'yes' if do_portable else 'no'}")
|
||||
print(f" dist dir: {DIST}")
|
||||
|
||||
if target != _detect_platform():
|
||||
_warn(
|
||||
f"--platform {target} but host is {_detect_platform()}. "
|
||||
"PyInstaller can't cross-compile — the bundle will be for "
|
||||
"the HOST, only the packaging step will follow your override. "
|
||||
"Useful only for testing the packager paths."
|
||||
)
|
||||
|
||||
preflight(target)
|
||||
if args.preflight:
|
||||
return 0
|
||||
|
||||
if args.clean and DIST.exists():
|
||||
_step(f"cleaning {DIST}")
|
||||
shutil.rmtree(DIST)
|
||||
|
||||
step_generate_icons()
|
||||
step_pyinstaller(clean=args.clean)
|
||||
|
||||
if target == "win":
|
||||
outputs = step_package_win(version, do_installer, do_portable)
|
||||
elif target == "mac":
|
||||
outputs = step_package_mac(version, do_installer, do_portable)
|
||||
else:
|
||||
outputs = step_package_linux(version, do_installer, do_portable)
|
||||
|
||||
_summarise(outputs)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user