feat: build linux x64 + arm64 in a single invocation (#652)

`release.linux.yaml` now declares `architecture: [x64, arm64]` and the
runner loops the entire pipeline once per architecture. depot_tools
fetches both Linux sysroots automatically — `git_setup` idempotently
ensures `target_cpus = ['x64', 'arm64']` is in `.gclient` before
`gclient sync`, so cross-compiling arm64 from an x64 host just works.

The resolver returns `List[Context]` (single-element for the common
single-arch case), and `build/cli/build.py` loops `execute_pipeline` over
the per-arch contexts. Modules stay 100% arch-agnostic — no new
orchestration module, no new YAML schema beyond the list form.

Also fix a cross-compile bug in `build/modules/package/linux.py`: the
appimagetool binary must match the BUILD machine's arch (it executes
locally), not the target arch. Split into a host-keyed
`LINUX_HOST_APPIMAGETOOL` lookup vs the existing target-keyed
`LINUX_ARCHITECTURE_CONFIG`. Target arch is still passed to appimagetool
via the `ARCH` env var.

- build/common/resolver.py: scalar OR list `architecture` -> List[Context]
- build/cli/build.py: loop pipeline per arch, log multi-arch headers
- build/config/release.linux.yaml: `architecture: [x64, arm64]`
- build/modules/setup/git.py: idempotent `target_cpus` edit on Linux
- build/modules/package/linux.py: host vs target appimagetool split
- build/modules/package/linux_test.py: cover the host/target split
This commit is contained in:
Nikhil
2026-04-06 13:08:06 -07:00
committed by GitHub
parent 1b8720740c
commit 8de2bf984f
6 changed files with 250 additions and 60 deletions

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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