feat: add linux arm64 release support (#651)

* feat: support linux arm64 release artifacts

* fix: address PR review comments for 0406-linux_arm64_support
This commit is contained in:
Nikhil
2026-04-06 10:20:38 -07:00
committed by GitHub
parent 91be726381
commit 1b8720740c
7 changed files with 218 additions and 19 deletions

View File

@@ -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_<arch>/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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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