diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index 72f01c8..81df8c0 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -5,7 +5,7 @@ import hashlib import os import platform -import zipfile +import shutil from pathlib import Path @@ -61,94 +61,85 @@ def resolved_artifact_target() -> tuple[str, str]: return normalized_platform(), normalized_architecture() -def artifact_identity() -> dict[str, str]: +def artifact_identity(filename: str) -> dict[str, str]: """Build the archive and manifest names for the current artifact target.""" git_sha = os.environ.get("GITHUB_SHA", "local")[:12] target_platform, target_arch = resolved_artifact_target() suffix = f"bandscope-{target_platform}-{target_arch}-{git_sha}" + ext = Path(filename).suffix return { "platform": target_platform, "arch": target_arch, - "archive_name": f"{suffix}.zip", - "manifest_name": f"{suffix}.manifest.txt", + "archive_name": f"{suffix}{ext}", + "manifest_name": f"{suffix}{ext}.manifest.txt", } -def expected_binary_path(repo_root: Path) -> Path: - """Return the expected desktop binary path for the selected target triple.""" +def find_installer_packages(repo_root: Path) -> list[Path]: + """Find built Tauri installers (DMG, EXE, MSI).""" target_triple = os.environ.get("BANDSCOPE_TARGET_TRIPLE") - if target_triple and "windows" in target_triple: - system = "windows" - elif target_triple and "apple-darwin" in target_triple: - system = "macos" - else: - system = normalized_platform() - binary_name = "bandscope-desktop.exe" if system == "windows" else "bandscope-desktop" target_root = repo_root / "apps" / "desktop" / "src-tauri" / "target" if target_triple: target_root = target_root / target_triple - return target_root / "release" / binary_name + + bundle_dir = target_root / "release" / "bundle" + installers = [] + + if bundle_dir.exists(): + # macOS DMG + installers.extend(bundle_dir.glob("dmg/*.dmg")) + # Windows EXE/MSI + installers.extend(bundle_dir.glob("nsis/*.exe")) + installers.extend(bundle_dir.glob("msi/*.msi")) + + return installers def main() -> int: - """Package the desktop binary, frontend assets, and metadata into a zip archive.""" + """Find the built installer packages, rename them, and calculate checksums.""" repo_root = Path(__file__).resolve().parents[2] - binary_path = expected_binary_path(repo_root) - frontend_dist = repo_root / "apps" / "desktop" / "dist" output_dir = repo_root / "artifacts" output_dir.mkdir(parents=True, exist_ok=True) - if not binary_path.exists(): - raise FileNotFoundError(f"Missing built binary: {binary_path}") - if not frontend_dist.exists(): - raise FileNotFoundError(f"Missing frontend dist directory: {frontend_dist}") - - metadata_paths = [ - repo_root / "services" / "analysis-engine" / "uv.lock", - repo_root / "package-lock.json", - repo_root / "apps" / "desktop" / "src-tauri" / "Cargo.lock", - repo_root / "supply-chain" / "supplemental-component-inventory.json", - ] - missing_metadata = [str(path) for path in metadata_paths if not path.exists()] - if missing_metadata: - missing_list = ", ".join(missing_metadata) - raise FileNotFoundError(f"Missing release metadata files: {missing_list}") - - identity = artifact_identity() - archive_name = identity["archive_name"] - archive_path = output_dir / archive_name - - with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: - archive.write(binary_path, arcname=f"bin/{binary_path.name}") - for path in frontend_dist.rglob("*"): - if path.is_file(): - archive.write( - path, - arcname=str(Path("frontend") / path.relative_to(frontend_dist)), - ) - for extra_path in metadata_paths: - archive.write(extra_path, arcname=str(Path("metadata") / extra_path.name)) - - checksum_path = output_dir / f"{archive_name}.sha256" - checksum_path.write_text(f"{sha256_file(archive_path)} {archive_name}\n", encoding="utf-8") - - manifest_path = output_dir / identity["manifest_name"] - manifest_path.write_text( - "\n".join( - [ - f"platform={identity['platform']}", - f"arch={identity['arch']}", - f"target_triple={os.environ.get('BANDSCOPE_TARGET_TRIPLE', 'native')}", - f"binary={binary_path.name}", - f"archive={archive_name}", - f"checksum={checksum_path.name}", - ] + installers = find_installer_packages(repo_root) + if not installers: + raise FileNotFoundError("Could not find any built installers (DMG/EXE) in target/release/bundle/") + + # For safety, ensure we only pick one installer per run, or handle multiple + # Typically we might have both EXE and MSI for Windows. + for installer_path in installers: + identity = artifact_identity(installer_path.name) + archive_name = identity["archive_name"] + + # If there are multiple (e.g. EXE and MSI), add original extension to avoid overwrite + if len(installers) > 1: + ext = installer_path.suffix + archive_name = archive_name.replace(ext, f"{ext}") + + archive_path = output_dir / archive_name + shutil.copy2(installer_path, archive_path) + + checksum_path = output_dir / f"{archive_name}.sha256" + checksum_path.write_text(f"{sha256_file(archive_path)} {archive_name}\n", encoding="utf-8") + + manifest_path = output_dir / identity["manifest_name"] + manifest_path.write_text( + "\n".join( + [ + f"platform={identity['platform']}", + f"arch={identity['arch']}", + f"target_triple={os.environ.get('BANDSCOPE_TARGET_TRIPLE', 'native')}", + f"original_file={installer_path.name}", + f"archive={archive_name}", + f"checksum={checksum_path.name}", + ] + ) + + "\n", + encoding="utf-8", ) - + "\n", - encoding="utf-8", - ) - print(str(archive_path.relative_to(repo_root))) + print(f"Packaged {installer_path.name} to artifacts/{archive_name}") + return 0 diff --git a/services/analysis-engine/tests/test_release_packaging.py b/services/analysis-engine/tests/test_release_packaging.py index d53db32..ce6ef9a 100644 --- a/services/analysis-engine/tests/test_release_packaging.py +++ b/services/analysis-engine/tests/test_release_packaging.py @@ -20,13 +20,13 @@ def test_release_packaging_includes_architecture_in_artifact_identity( monkeypatch.setenv("BANDSCOPE_ARTIFACT_OS", "windows") monkeypatch.setenv("BANDSCOPE_ARTIFACT_ARCH", "arm64") - artifact = packaging.artifact_identity() + artifact = packaging.artifact_identity("installer.dmg") assert artifact == { "platform": "windows", "arch": "arm64", - "archive_name": "bandscope-windows-arm64-abcdef123456.zip", - "manifest_name": "bandscope-windows-arm64-abcdef123456.manifest.txt", + "archive_name": "bandscope-windows-arm64-abcdef123456.dmg", + "manifest_name": "bandscope-windows-arm64-abcdef123456.dmg.manifest.txt", } @@ -46,29 +46,26 @@ def test_release_packaging_derives_artifact_identity_from_target_triple( monkeypatch.setattr(packaging.platform, "system", lambda: "Darwin") monkeypatch.setattr(packaging.platform, "machine", lambda: "arm64") - artifact = packaging.artifact_identity() + artifact = packaging.artifact_identity("installer.exe") assert artifact == { "platform": "windows", "arch": "amd64", - "archive_name": "bandscope-windows-amd64-fedcba987654.zip", - "manifest_name": "bandscope-windows-amd64-fedcba987654.manifest.txt", + "archive_name": "bandscope-windows-amd64-fedcba987654.exe", + "manifest_name": "bandscope-windows-amd64-fedcba987654.exe.manifest.txt", } -def test_expected_binary_path_uses_target_triple_when_provided( +def test_find_installer_packages_returns_dmg( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Ensure target triples redirect packaging to the expected Tauri output path.""" + """Ensure find_installer_packages finds dmg files.""" packaging = load_module( "scripts/release/package_desktop_artifact.py", "package_desktop_artifact_target" ) monkeypatch.setenv("BANDSCOPE_TARGET_TRIPLE", "aarch64-apple-darwin") - - binary_path = packaging.expected_binary_path(tmp_path) - - assert binary_path == ( + dmg_path = ( tmp_path / "apps" / "desktop" @@ -76,14 +73,19 @@ def test_expected_binary_path_uses_target_triple_when_provided( / "target" / "aarch64-apple-darwin" / "release" - / "bandscope-desktop" + / "bundle" + / "dmg" + / "Test.dmg" ) + dmg_path.parent.mkdir(parents=True) + dmg_path.write_bytes(b"dmg") + installers = packaging.find_installer_packages(tmp_path) + assert installers == [dmg_path] -def test_expected_binary_path_derives_windows_extension_from_target_triple( - monkeypatch, tmp_path: Path -) -> None: - """Ensure Windows target triples select the .exe packaging path on non-Windows hosts.""" + +def test_find_installer_packages_returns_exe_and_msi(monkeypatch, tmp_path: Path) -> None: + """Ensure find_installer_packages finds exe and msi files.""" packaging = load_module( "scripts/release/package_desktop_artifact.py", "package_desktop_artifact_windows_target" ) @@ -92,9 +94,7 @@ def test_expected_binary_path_derives_windows_extension_from_target_triple( monkeypatch.setenv("BANDSCOPE_TARGET_TRIPLE", "x86_64-pc-windows-msvc") monkeypatch.setattr(packaging.platform, "system", lambda: "Darwin") - binary_path = packaging.expected_binary_path(tmp_path) - - assert binary_path == ( + exe_path = ( tmp_path / "apps" / "desktop" @@ -102,8 +102,29 @@ def test_expected_binary_path_derives_windows_extension_from_target_triple( / "target" / "x86_64-pc-windows-msvc" / "release" - / "bandscope-desktop.exe" + / "bundle" + / "nsis" + / "Test.exe" ) + msi_path = ( + tmp_path + / "apps" + / "desktop" + / "src-tauri" + / "target" + / "x86_64-pc-windows-msvc" + / "release" + / "bundle" + / "msi" + / "Test.msi" + ) + exe_path.parent.mkdir(parents=True) + exe_path.write_bytes(b"exe") + msi_path.parent.mkdir(parents=True) + msi_path.write_bytes(b"msi") + + installers = packaging.find_installer_packages(tmp_path) + assert set(installers) == {exe_path, msi_path} def test_release_packaging_maps_darwin_to_macos(monkeypatch: pytest.MonkeyPatch) -> None: @@ -129,7 +150,8 @@ def test_release_packaging_main_writes_arch_specific_manifest( script_path = repo_root / "scripts" / "release" / "package_desktop_artifact.py" script_path.parent.mkdir(parents=True) script_path.write_text("# placeholder", encoding="utf-8") - binary_path = ( + + dmg_path = ( repo_root / "apps" / "desktop" @@ -137,21 +159,12 @@ def test_release_packaging_main_writes_arch_specific_manifest( / "target" / "aarch64-apple-darwin" / "release" - / "bandscope-desktop" + / "bundle" + / "dmg" + / "App.dmg" ) - binary_path.parent.mkdir(parents=True) - binary_path.write_bytes(b"binary") - frontend_file = repo_root / "apps" / "desktop" / "dist" / "index.html" - frontend_file.parent.mkdir(parents=True) - frontend_file.write_text("", encoding="utf-8") - for metadata_path in [ - repo_root / "services" / "analysis-engine" / "uv.lock", - repo_root / "package-lock.json", - repo_root / "apps" / "desktop" / "src-tauri" / "Cargo.lock", - repo_root / "supply-chain" / "supplemental-component-inventory.json", - ]: - metadata_path.parent.mkdir(parents=True, exist_ok=True) - metadata_path.write_text("metadata", encoding="utf-8") + dmg_path.parent.mkdir(parents=True) + dmg_path.write_bytes(b"dmg") monkeypatch.setattr(packaging, "__file__", str(script_path)) monkeypatch.setenv("GITHUB_SHA", "1234567890abcdef") @@ -160,7 +173,7 @@ def test_release_packaging_main_writes_arch_specific_manifest( monkeypatch.setenv("BANDSCOPE_TARGET_TRIPLE", "aarch64-apple-darwin") assert packaging.main() == 0 - manifest_path = repo_root / "artifacts" / "bandscope-macos-arm64-1234567890ab.manifest.txt" + manifest_path = repo_root / "artifacts" / "bandscope-macos-arm64-1234567890ab.dmg.manifest.txt" assert manifest_path.exists() assert "platform=macos" in manifest_path.read_text(encoding="utf-8")