Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled
- Added support for multiple brands in release scripts, allowing for branded artifacts. - Introduced brand configuration management with JSON files for each brand. - Created a new `brand_config.py` script to handle brand-specific logic and asset resolution. - Updated `create_release.ps1` and `create_release.sh` scripts to utilize brand configurations and generate release manifests. - Added unit tests for brand configuration loading and release manifest generation. - Introduced `agravity` brand with its specific configuration in `agravity.json`.
619 lines
22 KiB
Python
619 lines
22 KiB
Python
"""Build Windows installer (MSI) using PyInstaller.
|
|
|
|
This script builds the WebDrop Bridge application for Windows using PyInstaller.
|
|
It creates both a standalone executable and optionally an MSI installer.
|
|
|
|
Requirements:
|
|
- PyInstaller 6.0+
|
|
- Python 3.10+
|
|
- For MSI: WiX Toolset (optional, requires separate installation)
|
|
|
|
Usage:
|
|
python build_windows.py [--msi] [--code-sign] [--env-file PATH]
|
|
|
|
Options:
|
|
--msi Create MSI installer (requires WiX Toolset)
|
|
--code-sign Sign executable (requires certificate)
|
|
--env-file PATH Use custom .env file (default: project root .env)
|
|
If not provided, uses .env from project root
|
|
Build fails if .env doesn't exist
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
from typing import Optional
|
|
|
|
# Fix Unicode output on Windows BEFORE any other imports
|
|
if sys.platform == "win32":
|
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
|
import io
|
|
|
|
# Reconfigure stdout/stderr for UTF-8 output
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
|
|
|
import subprocess
|
|
import shutil
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
# Import shared version utilities
|
|
from brand_config import load_brand_config
|
|
from sync_version import get_current_version, do_sync_version
|
|
|
|
|
|
class WindowsBuilder:
|
|
"""Build Windows installer using PyInstaller."""
|
|
|
|
def __init__(self, env_file: Path | None = None, brand: str | None = None):
|
|
"""Initialize builder paths.
|
|
|
|
Args:
|
|
env_file: Path to .env file to bundle. If None, uses project root .env.
|
|
If that doesn't exist, raises error.
|
|
"""
|
|
self.project_root = Path(__file__).parent.parent.parent
|
|
self.brand = load_brand_config(brand, root=self.project_root)
|
|
self.build_dir = self.project_root / "build"
|
|
self.dist_dir = self.build_dir / "dist" / "windows" / self.brand.brand_id
|
|
self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id
|
|
self.spec_file = self.build_dir / "webdrop_bridge.spec"
|
|
self.wix_template = self.build_dir / "WebDropBridge.wxs"
|
|
self.version = get_current_version()
|
|
|
|
# Validate and set env file
|
|
if env_file is None:
|
|
env_file = self.project_root / ".env"
|
|
else:
|
|
env_file = Path(env_file).resolve()
|
|
|
|
if not env_file.exists():
|
|
raise FileNotFoundError(
|
|
f"Configuration file not found: {env_file}\n"
|
|
f"Please provide a .env file using --env-file parameter\n"
|
|
f"or ensure .env exists in project root"
|
|
)
|
|
|
|
self.env_file = env_file
|
|
print(f"📋 Using configuration: {self.env_file}")
|
|
print(f"🏷️ Building brand: {self.brand.display_name} ({self.brand.brand_id})")
|
|
|
|
def _get_version(self) -> str:
|
|
"""Get version from __init__.py.
|
|
|
|
Note: This method is deprecated. Use get_current_version() from
|
|
version_utils.py instead.
|
|
"""
|
|
return get_current_version()
|
|
|
|
def clean(self):
|
|
"""Clean previous builds."""
|
|
print("🧹 Cleaning previous builds...")
|
|
for path in [self.dist_dir, self.temp_dir]:
|
|
if path.exists():
|
|
shutil.rmtree(path)
|
|
print(f" Removed {path}")
|
|
|
|
def build_executable(self) -> bool:
|
|
"""Build executable using PyInstaller."""
|
|
print("\n🔨 Building Windows executable with PyInstaller...")
|
|
|
|
self.dist_dir.mkdir(parents=True, exist_ok=True)
|
|
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# PyInstaller command using spec file
|
|
# Pass env_file path as environment variable for spec to pick up
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"PyInstaller",
|
|
"--distpath",
|
|
str(self.dist_dir),
|
|
"--workpath",
|
|
str(self.temp_dir),
|
|
str(self.spec_file),
|
|
]
|
|
|
|
print(f" Command: {' '.join(cmd)}")
|
|
|
|
# Set environment variable for spec file to use
|
|
env = os.environ.copy()
|
|
env["WEBDROP_ENV_FILE"] = str(self.env_file)
|
|
env["WEBDROP_BRAND_ID"] = self.brand.brand_id
|
|
env["WEBDROP_APP_DISPLAY_NAME"] = self.brand.display_name
|
|
env["WEBDROP_ASSET_PREFIX"] = self.brand.asset_prefix
|
|
env["WEBDROP_EXE_NAME"] = self.brand.exe_name
|
|
env["WEBDROP_BUNDLE_ID"] = self.brand.bundle_identifier
|
|
env["WEBDROP_CONFIG_DIR_NAME"] = self.brand.config_dir_name
|
|
env["WEBDROP_ICON_ICO"] = str(self.brand.icon_ico)
|
|
env["WEBDROP_ICON_ICNS"] = str(self.brand.icon_icns)
|
|
env["WEBDROP_VERSION"] = self.version
|
|
|
|
result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
|
|
|
|
if result.returncode != 0:
|
|
print("❌ PyInstaller build failed")
|
|
return False
|
|
|
|
# Check if executable exists (inside the COLLECT directory)
|
|
exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
|
|
if not exe_path.exists():
|
|
print(f"❌ Executable not found at {exe_path}")
|
|
return False
|
|
|
|
print("✅ Executable built successfully")
|
|
print(f"📦 Output: {exe_path}")
|
|
|
|
# Calculate total dist size
|
|
total_size = sum(
|
|
f.stat().st_size
|
|
for f in self.dist_dir.glob(f"{self.brand.exe_name}/**/*")
|
|
if f.is_file()
|
|
)
|
|
if total_size > 0:
|
|
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
|
|
|
|
# Set executable version information (required for MSI updates)
|
|
self.set_exe_version(exe_path)
|
|
|
|
# Generate SHA256 checksum
|
|
self.generate_checksum(exe_path)
|
|
|
|
return True
|
|
|
|
def generate_checksum(self, file_path: Path) -> None:
|
|
"""Generate SHA256 checksum for file."""
|
|
import hashlib
|
|
|
|
print("\n📝 Generating SHA256 checksum...")
|
|
sha256_hash = hashlib.sha256()
|
|
|
|
with open(file_path, "rb") as f:
|
|
for byte_block in iter(lambda: f.read(4096), b""):
|
|
sha256_hash.update(byte_block)
|
|
|
|
checksum = sha256_hash.hexdigest()
|
|
checksum_file = file_path.parent / f"{file_path.name}.sha256"
|
|
|
|
checksum_file.write_text(checksum)
|
|
print(f"✅ Checksum generated")
|
|
print(f" File: {checksum_file}")
|
|
print(f" Hash: {checksum}")
|
|
|
|
def set_exe_version(self, exe_path: Path) -> None:
|
|
"""Set executable file version for Windows.
|
|
|
|
This is important for MSI updates: Windows Installer compares file versions
|
|
to determine if files should be updated during a major upgrade.
|
|
|
|
Args:
|
|
exe_path: Path to the executable file
|
|
"""
|
|
print("\n🏷️ Setting executable version information...")
|
|
|
|
try:
|
|
import pefile
|
|
except ImportError:
|
|
print("⚠️ pefile not installed - skipping EXE version update")
|
|
print(" Note: Install with: pip install pefile")
|
|
print(" EXE version info will be blank (MSI updates may not work correctly)")
|
|
return
|
|
|
|
try:
|
|
pe = pefile.PE(str(exe_path))
|
|
|
|
# Parse version into 4-part format (Major, Minor, Build, Revision)
|
|
version_parts = self.version.split(".")
|
|
while len(version_parts) < 4:
|
|
version_parts.append("0")
|
|
|
|
file_version = tuple(int(v) for v in version_parts[:4])
|
|
|
|
# Set version resource if it exists
|
|
if hasattr(pe, "VS_FIXEDFILEINFO"):
|
|
pe.VS_FIXEDFILEINFO[0].FileVersionMS = (file_version[0] << 16) | file_version[1]
|
|
pe.VS_FIXEDFILEINFO[0].FileVersionLS = (file_version[2] << 16) | file_version[3]
|
|
pe.VS_FIXEDFILEINFO[0].ProductVersionMS = (file_version[0] << 16) | file_version[1]
|
|
pe.VS_FIXEDFILEINFO[0].ProductVersionLS = (file_version[2] << 16) | file_version[3]
|
|
|
|
# Write modified PE back to file
|
|
pe.write(filename=str(exe_path))
|
|
print(f"✅ Version set to {self.version}")
|
|
else:
|
|
print("⚠️ No version resource found in EXE")
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Could not set EXE version: {e}")
|
|
print(" MSI updates may not work correctly without file version info")
|
|
|
|
def create_msi(self) -> bool:
|
|
"""Create MSI installer using WiX Toolset.
|
|
|
|
This requires WiX Toolset to be installed:
|
|
https://wixtoolset.org/releases/
|
|
"""
|
|
print("\n📦 Creating MSI installer with WiX...")
|
|
|
|
# Check if WiX is installed (try PATH first, then default location)
|
|
heat_exe = shutil.which("heat.exe")
|
|
candle_exe = shutil.which("candle.exe")
|
|
light_exe = shutil.which("light.exe")
|
|
|
|
# Fallback to default WiX installation location
|
|
if not candle_exe:
|
|
default_wix = Path("C:\\Program Files (x86)\\WiX Toolset v3.14\\bin")
|
|
if default_wix.exists():
|
|
heat_exe = str(default_wix / "heat.exe")
|
|
candle_exe = str(default_wix / "candle.exe")
|
|
light_exe = str(default_wix / "light.exe")
|
|
|
|
if not all([heat_exe, candle_exe, light_exe]):
|
|
print("⚠️ WiX Toolset not found in PATH or default location")
|
|
print(" Install from: https://wixtoolset.org/releases/")
|
|
print(" Or use: choco install wixtoolset")
|
|
return False
|
|
|
|
# Create base WiX source file
|
|
if not self._create_wix_source():
|
|
return False
|
|
|
|
# Ensure toolbar icons are present in bundled resources before harvesting.
|
|
if not self._ensure_toolbar_icons_in_bundle():
|
|
return False
|
|
|
|
# Harvest application files using Heat
|
|
print(f" Harvesting application files...")
|
|
dist_folder = self.dist_dir / self.brand.exe_name
|
|
if not dist_folder.exists():
|
|
print(f"❌ Distribution folder not found: {dist_folder}")
|
|
return False
|
|
|
|
harvest_file = self.build_dir / "WebDropBridge_Files.wxs"
|
|
|
|
# Use Heat to harvest all files
|
|
heat_cmd = [
|
|
str(heat_exe),
|
|
"dir",
|
|
str(dist_folder),
|
|
"-cg",
|
|
"AppFiles",
|
|
"-dr",
|
|
"INSTALLFOLDER",
|
|
"-sfrag",
|
|
"-srd",
|
|
"-gg",
|
|
"-o",
|
|
str(harvest_file),
|
|
]
|
|
|
|
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if result.returncode != 0:
|
|
print("⚠️ Heat harvest warnings (may be non-critical)")
|
|
if result.stderr:
|
|
print(result.stderr[:200]) # Show first 200 chars of errors
|
|
else:
|
|
print(f" ✓ Harvested files")
|
|
|
|
# Post-process harvested file to mark components as 64-bit
|
|
if harvest_file.exists():
|
|
content = harvest_file.read_text()
|
|
# Add Win64="yes" to all Component tags
|
|
content = content.replace("<Component ", '<Component Win64="yes" ')
|
|
harvest_file.write_text(content)
|
|
print(f" ✓ Marked components as 64-bit")
|
|
|
|
# Compile both WiX files
|
|
wix_obj = self.build_dir / "WebDropBridge.wixobj"
|
|
wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj"
|
|
msi_output = self.dist_dir / self.brand.windows_installer_name(self.version)
|
|
|
|
# Run candle compiler - make sure to use correct source directory
|
|
candle_cmd = [
|
|
str(candle_exe),
|
|
"-ext",
|
|
"WixUIExtension",
|
|
"-ext",
|
|
"WixUtilExtension",
|
|
f"-dDistDir={self.dist_dir}",
|
|
f"-dSourceDir={self.dist_dir}\{self.brand.exe_name}", # Set SourceDir for Heat-generated files
|
|
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
|
|
"-o",
|
|
str(self.build_dir) + "\\",
|
|
str(self.build_dir / "WebDropBridge.generated.wxs"),
|
|
]
|
|
|
|
if harvest_file.exists():
|
|
candle_cmd.append(str(harvest_file))
|
|
|
|
print(f" Compiling WiX source...")
|
|
result = subprocess.run(candle_cmd, text=True, cwd=str(self.build_dir))
|
|
if result.returncode != 0:
|
|
print("❌ WiX compilation failed")
|
|
return False
|
|
|
|
# Link MSI - include both obj files if harvest was successful
|
|
light_cmd = [
|
|
str(light_exe),
|
|
"-ext",
|
|
"WixUIExtension",
|
|
"-ext",
|
|
"WixUtilExtension",
|
|
"-b",
|
|
str(self.dist_dir / self.brand.exe_name), # Base path for source files
|
|
"-o",
|
|
str(msi_output),
|
|
str(wix_obj),
|
|
]
|
|
|
|
if wix_files_obj.exists():
|
|
light_cmd.append(str(wix_files_obj))
|
|
|
|
print(f" Linking MSI installer...")
|
|
result = subprocess.run(
|
|
light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
)
|
|
if result.returncode != 0:
|
|
print("❌ MSI linking failed")
|
|
if result.stdout:
|
|
print(f" Output: {result.stdout[:500]}")
|
|
if result.stderr:
|
|
print(f" Error: {result.stderr[:500]}")
|
|
return False
|
|
|
|
if not msi_output.exists():
|
|
print(f"❌ MSI not found at {msi_output}")
|
|
return False
|
|
|
|
print("✅ MSI installer created successfully")
|
|
print(f"📦 Output: {msi_output}")
|
|
print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB")
|
|
self.generate_checksum(msi_output)
|
|
|
|
return True
|
|
|
|
def _ensure_toolbar_icons_in_bundle(self) -> bool:
|
|
"""Ensure toolbar icon files exist in the bundled app folder.
|
|
|
|
This guarantees WiX Heat harvest includes these icons in the MSI,
|
|
even if a previous PyInstaller run omitted them.
|
|
"""
|
|
src_icons_dir = self.project_root / "resources" / "icons"
|
|
bundle_icons_dir = self.dist_dir / self.brand.exe_name / "_internal" / "resources" / "icons"
|
|
required_icons = ["home.ico", "reload.ico", "open.ico", "openwith.ico"]
|
|
|
|
try:
|
|
bundle_icons_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
for icon_name in required_icons:
|
|
src = src_icons_dir / icon_name
|
|
dst = bundle_icons_dir / icon_name
|
|
|
|
if not src.exists():
|
|
print(f"❌ Required icon not found: {src}")
|
|
return False
|
|
|
|
if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime:
|
|
shutil.copy2(src, dst)
|
|
print(f" ✓ Ensured toolbar icon in bundle: {icon_name}")
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"❌ Failed to ensure toolbar icons in bundle: {e}")
|
|
return False
|
|
|
|
def _create_wix_source(self) -> bool:
|
|
"""Create WiX source file for MSI generation.
|
|
|
|
Creates per-machine installation (Program Files).
|
|
Installation requires admin rights, but the app does not.
|
|
"""
|
|
wix_template = self.wix_template.read_text(encoding="utf-8")
|
|
wix_content = wix_template.format(
|
|
product_name=self.brand.display_name,
|
|
version=self.version,
|
|
manufacturer=self.brand.manufacturer,
|
|
upgrade_code=self.brand.msi_upgrade_code,
|
|
asset_prefix=self.brand.asset_prefix,
|
|
icon_ico=str(self.brand.icon_ico),
|
|
dialog_bmp=str(self.brand.dialog_bmp),
|
|
banner_bmp=str(self.brand.banner_bmp),
|
|
license_rtf=str(self.brand.license_rtf),
|
|
exe_name=self.brand.exe_name,
|
|
install_dir_name=self.brand.install_dir_name,
|
|
shortcut_description=self.brand.shortcut_description,
|
|
)
|
|
|
|
wix_file = self.build_dir / "WebDropBridge.generated.wxs"
|
|
wix_file.write_text(wix_content)
|
|
print(f" Created WiX source: {wix_file}")
|
|
return True
|
|
|
|
def _generate_file_elements(
|
|
self,
|
|
folder: Path,
|
|
parent_dir_ref: str,
|
|
parent_rel_path: str,
|
|
indent: int = 8,
|
|
file_counter: Optional[dict] = None,
|
|
) -> str:
|
|
"""Generate WiX File elements for all files in a folder.
|
|
|
|
Args:
|
|
folder: Root folder to scan
|
|
parent_dir_ref: Parent WiX DirectoryRef ID
|
|
parent_rel_path: Relative path for component structure
|
|
indent: Indentation level
|
|
file_counter: Dictionary to track file IDs for uniqueness
|
|
|
|
Returns:
|
|
WiX XML string with all File elements
|
|
"""
|
|
if file_counter is None:
|
|
file_counter = {}
|
|
|
|
elements = []
|
|
indent_str = " " * indent
|
|
|
|
try:
|
|
# Get all files in current folder
|
|
for item in sorted(folder.iterdir()):
|
|
if item.is_file():
|
|
# Create unique File element ID using hash of full path
|
|
import hashlib
|
|
|
|
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
|
|
file_id = f"File_{path_hash}"
|
|
file_path = str(item)
|
|
elements.append(f'{indent_str}<File Id="{file_id}" Source="{file_path}" />')
|
|
elif item.is_dir() and item.name != "__pycache__":
|
|
# Recursively add files from subdirectories
|
|
sub_elements = self._generate_file_elements(
|
|
item, parent_dir_ref, f"{parent_rel_path}/{item.name}", indent, file_counter
|
|
)
|
|
if sub_elements:
|
|
elements.append(sub_elements)
|
|
except PermissionError:
|
|
print(f" ⚠️ Permission denied accessing {folder}")
|
|
|
|
return "\n".join(elements)
|
|
|
|
def _sanitize_id(self, filename: str) -> str:
|
|
"""Sanitize filename to be a valid WiX identifier.
|
|
|
|
Args:
|
|
filename: Filename to sanitize
|
|
|
|
Returns:
|
|
Sanitized identifier
|
|
"""
|
|
# Remove extension and invalid characters
|
|
safe_name = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
# Replace invalid characters with underscores
|
|
safe_name = "".join(c if c.isalnum() or c == "_" else "_" for c in safe_name)
|
|
# Ensure it starts with a letter or underscore
|
|
if safe_name and not (safe_name[0].isalpha() or safe_name[0] == "_"):
|
|
safe_name = f"_{safe_name}"
|
|
# Limit length to avoid WiX ID limits
|
|
return safe_name[:50] if len(safe_name) > 50 else safe_name
|
|
|
|
def sign_executable(self, cert_path: str, password: str) -> bool:
|
|
"""Sign executable with certificate (optional).
|
|
|
|
Args:
|
|
cert_path: Path to code signing certificate
|
|
password: Certificate password
|
|
|
|
Returns:
|
|
True if signing successful
|
|
"""
|
|
print("\n🔐 Signing executable...")
|
|
|
|
signtool = shutil.which("signtool.exe")
|
|
if not signtool:
|
|
print("⚠️ signtool.exe not found (part of Windows SDK)")
|
|
print(" Skipping code signing")
|
|
return True
|
|
|
|
exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
|
|
cmd = [
|
|
signtool,
|
|
"sign",
|
|
"/f",
|
|
cert_path,
|
|
"/p",
|
|
password,
|
|
"/t",
|
|
"http://timestamp.comodoca.com/authenticode",
|
|
str(exe_path),
|
|
]
|
|
|
|
result = subprocess.run(cmd, text=True)
|
|
if result.returncode != 0:
|
|
print("❌ Code signing failed")
|
|
return False
|
|
|
|
print("✅ Executable signed successfully")
|
|
return True
|
|
|
|
def build(self, create_msi: bool = False, sign: bool = False) -> bool:
|
|
"""Run complete build process.
|
|
|
|
Args:
|
|
create_msi: Whether to create MSI installer
|
|
sign: Whether to sign executable (requires certificate)
|
|
|
|
Returns:
|
|
True if build successful
|
|
"""
|
|
start_time = datetime.now()
|
|
print("=" * 60)
|
|
print(f"🚀 {self.brand.display_name} Windows Build")
|
|
print("=" * 60)
|
|
|
|
self.clean()
|
|
|
|
if not self.build_executable():
|
|
return False
|
|
|
|
if create_msi:
|
|
if not self.create_msi():
|
|
print("⚠️ MSI creation failed, but executable is available")
|
|
|
|
if sign:
|
|
# Would need certificate path from environment
|
|
cert_path = os.getenv("CODE_SIGN_CERT")
|
|
if cert_path:
|
|
self.sign_executable(cert_path, os.getenv("CODE_SIGN_PASSWORD", ""))
|
|
|
|
elapsed = (datetime.now() - start_time).total_seconds()
|
|
print("\n" + "=" * 60)
|
|
print(f"✅ Build completed in {elapsed:.1f}s")
|
|
print("=" * 60)
|
|
return True
|
|
|
|
|
|
def main() -> int:
|
|
"""Build Windows MSI installer."""
|
|
parser = argparse.ArgumentParser(description="Build WebDrop Bridge Windows installer")
|
|
parser.add_argument(
|
|
"--msi",
|
|
action="store_true",
|
|
help="Create MSI installer (requires WiX Toolset)",
|
|
)
|
|
parser.add_argument(
|
|
"--code-sign",
|
|
action="store_true",
|
|
help="Sign executable (requires certificate in CODE_SIGN_CERT env var)",
|
|
)
|
|
parser.add_argument(
|
|
"--env-file",
|
|
type=str,
|
|
default=None,
|
|
help="Path to .env file to bundle (default: project root .env)",
|
|
)
|
|
parser.add_argument(
|
|
"--brand",
|
|
type=str,
|
|
default=None,
|
|
help="Brand manifest name from build/brands (e.g. agravity)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
print("🔄 Syncing version...")
|
|
do_sync_version()
|
|
|
|
try:
|
|
builder = WindowsBuilder(env_file=args.env_file, brand=args.brand)
|
|
except FileNotFoundError as e:
|
|
print(f"❌ Build failed: {e}")
|
|
return 1
|
|
|
|
success = builder.build(create_msi=args.msi, sign=args.code_sign)
|
|
|
|
return 0 if success else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|