From 1b8720740cfbb2b091d29309b24209f6b1189f30 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 6 Apr 2026 10:20:38 -0700 Subject: [PATCH] feat: add linux arm64 release support (#651) * feat: support linux arm64 release artifacts * fix: address PR review comments for 0406-linux_arm64_support --- .../browseros/build/config/package.linux.yaml | 6 +- .../browseros/build/config/release.linux.yaml | 5 +- .../browseros/build/modules/package/linux.py | 51 +++++++++-- .../build/modules/package/linux_test.py | 30 +++++++ .../browseros/build/modules/release/common.py | 3 +- .../browseros/build/modules/storage/upload.py | 57 +++++++++++-- .../build/modules/storage/upload_test.py | 85 +++++++++++++++++++ 7 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 packages/browseros/build/modules/package/linux_test.py create mode 100644 packages/browseros/build/modules/storage/upload_test.py diff --git a/packages/browseros/build/config/package.linux.yaml b/packages/browseros/build/config/package.linux.yaml index c83081ffc..fb086b07d 100644 --- a/packages/browseros/build/config/package.linux.yaml +++ b/packages/browseros/build/config/package.linux.yaml @@ -3,7 +3,10 @@ # This config packages an already-built Linux application. # Use this when you have a pre-built app and only need to package it. # -# Expects: out/Default/chrome (Linux binary) +# Expects: out/Default_/browseros +# Invoke with: +# browseros build --config build/config/package.linux.yaml --arch x64 +# browseros build --config build/config/package.linux.yaml --arch arm64 # # Environment Variables: # Use !env tag to reference environment variables: @@ -11,7 +14,6 @@ build: type: release - architecture: x64 # Linux x64 gn_flags: file: build/config/gn/flags.linux.release.gn diff --git a/packages/browseros/build/config/release.linux.yaml b/packages/browseros/build/config/release.linux.yaml index b22f5ed16..6847d2bdc 100644 --- a/packages/browseros/build/config/release.linux.yaml +++ b/packages/browseros/build/config/release.linux.yaml @@ -1,12 +1,15 @@ # 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 +# # Environment Variables: # Use !env tag to reference environment variables: # Example: chromium_src: !env CHROMIUM_SRC build: type: release - architecture: x64 # Linux x64 gn_flags: file: build/config/gn/flags.linux.release.gn diff --git a/packages/browseros/build/modules/package/linux.py b/packages/browseros/build/modules/package/linux.py index 322088223..84134b8cd 100644 --- a/packages/browseros/build/modules/package/linux.py +++ b/packages/browseros/build/modules/package/linux.py @@ -21,6 +21,31 @@ from ...common.utils import ( ) from ...common.notify import get_notifier, COLOR_GREEN +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", + }, +} + + +def get_linux_architecture_config(architecture: str) -> dict[str, str]: + config = LINUX_ARCHITECTURE_CONFIG.get(architecture) + if not config: + supported = ", ".join(sorted(LINUX_ARCHITECTURE_CONFIG)) + raise ValueError( + f"Unsupported Linux architecture: {architecture}. Supported: {supported}" + ) + return config + class LinuxPackageModule(CommandModule): produces = ["appimage", "deb"] @@ -30,6 +55,10 @@ class LinuxPackageModule(CommandModule): def validate(self, ctx: Context) -> None: if not IS_LINUX(): raise ValidationError("Linux packaging requires Linux") + try: + get_linux_architecture_config(ctx.architecture) + except ValueError as exc: + raise ValidationError(str(exc)) from exc out_dir = join_paths(ctx.chromium_src, ctx.out_dir) chrome_binary = join_paths(out_dir, ctx.BROWSEROS_APP_NAME) @@ -73,7 +102,7 @@ class LinuxPackageModule(CommandModule): artifacts.append(deb_path.name) notifier.notify( "📦 Package Created", - f"Linux packages created successfully", + "Linux packages created successfully", { "Artifacts": ", ".join(artifacts), "Version": ctx.semantic_version, @@ -287,15 +316,16 @@ def download_appimagetool(ctx: Context) -> Optional[Path]: """Download appimagetool if not available""" 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, "appimagetool-x86_64.AppImage")) + tool_path = Path(join_paths(tool_dir, arch_config["appimage_tool"])) if tool_path.exists(): log_info("✓ appimagetool already available") return tool_path log_info("📥 Downloading appimagetool...") - url = "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" + url = arch_config["appimage_url"] cmd = ["wget", "-O", str(tool_path), url] result = run_command(cmd, check=False) @@ -312,6 +342,7 @@ def download_appimagetool(ctx: Context) -> Optional[Path]: def create_appimage(ctx: Context, appdir: Path, output_path: Path) -> bool: """Create AppImage from AppDir""" log_info("📦 Creating AppImage...") + arch_config = get_linux_architecture_config(ctx.architecture) # Download appimagetool if needed appimagetool = download_appimagetool(ctx) @@ -319,7 +350,7 @@ def create_appimage(ctx: Context, appdir: Path, output_path: Path) -> bool: return False # Set architecture environment variable (required by appimagetool) - arch = "x86_64" if ctx.architecture == "x64" else "aarch64" + arch = arch_config["appimage_arch"] # Create AppImage with ARCH env var set for this command only cmd = [ @@ -384,7 +415,7 @@ def create_control_file(ctx: Context, debian_dir: Path) -> None: version = version.lstrip("v").replace(" ", "").replace("_", ".") # Architecture mapping - deb_arch = "amd64" if ctx.architecture == "x64" else "arm64" + deb_arch = get_linux_architecture_config(ctx.architecture)["deb_arch"] control_content = f"""Package: browseros Version: {version} @@ -653,7 +684,9 @@ def package_appimage(ctx: Context, package_dir: Path) -> Optional[Path]: """ log_info("🖼️ Building AppImage...") - appdir = Path(join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}.AppDir")) + appdir = Path( + join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}-{ctx.architecture}.AppDir") + ) if appdir.exists(): safe_rmtree(appdir) @@ -683,7 +716,9 @@ def package_deb(ctx: Context, package_dir: Path) -> Optional[Path]: """ log_info("📦 Building .deb package...") - debdir = Path(join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}_deb")) + debdir = Path( + join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}_{ctx.architecture}_deb") + ) if debdir.exists(): safe_rmtree(debdir) @@ -703,6 +738,8 @@ def package_deb(ctx: Context, package_dir: Path) -> Optional[Path]: return output_path return None + + def package_universal(contexts: List[Context]) -> bool: """Linux doesn't support universal binaries""" log_warning("Universal binaries are not supported on Linux") diff --git a/packages/browseros/build/modules/package/linux_test.py b/packages/browseros/build/modules/package/linux_test.py new file mode 100644 index 000000000..5d11089fb --- /dev/null +++ b/packages/browseros/build/modules/package/linux_test.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Tests for Linux packaging architecture helpers.""" + +import unittest + +from build.modules.package.linux import 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") + + def test_rejects_unsupported_architecture(self) -> None: + with self.assertRaisesRegex(ValueError, "Unsupported Linux architecture"): + get_linux_architecture_config("universal") + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/browseros/build/modules/release/common.py b/packages/browseros/build/modules/release/common.py index 96d11f2ce..3506e7f2a 100644 --- a/packages/browseros/build/modules/release/common.py +++ b/packages/browseros/build/modules/release/common.py @@ -6,7 +6,6 @@ from datetime import datetime from typing import Dict, List, Optional from ...common.env import EnvConfig -from ...common.utils import log_warning from ..storage import get_release_json, get_r2_client, BOTO3_AVAILABLE PLATFORMS = ["macos", "win", "linux"] @@ -24,6 +23,8 @@ DOWNLOAD_PATH_MAPPING = { "linux": { "x64_appimage": "download/BrowserOS.AppImage", "x64_deb": "download/BrowserOS.deb", + "arm64_appimage": "download/BrowserOS-arm64.AppImage", + "arm64_deb": "download/BrowserOS-arm64.deb", }, } diff --git a/packages/browseros/build/modules/storage/upload.py b/packages/browseros/build/modules/storage/upload.py index 805a621af..9bcc3450a 100644 --- a/packages/browseros/build/modules/storage/upload.py +++ b/packages/browseros/build/modules/storage/upload.py @@ -4,7 +4,7 @@ import json from datetime import datetime, timezone from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, cast from ...common.module import CommandModule, ValidationError from ...common.context import Context @@ -21,6 +21,7 @@ from ...common.notify import get_notifier, COLOR_GREEN from .r2 import ( BOTO3_AVAILABLE, get_r2_client, + get_release_json, upload_file_to_r2, ) @@ -58,7 +59,10 @@ class UploadModule(CommandModule): log_info("\nUploading package artifacts to R2...") extra_metadata = {} - sparkle_signatures = ctx.artifacts.get("sparkle_signatures") + sparkle_signatures = cast( + Optional[dict[str, tuple[str, int]]], + ctx.artifacts.get("sparkle_signatures"), + ) if sparkle_signatures: for filename, (sig, length) in sparkle_signatures.items(): extra_metadata[filename] = { @@ -120,6 +124,36 @@ def generate_release_json( return release_data +def merge_release_metadata(existing: Optional[Dict], new: Dict) -> Dict: + if not existing: + return new + + merged = dict(existing) + merged.update({key: value for key, value in new.items() if key != "artifacts"}) + + artifacts = dict(existing.get("artifacts", {})) + artifacts.update(new.get("artifacts", {})) + merged["artifacts"] = artifacts + return merged + + +def _get_linux_artifact_key(filename: str) -> Optional[str]: + lower = filename.lower() + + if ".appimage" in lower: + if "arm64" in lower or "aarch64" in lower: + return "arm64_appimage" + if "x64" in lower or "x86_64" in lower: + return "x64_appimage" + elif ".deb" in lower: + if "arm64" in lower or "aarch64" in lower: + return "arm64_deb" + if "amd64" in lower or "x64" in lower or "x86_64" in lower: + return "x64_deb" + + return None + + def _get_artifact_key(filename: str, platform: str) -> str: """Get artifact key name from filename @@ -147,10 +181,10 @@ def _get_artifact_key(filename: str, platform: str) -> str: return "x64_zip" elif platform == "linux": - if ".appimage" in lower: - return "x64_appimage" - elif ".deb" in lower: - return "x64_deb" + artifact_key = _get_linux_artifact_key(filename) + if artifact_key: + return artifact_key + log_warning(f"Unrecognized Linux artifact name: {filename}; using stem key") return Path(filename).stem @@ -181,7 +215,7 @@ def detect_artifacts(ctx: Context) -> List[Path]: def upload_release_artifacts( ctx: Context, - extra_metadata: Optional[Dict[str, Dict[str, any]]] = None, + extra_metadata: Optional[Dict[str, Dict[str, Any]]] = None, ) -> Tuple[bool, Optional[Dict]]: """Upload release artifacts to R2 and generate release.json @@ -240,6 +274,13 @@ def upload_release_artifacts( artifact_metadata.append(metadata) release_data = generate_release_json(ctx, artifact_metadata, platform) + if platform == "linux": + # Linux x64 and arm64 release jobs must be sequenced. A parallel + # fetch-merge-upload flow can still race and drop one architecture. + existing_release_data = get_release_json( + ctx.get_semantic_version(), platform, env + ) + release_data = merge_release_metadata(existing_release_data, release_data) release_json_path = ctx.get_dist_dir() / "release.json" release_json_path.write_text(json.dumps(release_data, indent=2)) @@ -248,7 +289,7 @@ def upload_release_artifacts( return False, None log_success(f"\nSuccessfully uploaded {len(artifacts)} artifact(s) to R2") - log_info(f"\nRelease metadata:") + log_info("\nRelease metadata:") log_info(f" Version: {release_data['version']}") if platform == "macos": log_info(f" Sparkle version: {release_data.get('sparkle_version', 'N/A')}") diff --git a/packages/browseros/build/modules/storage/upload_test.py b/packages/browseros/build/modules/storage/upload_test.py new file mode 100644 index 000000000..ce1b8e218 --- /dev/null +++ b/packages/browseros/build/modules/storage/upload_test.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Tests for release artifact upload metadata helpers.""" + +import unittest + +from build.modules.storage.upload import _get_artifact_key, merge_release_metadata + + +class UploadMetadataTest(unittest.TestCase): + def test_linux_x64_artifacts_use_x64_keys(self) -> None: + self.assertEqual( + _get_artifact_key("BrowserOS_v1.2.3_x64.AppImage", "linux"), + "x64_appimage", + ) + self.assertEqual( + _get_artifact_key("BrowserOS_v1.2.3_amd64.deb", "linux"), + "x64_deb", + ) + + def test_linux_arm64_artifacts_use_arm64_keys(self) -> None: + self.assertEqual( + _get_artifact_key("BrowserOS_v1.2.3_arm64.AppImage", "linux"), + "arm64_appimage", + ) + self.assertEqual( + _get_artifact_key("BrowserOS_v1.2.3_arm64.deb", "linux"), + "arm64_deb", + ) + self.assertEqual( + _get_artifact_key("BrowserOS_v1.2.3_aarch64.deb", "linux"), + "arm64_deb", + ) + + def test_merge_release_metadata_preserves_existing_artifacts(self) -> None: + existing = { + "platform": "linux", + "version": "1.2.3", + "build_date": "old", + "artifacts": { + "x64_appimage": {"filename": "BrowserOS_v1.2.3_x64.AppImage"}, + "x64_deb": {"filename": "BrowserOS_v1.2.3_amd64.deb"}, + }, + } + new = { + "platform": "linux", + "version": "1.2.3", + "build_date": "new", + "artifacts": { + "arm64_appimage": {"filename": "BrowserOS_v1.2.3_arm64.AppImage"}, + "arm64_deb": {"filename": "BrowserOS_v1.2.3_arm64.deb"}, + }, + } + + merged = merge_release_metadata(existing, new) + + self.assertEqual(merged["build_date"], "new") + self.assertEqual( + sorted(merged["artifacts"]), + ["arm64_appimage", "arm64_deb", "x64_appimage", "x64_deb"], + ) + + def test_merge_release_metadata_overwrites_matching_artifact_keys(self) -> None: + existing = { + "platform": "linux", + "version": "1.2.3", + "artifacts": { + "x64_appimage": {"filename": "old.AppImage", "size": 1}, + }, + } + new = { + "platform": "linux", + "version": "1.2.3", + "artifacts": { + "x64_appimage": {"filename": "new.AppImage", "size": 2}, + }, + } + + merged = merge_release_metadata(existing, new) + + self.assertEqual(merged["artifacts"]["x64_appimage"]["filename"], "new.AppImage") + self.assertEqual(merged["artifacts"]["x64_appimage"]["size"], 2) + + +if __name__ == "__main__": + unittest.main()