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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nikhil
2026-03-09 13:20:08 -07:00
committed by GitHub
parent 04ca38c93b
commit b035278ad9
4 changed files with 93 additions and 52 deletions

View File

@@ -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,
)

View File

@@ -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",

View File

@@ -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 = """<?xml version="1.0" encoding="utf-8"?>
@@ -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.

View File

@@ -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):