Files
BrowserOS/packages/browseros/build/cli/ota.py
Nikhil b035278ad9 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>
2026-03-09 13:20:08 -07:00

248 lines
6.9 KiB
Python
Generated

#!/usr/bin/env python3
"""OTA CLI - Over-The-Air update automation for BrowserOS"""
from pathlib import Path
from typing import Optional
import typer
from ..common.context import Context
from ..common.env import EnvConfig
from ..common.module import ValidationError
from ..common.sparkle import sparkle_sign_file
from ..common.utils import log_info, log_error, log_success
from ..modules.ota import ServerOTAModule
from ..modules.ota.common import (
get_appcast_path,
SERVER_PLATFORMS,
)
from ..modules.storage import get_r2_client, upload_file_to_r2
app = typer.Typer(
help="OTA (Over-The-Air) update automation",
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
)
server_app = typer.Typer(
help="BrowserOS Server OTA commands",
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
)
app.add_typer(server_app, name="server")
def create_ota_context() -> Context:
"""Create Context for OTA operations"""
return Context(
chromium_src=Path(),
architecture="",
build_type="release",
)
def execute_module(ctx: Context, module) -> None:
"""Execute a single module with validation"""
try:
module.validate(ctx)
module.execute(ctx)
except ValidationError as e:
log_error(f"Validation failed: {e}")
raise typer.Exit(1)
except Exception as e:
log_error(f"Module failed: {e}")
raise typer.Exit(1)
@server_app.command("release")
def server_release(
version: str = typer.Option(
..., "--version", "-v", help="Version to release (e.g., 0.0.69)"
),
channel: str = typer.Option(
"alpha", "--channel", "-c", help="Release channel: alpha or prod"
),
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)"
),
):
"""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.69 --channel alpha
\b
Single Platform:
browseros ota server release --version 0.0.69 --platform darwin_arm64
\b
Multiple Platforms:
browseros ota server release --version 0.0.69 --platform darwin_arm64,darwin_x64
"""
log_info(f"🚀 BrowserOS Server OTA v{version}")
log_info("=" * 70)
ctx = create_ota_context()
module = ServerOTAModule(
version=version,
channel=channel,
platform_filter=platform,
)
execute_module(ctx, module)
@server_app.command("release-appcast")
def server_release_appcast(
channel: str = typer.Option(
"alpha", "--channel", "-c", help="Release channel: alpha or prod"
),
appcast_file: Optional[Path] = typer.Option(
None, "--file", "-f", help="Custom appcast file to upload"
),
):
"""Publish appcast XML to make the release live
This is the final step after 'release' uploads artifacts.
Publishing the appcast makes the update available to clients.
\b
Release alpha appcast:
browseros ota server release-appcast --channel alpha
\b
Release production appcast:
browseros ota server release-appcast --channel prod
\b
Release custom appcast file:
browseros ota server release-appcast --file /path/to/appcast.xml
"""
if appcast_file:
if not appcast_file.exists():
log_error(f"Appcast file not found: {appcast_file}")
raise typer.Exit(1)
source_path = appcast_file
else:
source_path = get_appcast_path(channel)
if not source_path.exists():
log_error(f"Appcast file not found: {source_path}")
log_error("Run 'browseros ota server release' first to generate the appcast")
raise typer.Exit(1)
if channel == "alpha":
r2_key = "appcast-server.alpha.xml"
else:
r2_key = "appcast-server.xml"
log_info(f"📤 Uploading {source_path.name} to {r2_key}...")
env = EnvConfig()
if not env.has_r2_config():
log_error("R2 configuration not set. Required env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY")
raise typer.Exit(1)
r2_client = get_r2_client(env)
if not r2_client:
log_error("Failed to create R2 client")
raise typer.Exit(1)
if upload_file_to_r2(r2_client, source_path, r2_key, env.r2_bucket):
cdn_url = f"https://cdn.browseros.com/{r2_key}"
log_success(f"✅ Published: {cdn_url}")
else:
log_error("Upload failed")
raise typer.Exit(1)
@server_app.command("list-platforms")
def server_list_platforms():
"""List available server platforms"""
log_info("\n📦 Available Server Platforms:")
log_info("-" * 50)
for p in SERVER_PLATFORMS:
log_info(f" {p['name']:<15} {p['os']:<10} {p['arch']}")
log_info("-" * 50)
@app.command("test-signing")
def test_signing(
file_path: Path = typer.Argument(..., help="File to sign for testing"),
):
"""Test Sparkle Ed25519 signing on a file
\b
Example:
browseros ota test-signing /path/to/file.zip
"""
if not file_path.exists():
log_error(f"File not found: {file_path}")
raise typer.Exit(1)
env = EnvConfig()
if not env.has_sparkle_key():
log_error("SPARKLE_PRIVATE_KEY not set")
raise typer.Exit(1)
log_info(f"\n🔐 Testing Sparkle Ed25519 signing")
log_info(f"File: {file_path}")
log_info("-" * 60)
sig, length = sparkle_sign_file(file_path, env)
if not sig:
log_error("Signing failed")
raise typer.Exit(1)
log_success(f"✅ Signed successfully")
log_info(f" Signature: {sig[:50]}...")
log_info(f" Length: {length}")
@server_app.callback(invoke_without_command=True)
def server_main(ctx: typer.Context):
"""BrowserOS Server OTA commands
\b
Release (upload artifacts):
browseros ota server release --version 0.0.36
\b
Release Appcast (make live):
browseros ota server release-appcast --channel alpha
\b
List Platforms:
browseros ota server list-platforms
"""
if ctx.invoked_subcommand is None:
typer.echo("Use --help for usage information")
typer.echo("Available commands: release, release-appcast, list-platforms")
raise typer.Exit(0)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context):
"""OTA update automation for BrowserOS
\b
Server OTA:
browseros ota server release --version 0.0.36
browseros ota server release-appcast --channel alpha
browseros ota server list-platforms
"""
if ctx.invoked_subcommand is None:
typer.echo("Use --help for usage information")
typer.echo("Available subcommands: server")
raise typer.Exit(0)
if __name__ == "__main__":
app()