diff --git a/packages/browseros/build/cli/build.py b/packages/browseros/build/cli/build.py index 2630d2ba1..739a6c803 100644 --- a/packages/browseros/build/cli/build.py +++ b/packages/browseros/build/cli/build.py @@ -402,9 +402,11 @@ def main( "upload": upload, } - # Resolve build context (CONFIG mode or DIRECT mode) + # Resolve build context (CONFIG mode or DIRECT mode). + # Returns one Context per architecture — single-element for normal + # builds, multi-element when YAML declares `architecture: [x64, arm64]`. try: - ctx = resolve_config(cli_args, config_data) + arch_ctxs = resolve_config(cli_args, config_data) except ValueError as e: log_error(str(e)) raise typer.Exit(1) @@ -459,20 +461,40 @@ def main( os.environ["DEPOT_TOOLS_WIN_TOOLCHAIN"] = "0" log_info("Set DEPOT_TOOLS_WIN_TOOLCHAIN=0 for Windows build") + # Print build summary using the first context — versions and paths + # are identical across per-arch contexts. Architecture is logged again + # inside the loop below for multi-arch runs. + summary_ctx = arch_ctxs[0] log_info(f"📍 Root: {root_dir}") - log_info(f"📍 Chromium: {ctx.chromium_src}") - log_info(f"📍 Architecture: {ctx.architecture}") - log_info(f"📍 Build type: {ctx.build_type}") - log_info(f"📍 Output: {ctx.out_dir}") - log_info(f"📍 Semantic version: {ctx.semantic_version}") - log_info(f"📍 Chromium version: {ctx.chromium_version}") - log_info(f"📍 Build offset: {ctx.browseros_build_offset}") + log_info(f"📍 Chromium: {summary_ctx.chromium_src}") + if len(arch_ctxs) > 1: + log_info( + f"📍 Architectures: {[c.architecture for c in arch_ctxs]} (multi-arch loop)" + ) + else: + log_info(f"📍 Architecture: {summary_ctx.architecture}") + log_info(f"📍 Build type: {summary_ctx.build_type}") + log_info(f"📍 Semantic version: {summary_ctx.semantic_version}") + log_info(f"📍 Chromium version: {summary_ctx.chromium_version}") + log_info(f"📍 Build offset: {summary_ctx.browseros_build_offset}") log_info(f"📍 Pipeline: {' → '.join(pipeline)}") log_info("=" * 70) - # Set notification context for OS and architecture os_name = "macOS" if IS_MACOS() else "Windows" if IS_WINDOWS() else "Linux" - set_build_context(os_name, ctx.architecture) - # Execute pipeline - execute_pipeline(ctx, pipeline, AVAILABLE_MODULES, pipeline_name="build") + # Execute the pipeline once per architecture. Modules see a normal + # single-arch ctx; the runner is the only thing that knows about the + # multi-arch loop. + for i, arch_ctx in enumerate(arch_ctxs, start=1): + if len(arch_ctxs) > 1: + log_info("\n" + "#" * 70) + log_info( + f"# Architecture {i}/{len(arch_ctxs)}: {arch_ctx.architecture}" + ) + log_info(f"# Output: {arch_ctx.out_dir}") + log_info("#" * 70) + + set_build_context(os_name, arch_ctx.architecture) + execute_pipeline( + arch_ctx, pipeline, AVAILABLE_MODULES, pipeline_name="build" + ) diff --git a/packages/browseros/build/common/resolver.py b/packages/browseros/build/common/resolver.py index b5f38b05f..011b8aa81 100644 --- a/packages/browseros/build/common/resolver.py +++ b/packages/browseros/build/common/resolver.py @@ -26,11 +26,13 @@ from .context import Context from .env import EnvConfig from .utils import get_platform_arch, log_info +VALID_ARCHITECTURES = {"x64", "arm64", "universal"} + def resolve_config( cli_args: Dict[str, Any], yaml_config: Optional[Dict[str, Any]] = None, -) -> Context: +) -> List[Context]: """Resolve build configuration - single entry point. Args: @@ -38,7 +40,9 @@ def resolve_config( yaml_config: Optional YAML configuration (triggers CONFIG mode) Returns: - Fully resolved Context object + List of fully resolved Context objects. Single-element for the + common single-arch case; multi-element when YAML declares + `architecture: [x64, arm64]` (Linux multi-arch). Raises: ValueError: If required fields missing or invalid @@ -59,7 +63,7 @@ def resolve_config( def _resolve_config_mode( yaml_config: Dict[str, Any], cli_args: Dict[str, Any] -) -> Context: +) -> List[Context]: """CONFIG MODE: YAML is base, CLI can override. Args: @@ -67,7 +71,7 @@ def _resolve_config_mode( cli_args: CLI arguments (can override YAML values) Returns: - Context with values from YAML, optionally overridden by CLI + List of Contexts. One per architecture when YAML provides a list. Raises: ValueError: If required fields missing from both YAML and CLI @@ -94,41 +98,66 @@ def _resolve_config_mode( f"Expected directory with Chromium source code" ) - # architecture: CLI override > YAML > platform default - architecture = ( - cli_args.get("arch") - or build_section.get("architecture") - or build_section.get("arch") - ) - arch_source = "cli" if cli_args.get("arch") else "yaml" - if not architecture: - architecture = get_platform_arch() + # architecture: CLI override > YAML > platform default. + # YAML may be a string OR a list (e.g. [x64, arm64]) — list form runs + # the entire pipeline once per arch. + cli_arch = cli_args.get("arch") + yaml_arch = build_section.get("architecture") or build_section.get("arch") + + if cli_arch: + architectures = [cli_arch] + arch_source = "cli" + elif yaml_arch is not None: + architectures = yaml_arch if isinstance(yaml_arch, list) else [yaml_arch] + arch_source = "yaml" + else: + architectures = [get_platform_arch()] arch_source = "default" - log_info(f"CONFIG MODE: Using platform default architecture: {architecture}") + log_info( + f"CONFIG MODE: Using platform default architecture: {architectures[0]}" + ) + + for arch in architectures: + if arch not in VALID_ARCHITECTURES: + raise ValueError( + f"CONFIG MODE: invalid architecture '{arch}'. " + f"Valid: {sorted(VALID_ARCHITECTURES)}" + ) # build_type: CLI override > YAML > debug build_type = cli_args.get("build_type") or build_section.get("type", "debug") build_type_source = "cli" if cli_args.get("build_type") else "yaml" log_info(f"✓ CONFIG MODE: chromium_src={chromium_src} ({chromium_src_source})") - log_info(f"✓ CONFIG MODE: architecture={architecture} ({arch_source})") + if len(architectures) > 1: + log_info( + f"✓ CONFIG MODE: architectures={architectures} ({arch_source}, multi-arch loop)" + ) + else: + log_info( + f"✓ CONFIG MODE: architecture={architectures[0]} ({arch_source})" + ) log_info(f"✓ CONFIG MODE: build_type={build_type} ({build_type_source})") - return Context( - chromium_src=chromium_src, - architecture=architecture, - build_type=build_type, - ) + return [ + Context( + chromium_src=chromium_src, + architecture=arch, + build_type=build_type, + ) + for arch in architectures + ] -def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context: +def _resolve_direct_mode(cli_args: Dict[str, Any]) -> List[Context]: """DIRECT MODE: CLI > Env > Defaults. Args: cli_args: CLI arguments (None if not provided by user) Returns: - Context with resolved values + Single-element list with the resolved Context. DIRECT mode is + always single-arch (CLI --arch is a scalar). Raises: ValueError: If chromium_src not provided @@ -160,6 +189,12 @@ def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context: architecture = get_platform_arch() log_info(f"DIRECT MODE: Using platform default architecture: {architecture}") + if architecture not in VALID_ARCHITECTURES: + raise ValueError( + f"DIRECT MODE: invalid architecture '{architecture}'. " + f"Valid: {sorted(VALID_ARCHITECTURES)}" + ) + # build_type: CLI > Default build_type = cli_args.get("build_type") or "debug" @@ -167,11 +202,13 @@ def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context: log_info(f"✓ DIRECT MODE: architecture={architecture} (cli/env/default)") log_info(f"✓ DIRECT MODE: build_type={build_type} (cli/default)") - return Context( - chromium_src=chromium_src, - architecture=architecture, - build_type=build_type, - ) + return [ + Context( + chromium_src=chromium_src, + architecture=architecture, + build_type=build_type, + ) + ] def resolve_pipeline( diff --git a/packages/browseros/build/config/release.linux.yaml b/packages/browseros/build/config/release.linux.yaml index 6847d2bdc..ef623f870 100644 --- a/packages/browseros/build/config/release.linux.yaml +++ b/packages/browseros/build/config/release.linux.yaml @@ -1,8 +1,11 @@ # BrowserOS Linux Release Build Configuration # -# Run this config once per target architecture: -# browseros build --config build/config/release.linux.yaml --arch x64 -# browseros build --config build/config/release.linux.yaml --arch arm64 +# Builds both x64 and arm64 in a single invocation on a Linux x64 host. +# The runner loops the entire pipeline once per architecture; depot_tools +# fetches the matching sysroots automatically (see git_setup module). +# +# Run: +# browseros build --config build/config/release.linux.yaml # # Environment Variables: # Use !env tag to reference environment variables: @@ -10,11 +13,12 @@ build: type: release + architecture: [x64, arm64] # Builds both arches sequentially in one run gn_flags: file: build/config/gn/flags.linux.release.gn -# Explicit module execution order +# Explicit module execution order. Runs once per architecture above. modules: # Phase 1: Setup - clean diff --git a/packages/browseros/build/modules/package/linux.py b/packages/browseros/build/modules/package/linux.py index 84134b8cd..cab7dc5a0 100644 --- a/packages/browseros/build/modules/package/linux.py +++ b/packages/browseros/build/modules/package/linux.py @@ -17,25 +17,41 @@ from ...common.utils import ( run_command, safe_rmtree, join_paths, + get_platform_arch, IS_LINUX, ) from ...common.notify import get_notifier, COLOR_GREEN +# Target-arch packaging metadata. These describe the artifact we're +# producing, not the build machine. `appimage_arch` is passed to +# appimagetool via the ARCH env var; `deb_arch` is written into the +# .deb control file. LINUX_ARCHITECTURE_CONFIG = { "x64": { - "appimage_tool": "appimagetool-x86_64.AppImage", - "appimage_url": "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage", "appimage_arch": "x86_64", "deb_arch": "amd64", }, "arm64": { - "appimage_tool": "appimagetool-aarch64.AppImage", - "appimage_url": "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage", "appimage_arch": "aarch64", "deb_arch": "arm64", }, } +# Host-arch tool selection. appimagetool is a normal binary that runs on +# the build machine — when cross-compiling arm64 from an x64 host, we +# still need the x86_64 tool to actually execute. Keyed on +# get_platform_arch() (BUILD machine arch), NOT ctx.architecture. +LINUX_HOST_APPIMAGETOOL = { + "x64": ( + "appimagetool-x86_64.AppImage", + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage", + ), + "arm64": ( + "appimagetool-aarch64.AppImage", + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage", + ), +} + def get_linux_architecture_config(architecture: str) -> dict[str, str]: config = LINUX_ARCHITECTURE_CONFIG.get(architecture) @@ -47,6 +63,19 @@ def get_linux_architecture_config(architecture: str) -> dict[str, str]: return config +def get_host_appimagetool() -> tuple[str, str]: + """Return (filename, url) for the appimagetool binary that runs on + the current build machine. Critical for cross-compile correctness.""" + host_arch = get_platform_arch() + tool = LINUX_HOST_APPIMAGETOOL.get(host_arch) + if not tool: + supported = ", ".join(sorted(LINUX_HOST_APPIMAGETOOL)) + raise ValueError( + f"No appimagetool binary for host arch '{host_arch}'. Supported: {supported}" + ) + return tool + + class LinuxPackageModule(CommandModule): produces = ["appimage", "deb"] requires = [] @@ -313,26 +342,30 @@ export CHROME_WRAPPER="${{THIS}}" def download_appimagetool(ctx: Context) -> Optional[Path]: - """Download appimagetool if not available""" + """Download the appimagetool binary that runs on the build machine. + + Note: this is keyed on the HOST arch, not ctx.architecture. When + cross-compiling arm64 packages from an x64 host, we still need the + x86_64 appimagetool because the tool executes locally; the target + arch is communicated via the ARCH env var in create_appimage(). + """ tool_dir = Path(join_paths(ctx.root_dir, "build", "tools")) tool_dir.mkdir(exist_ok=True) - arch_config = get_linux_architecture_config(ctx.architecture) - tool_path = Path(join_paths(tool_dir, arch_config["appimage_tool"])) + tool_filename, url = get_host_appimagetool() + tool_path = Path(join_paths(tool_dir, tool_filename)) if tool_path.exists(): - log_info("✓ appimagetool already available") + log_info(f"✓ appimagetool already available ({tool_filename})") return tool_path - log_info("📥 Downloading appimagetool...") - url = arch_config["appimage_url"] - + log_info(f"📥 Downloading {tool_filename}...") cmd = ["wget", "-O", str(tool_path), url] result = run_command(cmd, check=False) if result.returncode == 0: tool_path.chmod(0o755) - log_success("✓ Downloaded appimagetool") + log_success(f"✓ Downloaded {tool_filename}") return tool_path else: log_error("Failed to download appimagetool") diff --git a/packages/browseros/build/modules/package/linux_test.py b/packages/browseros/build/modules/package/linux_test.py index 5d11089fb..4dfbec6bd 100644 --- a/packages/browseros/build/modules/package/linux_test.py +++ b/packages/browseros/build/modules/package/linux_test.py @@ -2,22 +2,25 @@ """Tests for Linux packaging architecture helpers.""" import unittest +from unittest.mock import patch -from build.modules.package.linux import get_linux_architecture_config +from build.modules.package.linux import ( + LINUX_HOST_APPIMAGETOOL, + get_host_appimagetool, + get_linux_architecture_config, +) class LinuxArchitectureConfigTest(unittest.TestCase): def test_returns_x64_packaging_config(self) -> None: config = get_linux_architecture_config("x64") - self.assertEqual(config["appimage_tool"], "appimagetool-x86_64.AppImage") self.assertEqual(config["appimage_arch"], "x86_64") self.assertEqual(config["deb_arch"], "amd64") def test_returns_arm64_packaging_config(self) -> None: config = get_linux_architecture_config("arm64") - self.assertEqual(config["appimage_tool"], "appimagetool-aarch64.AppImage") self.assertEqual(config["appimage_arch"], "aarch64") self.assertEqual(config["deb_arch"], "arm64") @@ -26,5 +29,35 @@ class LinuxArchitectureConfigTest(unittest.TestCase): get_linux_architecture_config("universal") +class HostAppImageToolTest(unittest.TestCase): + """The appimagetool binary must match the BUILD machine's arch, not + the target arch — otherwise cross-compiling arm64 packages from an x64 + host fails because the aarch64 tool can't execute on x64.""" + + def test_x64_host_picks_x86_64_tool(self) -> None: + with patch( + "build.modules.package.linux.get_platform_arch", return_value="x64" + ): + filename, url = get_host_appimagetool() + + self.assertEqual(filename, "appimagetool-x86_64.AppImage") + self.assertIn("x86_64", url) + + def test_arm64_host_picks_aarch64_tool(self) -> None: + with patch( + "build.modules.package.linux.get_platform_arch", return_value="arm64" + ): + filename, url = get_host_appimagetool() + + self.assertEqual(filename, "appimagetool-aarch64.AppImage") + self.assertIn("aarch64", url) + + def test_host_lookup_independent_of_target(self) -> None: + # Both architectures must be present in the host lookup so cross + # builds work in either direction. + self.assertIn("x64", LINUX_HOST_APPIMAGETOOL) + self.assertIn("arm64", LINUX_HOST_APPIMAGETOOL) + + if __name__ == "__main__": unittest.main() diff --git a/packages/browseros/build/modules/setup/git.py b/packages/browseros/build/modules/setup/git.py index b2803a610..4273d495b 100644 --- a/packages/browseros/build/modules/setup/git.py +++ b/packages/browseros/build/modules/setup/git.py @@ -1,12 +1,24 @@ #!/usr/bin/env python3 """Git operations module for BrowserOS build system""" +import re import subprocess import tarfile import urllib.request +from typing import List + from ...common.module import CommandModule, ValidationError from ...common.context import Context -from ...common.utils import run_command, log_info, log_error, log_success, IS_WINDOWS, safe_rmtree +from ...common.utils import ( + run_command, + log_info, + log_warning, + log_error, + log_success, + IS_LINUX, + IS_WINDOWS, + safe_rmtree, +) class GitSetupModule(CommandModule): @@ -32,6 +44,12 @@ class GitSetupModule(CommandModule): log_info(f"🔀 Checking out tag: {ctx.chromium_version}") run_command(["git", "checkout", f"tags/{ctx.chromium_version}"], cwd=ctx.chromium_src) + # On Linux, depot_tools fetches per-arch sysroots automatically when + # `.gclient` declares `target_cpus`. Ensure both x64 and arm64 are + # listed before sync so cross-compilation just works on x64 hosts. + if IS_LINUX(): + self._ensure_gclient_target_cpus(ctx, ["x64", "arm64"]) + log_info("📥 Syncing dependencies (this may take a while)...") if IS_WINDOWS(): run_command(["gclient.bat", "sync", "-D", "--no-history", "--shallow"], cwd=ctx.chromium_src) @@ -40,6 +58,49 @@ class GitSetupModule(CommandModule): log_success("Git setup complete") + def _ensure_gclient_target_cpus(self, ctx: Context, required: List[str]) -> None: + """Idempotently add `target_cpus` to .gclient so depot_tools fetches + the matching Linux sysroots for cross-compilation. + + depot_tools convention: .gclient lives one directory above + chromium_src (i.e. ../.gclient). It is a Python file with a list + of solution dicts followed by optional top-level assignments. + We append a `target_cpus = [...]` line if missing or merge in any + archs that aren't already present. + """ + gclient_path = ctx.chromium_src.parent / ".gclient" + if not gclient_path.exists(): + log_warning( + f"⚠️ .gclient not found at {gclient_path}; " + f"skipping target_cpus bootstrap. " + f"Cross-arch builds may fail until you run `fetch chromium`." + ) + return + + content = gclient_path.read_text() + match = re.search(r"^\s*target_cpus\s*=\s*\[([^\]]*)\]", content, re.MULTILINE) + + if match: + existing = re.findall(r"['\"]([^'\"]+)['\"]", match.group(1)) + missing = [arch for arch in required if arch not in existing] + if not missing: + log_info(f"✓ .gclient target_cpus already includes {required}") + return + merged = sorted(set(existing) | set(required)) + new_line = f"target_cpus = {merged!r}" + content = ( + content[: match.start()] + new_line + content[match.end() :] + ) + log_info( + f"📝 Updating .gclient target_cpus: {existing} → {merged}" + ) + else: + new_line = f"\ntarget_cpus = {required!r}\n" + content = content.rstrip() + "\n" + new_line + log_info(f"📝 Adding target_cpus = {required} to .gclient") + + gclient_path.write_text(content) + def _verify_tag_exists(self, ctx: Context) -> None: result = subprocess.run( ["git", "tag", "-l", ctx.chromium_version],