From b035278ad92a63f2d831bf63874ade0578308a0f Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 9 Mar 2026 13:20:08 -0700 Subject: [PATCH] fix: OTA binary discovery for artifact-extracted structure (#411) * fix: support artifact-extracted directory structure in OTA binary discovery The download_resources system now extracts server binaries into platform-specific subdirectories (e.g., darwin-arm64/resources/bin/), but the OTA module only looked for flat binary names. This adds find_server_binary() which checks both layouts, keeping backward compatibility with --binaries while supporting the new structure. Co-Authored-By: Claude Opus 4.6 * feat: download server binaries from R2 instead of requiring --binaries Remove the --binaries flag from `ota server release`. The module now downloads artifact zips from artifacts/server/latest/ in R2, extracts them, then signs and packages as before. This eliminates the need to have mono build output locally. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- packages/browseros/build/cli/ota.py | 15 ++-- .../browseros/build/modules/ota/__init__.py | 2 + .../browseros/build/modules/ota/common.py | 39 ++++++-- .../browseros/build/modules/ota/server.py | 89 +++++++++++-------- 4 files changed, 93 insertions(+), 52 deletions(-) diff --git a/packages/browseros/build/cli/ota.py b/packages/browseros/build/cli/ota.py index e2bd480c3..a873a0b7d 100644 --- a/packages/browseros/build/cli/ota.py +++ b/packages/browseros/build/cli/ota.py @@ -58,14 +58,11 @@ def execute_module(ctx: Context, module) -> None: @server_app.command("release") def server_release( version: str = typer.Option( - ..., "--version", "-v", help="Version to release (e.g., 0.0.36)" + ..., "--version", "-v", help="Version to release (e.g., 0.0.69)" ), channel: str = typer.Option( "alpha", "--channel", "-c", help="Release channel: alpha or prod" ), - binaries: Optional[Path] = typer.Option( - None, "--binaries", "-b", help="Directory containing server binaries" - ), platform: Optional[str] = typer.Option( None, "--platform", "-p", help="Platform(s) to process, comma-separated (darwin_arm64, darwin_x64, linux_arm64, linux_x64, windows_x64)" @@ -73,17 +70,20 @@ def server_release( ): """Release BrowserOS Server OTA update + Downloads server binaries from R2 (artifacts/server/latest/), + signs them, creates Sparkle update packages, and uploads to R2. + \b Full Release (all platforms): - browseros ota server release --version 0.0.36 --channel alpha + browseros ota server release --version 0.0.69 --channel alpha \b Single Platform: - browseros ota server release --version 0.0.36 --platform darwin_arm64 + browseros ota server release --version 0.0.69 --platform darwin_arm64 \b Multiple Platforms: - browseros ota server release --version 0.0.36 --platform darwin_arm64,darwin_x64 + browseros ota server release --version 0.0.69 --platform darwin_arm64,darwin_x64 """ log_info(f"šŸš€ BrowserOS Server OTA v{version}") log_info("=" * 70) @@ -93,7 +93,6 @@ def server_release( module = ServerOTAModule( version=version, channel=channel, - binaries_dir=binaries, platform_filter=platform, ) diff --git a/packages/browseros/build/modules/ota/__init__.py b/packages/browseros/build/modules/ota/__init__.py index b1f614c66..2a256afe5 100644 --- a/packages/browseros/build/modules/ota/__init__.py +++ b/packages/browseros/build/modules/ota/__init__.py @@ -9,6 +9,7 @@ from .common import ( SignedArtifact, SERVER_PLATFORMS, APPCAST_TEMPLATE, + find_server_binary, ) from .sign_binary import ( sign_macos_binary, @@ -29,6 +30,7 @@ __all__ = [ "parse_existing_appcast", "ExistingAppcast", "SignedArtifact", + "find_server_binary", "sign_macos_binary", "notarize_macos_binary", "sign_windows_binary", diff --git a/packages/browseros/build/modules/ota/common.py b/packages/browseros/build/modules/ota/common.py index 233e7649f..e09468638 100644 --- a/packages/browseros/build/modules/ota/common.py +++ b/packages/browseros/build/modules/ota/common.py @@ -21,11 +21,11 @@ SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" ET.register_namespace("sparkle", SPARKLE_NS) SERVER_PLATFORMS = [ - {"name": "darwin_arm64", "binary": "browseros-server-darwin-arm64", "os": "macos", "arch": "arm64"}, - {"name": "darwin_x64", "binary": "browseros-server-darwin-x64", "os": "macos", "arch": "x86_64"}, - {"name": "linux_arm64", "binary": "browseros-server-linux-arm64", "os": "linux", "arch": "arm64"}, - {"name": "linux_x64", "binary": "browseros-server-linux-x64", "os": "linux", "arch": "x86_64"}, - {"name": "windows_x64", "binary": "browseros-server-windows-x64.exe", "os": "windows", "arch": "x86_64"}, + {"name": "darwin_arm64", "binary": "browseros-server-darwin-arm64", "target": "darwin-arm64", "os": "macos", "arch": "arm64"}, + {"name": "darwin_x64", "binary": "browseros-server-darwin-x64", "target": "darwin-x64", "os": "macos", "arch": "x86_64"}, + {"name": "linux_arm64", "binary": "browseros-server-linux-arm64", "target": "linux-arm64", "os": "linux", "arch": "arm64"}, + {"name": "linux_x64", "binary": "browseros-server-linux-x64", "target": "linux-x64", "os": "linux", "arch": "x86_64"}, + {"name": "windows_x64", "binary": "browseros-server-windows-x64.exe", "target": "windows-x64", "os": "windows", "arch": "x86_64"}, ] APPCAST_TEMPLATE = """ @@ -76,6 +76,35 @@ class ExistingAppcast: artifacts: Dict[str, SignedArtifact] +def find_server_binary(binaries_dir: Path, platform: dict) -> Optional[Path]: + """Find server binary in either flat or artifact-extracted directory structure. + + Supports two layouts: + Flat: {binaries_dir}/{binary_name} (e.g., browseros-server-darwin-arm64) + Artifact: {binaries_dir}/{target}/resources/bin/browseros_server[.exe] + + Args: + binaries_dir: Root directory containing server binaries + platform: Platform dict from SERVER_PLATFORMS + + Returns: + Path to binary if found, None otherwise + """ + # Flat structure (used with --binaries pointing to mono build output) + flat_path = binaries_dir / platform["binary"] + if flat_path.exists(): + return flat_path + + # Artifact-extracted structure (used after download_resources) + target = platform.get("target", platform["name"].replace("_", "-")) + bin_name = "browseros_server.exe" if platform["os"] == "windows" else "browseros_server" + artifact_path = binaries_dir / target / "resources" / "bin" / bin_name + if artifact_path.exists(): + return artifact_path + + return None + + def parse_existing_appcast(appcast_path: Path) -> Optional[ExistingAppcast]: """Parse existing appcast XML file. diff --git a/packages/browseros/build/modules/ota/server.py b/packages/browseros/build/modules/ota/server.py index 8daa14a26..a721c8543 100644 --- a/packages/browseros/build/modules/ota/server.py +++ b/packages/browseros/build/modules/ota/server.py @@ -25,6 +25,7 @@ from .common import ( parse_existing_appcast, create_server_zip, get_appcast_path, + find_server_binary, ) from .sign_binary import ( sign_macos_binary, @@ -32,18 +33,18 @@ from .sign_binary import ( sign_windows_binary, get_entitlements_path, ) -from ..storage import get_r2_client, upload_file_to_r2 +from ..storage import get_r2_client, upload_file_to_r2, download_file_from_r2 +from ..storage.download import extract_artifact_zip + +# R2 key pattern for server artifact zips +ARTIFACT_R2_KEY = "artifacts/server/latest/browseros-server-resources-{target}.zip" class ServerOTAModule(CommandModule): """OTA update module for BrowserOS Server binaries - This module handles the full OTA workflow: - 1. Sign individual binaries (codesign for macOS, CodeSignTool for Windows) - 2. Create zip packages with proper structure - 3. Sign zips with Sparkle Ed25519 - 4. Upload to R2 - 5. Generate and upload appcast XML + Downloads server binaries from R2 (artifacts/server/latest/), + signs them, creates Sparkle update zips, and uploads to R2. """ produces = ["server_ota_artifacts", "server_appcast"] @@ -54,53 +55,28 @@ class ServerOTAModule(CommandModule): self, version: str = "", channel: str = "alpha", - binaries_dir: Optional[Path] = None, platform_filter: Optional[str] = None, ): - """ - Args: - version: Version string (e.g., "0.0.36") - channel: Release channel ("alpha" or "prod") - binaries_dir: Directory containing server binaries - platform_filter: Platform(s) to process, comma-separated (e.g., "darwin_arm64,darwin_x64") - """ self.version = version self.channel = channel - self.binaries_dir = binaries_dir self.platform_filter = platform_filter + self._download_dir: Optional[Path] = None def validate(self, context: Context) -> None: - ctx = context if not self.version: raise ValidationError("Version is required") if self.channel not in ["alpha", "prod"]: raise ValidationError("Channel must be 'alpha' or 'prod'") - if self.binaries_dir: - if not self.binaries_dir.exists(): - raise ValidationError(f"Binaries directory not found: {self.binaries_dir}") - else: - default_dir = ctx.root_dir / "resources" / "binaries" / "browseros_server" - if not default_dir.exists(): - raise ValidationError(f"Default binaries directory not found: {default_dir}") - self.binaries_dir = default_dir - - platforms = self._get_platforms() - for p in platforms: - binary_name = p["binary"] - binary_path = self.binaries_dir / binary_name - if not binary_path.exists(): - raise ValidationError(f"Binary not found: {binary_path}") - if IS_MACOS(): - if not ctx.env.macos_certificate_name: + if not context.env.macos_certificate_name: raise ValidationError("MACOS_CERTIFICATE_NAME required for signing") elif IS_WINDOWS(): - if not ctx.env.code_sign_tool_path: + if not context.env.code_sign_tool_path: raise ValidationError("CODE_SIGN_TOOL_PATH required for signing") - if not ctx.env.has_r2_config(): + if not context.env.has_r2_config(): raise ValidationError( "R2 configuration not set. Required env vars: " "R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY" @@ -113,11 +89,44 @@ class ServerOTAModule(CommandModule): return [p for p in SERVER_PLATFORMS if p["name"] in requested] return SERVER_PLATFORMS + def _download_artifacts(self, ctx: Context) -> Path: + """Download server artifact zips from R2 latest/ and extract them.""" + download_dir = Path(tempfile.mkdtemp(prefix="ota_artifacts_")) + self._download_dir = download_dir + + r2_client = get_r2_client(ctx.env) + if not r2_client: + raise RuntimeError("Failed to create R2 client") + + bucket = ctx.env.r2_bucket + platforms = self._get_platforms() + + log_info("šŸ“„ Downloading server artifacts from R2...") + + for platform in platforms: + target = platform["target"] + r2_key = ARTIFACT_R2_KEY.format(target=target) + zip_path = download_dir / f"{target}.zip" + extract_dir = download_dir / target + + log_info(f" Downloading {target}...") + if not download_file_from_r2(r2_client, r2_key, zip_path, bucket): + raise RuntimeError(f"Failed to download artifact: {r2_key}") + + extract_artifact_zip(zip_path, extract_dir) + zip_path.unlink() + + log_success(f"Downloaded {len(platforms)} artifact(s)") + return download_dir + def execute(self, context: Context) -> None: ctx = context log_info(f"\nšŸš€ BrowserOS Server OTA v{self.version} ({self.channel})") log_info("=" * 70) + # Download artifacts from R2 + binaries_dir = self._download_artifacts(ctx) + platforms = self._get_platforms() temp_dir = Path(tempfile.mkdtemp()) log_info(f"Temp directory: {temp_dir}") @@ -127,11 +136,13 @@ class ServerOTAModule(CommandModule): for platform in platforms: log_info(f"\nšŸ“¦ Processing {platform['name']}...") - binary_name = platform["binary"] - source_binary = self.binaries_dir / binary_name + source_binary = find_server_binary(binaries_dir, platform) + if not source_binary: + log_warning(f"Binary not found for {platform['name']}, skipping") + continue # Copy binary to temp to preserve original - temp_binary = temp_dir / binary_name + temp_binary = temp_dir / platform["binary"] shutil.copy2(source_binary, temp_binary) if not self._sign_binary(temp_binary, platform, ctx):