summaryrefslogtreecommitdiff
path: root/src/sailfish_devel_mcp/vendor/build_sailfishos.py
diff options
context:
space:
mode:
authorAndrew Branson <andrew.branson@jolla.com>2026-08-09 15:03:34 +0200
committerAndrew Branson <andrew.branson@jolla.com>2026-08-09 15:03:34 +0200
commitb9746bfa320cdd1464eb6c91fbadad35c84d2459 (patch)
tree6528b43f66e0bc6b451666c3fb607a927110d304 /src/sailfish_devel_mcp/vendor/build_sailfishos.py
parenteb92561bd685961889cc553cd96db4a569eefec5 (diff)
Harden build workflows and OBS selection
Add preflight, cancellation, confined status, and timeout handling for local Sailfish and remote Android build jobs. Vendor helper 2.0.0 with explicit backend/pull controls, build locking, local RPM validation, metadata, and structured failure reporting. Expose named internal, partner, and community OBS servers while retaining raw osc API aliases.
Diffstat (limited to 'src/sailfish_devel_mcp/vendor/build_sailfishos.py')
-rwxr-xr-xsrc/sailfish_devel_mcp/vendor/build_sailfishos.py711
1 files changed, 597 insertions, 114 deletions
diff --git a/src/sailfish_devel_mcp/vendor/build_sailfishos.py b/src/sailfish_devel_mcp/vendor/build_sailfishos.py
index c9ad480..67cb26e 100755
--- a/src/sailfish_devel_mcp/vendor/build_sailfishos.py
+++ b/src/sailfish_devel_mcp/vendor/build_sailfishos.py
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
import argparse
+from contextlib import contextmanager, nullcontext
+import fcntl
import json
import os
import re
@@ -8,6 +10,7 @@ import shlex
import shutil
import subprocess
import sys
+import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -18,7 +21,10 @@ from urllib.request import urlopen
CONTAINER_UID = 100000
+# Third-party mirror. Its tags describe available build images, not the current
+# official SailfishOS release or installed SDK target.
CONTAINER_IMAGE = "coderus/sailfishos-platform-sdk"
+HELPER_VERSION = "2.0.0"
LIVE_RELEASE = "live"
DEFAULT_LOCAL_SDK = Path("/srv/mer/sdks/sfossdk/sdk-chroot")
LOCAL_SDK_BUILD_ENGINE_IMAGE_ENV = "SAILFISH_SDK_BUILD_ENGINE_IMAGE"
@@ -26,8 +32,19 @@ STATE_DIRNAME = "build-sailfishos-skill"
MANIFEST_NAME = "build-sailfishos-skill-manifest.txt"
BUILD_LOG_NAME = "build-sailfishos-skill-last.log"
BUILD_METADATA_NAME = "build-sailfishos-skill-last-build.json"
+BUILD_LOCK_NAME = "build-sailfishos-skill.lock"
+LOCAL_RPMS_STAGING_NAME = "local-rpms"
DEFAULT_PERMISSION_FALLBACK = "error"
+LOCAL_RPM_EXCLUDED_MARKERS = (
+ "-debuginfo-",
+ "-debugsource-",
+ "-tests-",
+ "-examples-",
+ "-doc-",
+ "-ts-devel-",
+)
+
ROOT_PATTERNS = (
"Makefile",
".qmake.stash",
@@ -91,6 +108,15 @@ class LocalSdkBuild:
target: str
+@dataclass(frozen=True)
+class BuildContext:
+ backend: str
+ release: str
+ image: str | None
+ image_id: str | None
+ local_sdk: str | None
+
+
def log(message: str) -> None:
print(message, file=sys.stderr)
@@ -127,6 +153,10 @@ def build_metadata_path(project_dir: Path) -> Path:
return project_dir / ".mb2" / BUILD_METADATA_NAME
+def build_lock_path(project_dir: Path) -> Path:
+ return project_dir / ".mb2" / BUILD_LOCK_NAME
+
+
def default_artifacts_dir(project_dir: Path) -> Path:
return project_dir / "RPMS"
@@ -135,6 +165,45 @@ def staging_rpms_dir(project_dir: Path) -> Path:
return project_state_dir(project_dir) / "rpms"
+def local_rpms_staging_dir(project_dir: Path) -> Path:
+ return project_state_dir(project_dir) / LOCAL_RPMS_STAGING_NAME
+
+
+def write_json_atomic(path: Path, payload: object) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp")
+ try:
+ temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ os.replace(temporary, path)
+ finally:
+ try:
+ temporary.unlink()
+ except FileNotFoundError:
+ pass
+
+
+@contextmanager
+def project_build_lock(project_dir: Path):
+ path = build_lock_path(project_dir)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("a+", encoding="utf-8") as lock_file:
+ try:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError as exc:
+ lock_file.seek(0)
+ owner = lock_file.read().strip()
+ detail = f" (owner {owner})" if owner else ""
+ raise SystemExit(f"Another SailfishOS build is already active for {project_dir}{detail}.") from exc
+ lock_file.seek(0)
+ lock_file.truncate()
+ lock_file.write(f"pid={os.getpid()} started_utc={datetime.now(timezone.utc).isoformat()}\n")
+ lock_file.flush()
+ try:
+ yield
+ finally:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
+
+
def has_spec_files(project_dir: Path) -> bool:
rpm_dir = project_dir / "rpm"
return rpm_dir.is_dir() and any(rpm_dir.glob("*.spec"))
@@ -148,7 +217,7 @@ def is_version_release_tag(value: str) -> bool:
return bool(re.fullmatch(r"\d+(?:\.\d+){3}", value))
-def fetch_release_tags(prefix: str | None = None) -> list[str]:
+def fetch_coderus_mirror_tags(prefix: str | None = None) -> list[str]:
name_filter = quote(prefix) if prefix else ""
matches: list[str] = []
@@ -171,11 +240,11 @@ def fetch_release_tags(prefix: str | None = None) -> list[str]:
return sorted(dict.fromkeys(matches), key=parse_version)
-def latest_release_tag() -> str:
- matches = fetch_release_tags()
+def latest_coderus_mirror_tag() -> str:
+ matches = fetch_coderus_mirror_tags()
if not matches:
raise SystemExit(
- f"Could not determine the latest SailfishOS release from {CONTAINER_IMAGE} tags."
+ f"Could not determine the newest available Docker image tag from {CONTAINER_IMAGE}."
)
return matches[-1]
@@ -186,10 +255,10 @@ def normalize_release_tag(release: str) -> str:
if release == "latest":
try:
- resolved = latest_release_tag()
+ resolved = latest_coderus_mirror_tag()
except (HTTPError, URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError) as exc:
- raise SystemExit("Could not resolve SailfishOS release tag 'latest'") from exc
- log(f"Resolved SailfishOS release latest to {resolved}")
+ raise SystemExit(f"Could not resolve Docker image tag 'latest' from {CONTAINER_IMAGE}") from exc
+ log(f"Resolved newest available {CONTAINER_IMAGE} image tag to {resolved}")
return resolved
if not re.fullmatch(r"\d+(?:\.\d+){2,3}", release):
@@ -198,7 +267,7 @@ def normalize_release_tag(release: str) -> str:
return release
try:
- matches = fetch_release_tags(release)
+ matches = fetch_coderus_mirror_tags(release)
except (HTTPError, URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError):
return release
@@ -208,7 +277,7 @@ def normalize_release_tag(release: str) -> str:
return release
resolved = max(matches, key=parse_version)
- log(f"Resolved SailfishOS release {release} to {resolved}")
+ log(f"Resolved release shorthand {release} to {CONTAINER_IMAGE} image tag {resolved}")
return resolved
@@ -253,13 +322,17 @@ def resolve_release(project_dirs: Iterable[Path], explicit_release: str | None)
return normalize_release_tag(inferred)
try:
- resolved = latest_release_tag()
+ resolved = latest_coderus_mirror_tag()
except (HTTPError, URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError) as exc:
raise SystemExit(
- "Could not determine SailfishOS release from arguments, environment, workflows, or Docker tags."
+ "Could not determine a build release from arguments, environment, workflows, "
+ f"or available {CONTAINER_IMAGE} image tags."
) from exc
- log(f"No SailfishOS release specified; using latest available release {resolved}")
+ log(
+ f"No build release specified; using newest available {CONTAINER_IMAGE} "
+ f"image tag {resolved}. This does not identify the current SailfishOS release."
+ )
return resolved
@@ -288,6 +361,42 @@ def pull_image(release: str) -> None:
run(["docker", "pull", image])
+def docker_image_exists(image: str) -> bool:
+ result = subprocess.run(
+ ["docker", "image", "inspect", image],
+ check=False,
+ text=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return result.returncode == 0
+
+
+def docker_image_id(image: str) -> str | None:
+ try:
+ result = run(
+ ["docker", "image", "inspect", "--format", "{{.Id}}", image],
+ capture_output=True,
+ )
+ except subprocess.CalledProcessError:
+ return None
+ return result.stdout.strip() or None
+
+
+def ensure_image(release: str, pull_policy: str) -> tuple[str, bool]:
+ image = f"{CONTAINER_IMAGE}:{release}"
+ exists = docker_image_exists(image)
+ should_pull = pull_policy == "always" or (pull_policy == "missing" and not exists)
+ if should_pull:
+ pull_image(release)
+ return image, True
+ if not exists:
+ raise SystemExit(
+ f"Docker image {image} is not available locally and pull policy is '{pull_policy}'."
+ )
+ return image, False
+
+
def list_supported_arches(release: str) -> list[str]:
image = f"{CONTAINER_IMAGE}:{release}"
result = run(
@@ -498,26 +607,46 @@ def write_manifest(project_dir: Path, paths: Iterable[Path]) -> None:
def write_build_metadata(
project_dir: Path,
*,
- release: str,
- arch: str,
+ context: BuildContext,
+ builds: list[dict[str, object]],
debug_build: bool,
artifacts_dir: Path,
status: str,
- rpms: Iterable[Path],
+ started_at: datetime,
+ failure_class: str | None = None,
+ failure_message: str | None = None,
) -> None:
metadata_file = build_metadata_path(project_dir)
- metadata_file.parent.mkdir(parents=True, exist_ok=True)
+ finished_at = datetime.now(timezone.utc)
+ serialized_builds: list[dict[str, object]] = []
+ for build in builds:
+ serialized = dict(build)
+ serialized["rpms"] = [str(path) for path in build.get("rpms", [])]
+ serialized_builds.append(serialized)
+ rpms = [path for build in serialized_builds for path in build.get("rpms", [])]
payload = {
- "timestamp_utc": datetime.now(timezone.utc).isoformat(),
- "release": release,
- "arch": arch,
+ "schema_version": 2,
+ "helper_version": HELPER_VERSION,
+ "started_utc": started_at.isoformat(),
+ "updated_utc": finished_at.isoformat(),
+ "finished_utc": None if status == "running" else finished_at.isoformat(),
+ "duration_seconds": round((finished_at - started_at).total_seconds(), 3),
+ "backend": context.backend,
+ "release": context.release,
+ "image": context.image,
+ "image_id": context.image_id,
+ "local_sdk": context.local_sdk,
"debug": debug_build,
"status": status,
+ "failure_class": failure_class,
+ "failure_message": failure_message,
"artifacts_dir": str(artifacts_dir),
"build_log": str(build_log_path(project_dir)),
- "rpms": [str(path) for path in rpms],
+ "builds": serialized_builds,
+ "rpms": rpms,
+ "rpmlint": rpmlint_summary(build_log_path(project_dir)),
}
- metadata_file.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ write_json_atomic(metadata_file, payload)
def write_target_marker(project_dir: Path, arch: str) -> None:
@@ -564,25 +693,21 @@ def ensure_container_write_access(project_dir: Path, permission_fallback: str) -
current_uid = os.getuid()
if shutil.which("setfacl"):
log(
- f"Granting write ACLs to host uid {current_uid} and container uid {CONTAINER_UID} under {project_dir}"
+ f"Granting read ACLs under {project_dir} and scoped output write ACLs to container uid {CONTAINER_UID}"
)
+ (project_dir / ".mb2").mkdir(parents=True, exist_ok=True)
run(
[
"find",
str(project_dir),
- "(",
- "-type",
- "f",
- "-o",
"-type",
"d",
- ")",
"-uid",
str(current_uid),
"-exec",
"setfacl",
"-m",
- f"u:{current_uid}:rwX,u:{CONTAINER_UID}:rwX",
+ f"u:{CONTAINER_UID}:rX",
"{}",
"+",
]
@@ -592,17 +717,28 @@ def ensure_container_write_access(project_dir: Path, permission_fallback: str) -
"find",
str(project_dir),
"-type",
- "d",
+ "f",
"-uid",
str(current_uid),
"-exec",
"setfacl",
"-m",
- f"d:u:{current_uid}:rwX,d:u:{CONTAINER_UID}:rwX",
+ f"u:{CONTAINER_UID}:rX",
"{}",
"+",
]
)
+ writable_paths = {project_dir, project_dir / ".mb2"}
+ translations = project_dir / "translations"
+ if translations.is_dir():
+ writable_paths.add(translations)
+ writable_paths.update(generated_candidate_paths(project_dir))
+ for path in sorted(writable_paths):
+ if not path.exists():
+ continue
+ run(["setfacl", "-m", f"u:{CONTAINER_UID}:rwX", str(path)])
+ if path.is_dir():
+ run(["setfacl", "-m", f"d:u:{CONTAINER_UID}:rwX", str(path)])
return
if permission_fallback == "chmod":
@@ -616,6 +752,85 @@ def ensure_container_write_access(project_dir: Path, permission_fallback: str) -
)
+def usable_local_rpms(directory: Path) -> list[Path]:
+ if not directory.is_dir():
+ raise SystemExit(f"Local RPM directory not found: {directory}")
+ return [
+ rpm
+ for rpm in sorted(directory.glob("*.rpm"))
+ if not any(marker in rpm.name for marker in LOCAL_RPM_EXCLUDED_MARKERS)
+ ]
+
+
+def validate_local_rpm_dirs(directories: list[Path]) -> dict[Path, list[Path]]:
+ selected: dict[Path, list[Path]] = {}
+ for directory in directories:
+ rpms = usable_local_rpms(directory)
+ if not rpms:
+ raise SystemExit(f"No installable RPMs found in local RPM directory: {directory}")
+ selected[directory] = rpms
+ return selected
+
+
+@contextmanager
+def stage_local_sdk_rpms(project_dir: Path, selected: dict[Path, list[Path]]):
+ staging_root = local_rpms_staging_dir(project_dir)
+ if staging_root.exists():
+ shutil.rmtree(staging_root)
+ staged_dirs: list[Path] = []
+ try:
+ for index, rpms in enumerate(selected.values()):
+ destination = staging_root / str(index)
+ destination.mkdir(parents=True, exist_ok=True)
+ for rpm in rpms:
+ shutil.copy2(rpm, destination / rpm.name)
+ staged_dirs.append(destination)
+ yield staged_dirs
+ finally:
+ if staging_root.exists():
+ shutil.rmtree(staging_root)
+
+
+def rpmlint_summary(log_file: Path) -> dict[str, int]:
+ summary = {"errors": 0, "warnings": 0}
+ if not log_file.is_file():
+ return summary
+ try:
+ text = log_file.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ return summary
+ final = re.findall(r"(?im);\s*(\d+)\s+errors?,\s*(\d+)\s+warnings?\.?$", text)
+ if final:
+ summary["errors"], summary["warnings"] = map(int, final[-1])
+ return summary
+ summary["errors"] = len(re.findall(r"(?m)^\S.*:\s+E:\s+", text))
+ summary["warnings"] = len(re.findall(r"(?m)^\S.*:\s+W:\s+", text))
+ return summary
+
+
+def classify_failure(error: BaseException, log_file: Path | None = None) -> str:
+ text = str(error)
+ if log_file and log_file.is_file():
+ try:
+ text += "\n" + log_file.read_text(encoding="utf-8", errors="replace")[-20000:]
+ except OSError:
+ pass
+ lowered = text.lower()
+ if "failed build dependencies" in lowered or "is needed by" in lowered:
+ return "missing-build-requires"
+ if "no basic authentication credentials" in lowered or "repository" in lowered and "not found" in lowered:
+ return "repository"
+ if "signature" in lowered or "gpg" in lowered or "unsigned rpm" in lowered:
+ return "package-trust"
+ if "permission denied" in lowered or "operation not permitted" in lowered:
+ return "permission"
+ if "docker image" in lowered or "manifest unknown" in lowered or "pull access denied" in lowered:
+ return "image"
+ if isinstance(error, subprocess.TimeoutExpired):
+ return "timeout"
+ return "build"
+
+
def parse_missing_build_requires(log_text: str) -> list[str]:
missing: list[str] = []
capture = False
@@ -885,12 +1100,26 @@ def select_local_sdk_builds(
requested_arches: list[str],
build_all: bool,
project_dir: Path,
+ requested_targets: list[str] | None = None,
) -> list[LocalSdkBuild] | None:
+ requested_targets = requested_targets or []
+ installed = list_local_sdk_targets(local_sdk)
matching = [
target
- for target in list_local_sdk_targets(local_sdk)
+ for target in installed
if local_target_matches_release(target, release)
]
+ if requested_targets:
+ by_name = {target.target: target for target in installed}
+ builds: list[LocalSdkBuild] = []
+ for requested in requested_targets:
+ target = by_name.get(canonical_local_target_name(requested))
+ if target is None:
+ return None
+ if release != LIVE_RELEASE and not local_target_matches_release(target, release):
+ return None
+ builds.append(LocalSdkBuild(target.arch, target.target))
+ return builds
if not matching:
return None
@@ -926,6 +1155,8 @@ def build_local_sdk_arch(
target: str,
debug_build: bool = False,
local_rpm_dirs: list[Path] | None = None,
+ no_vcs_apply: bool = True,
+ allow_untrusted_rpms: bool = False,
) -> None:
user = host_user()
uid = os.getuid()
@@ -971,12 +1202,20 @@ if [ -n "${LOCAL_RPM_DIRS:-}" ]; then
done
IFS="$OLDIFS"
if [ "${#rpm_files[@]}" -gt 0 ]; then
- sb2 -t "$TARGET" -m sdk-install -R zypper --non-interactive install \
- --allow-unsigned-rpm --oldpackage --force-resolution "${rpm_files[@]}" 2>&1 | tee -a "$logfile"
+ zypper_args=( --non-interactive install --oldpackage --force-resolution )
+ if [ "${ALLOW_UNTRUSTED_RPMS:-0}" = "1" ]; then
+ zypper_args+=( --allow-unsigned-rpm )
+ fi
+ sb2 -t "$TARGET" -m sdk-install -R zypper "${zypper_args[@]}" \
+ "${rpm_files[@]}" 2>&1 | tee -a "$logfile"
fi
fi
-mb2_args=( -t "$TARGET" --no-vcs-apply build --prepare )
+mb2_args=( -t "$TARGET" )
+if [ "${NO_VCS_APPLY:-0}" = "1" ]; then
+ mb2_args+=( --no-vcs-apply )
+fi
+mb2_args+=( build --prepare )
if [ "${DEBUG_BUILD:-0}" = "1" ]; then
mb2_args+=( -d )
fi
@@ -1017,6 +1256,8 @@ fi
DEBUG_BUILD="$DEBUG_BUILD" \
BUILD_LOG="$BUILD_LOG" \
LOCAL_RPM_DIRS="$LOCAL_RPM_DIRS" \
+ NO_VCS_APPLY="$NO_VCS_APPLY" \
+ ALLOW_UNTRUSTED_RPMS="$ALLOW_UNTRUSTED_RPMS" \
SYNC_BINARIES="$SYNC_BINARIES" \
bash -lc {shlex.quote(inner_command)}
'''
@@ -1050,6 +1291,10 @@ fi
"-e",
f"LOCAL_RPM_DIRS={':'.join(str(path) for path in local_rpm_dirs)}",
"-e",
+ f"NO_VCS_APPLY={'1' if no_vcs_apply else '0'}",
+ "-e",
+ f"ALLOW_UNTRUSTED_RPMS={'1' if allow_untrusted_rpms else '0'}",
+ "-e",
f"SYNC_BINARIES={binary_names}",
image,
"bash",
@@ -1065,6 +1310,8 @@ def build_arch(
arch: str,
debug_build: bool = False,
local_rpm_dirs: list[Path] | None = None,
+ no_vcs_apply: bool = False,
+ allow_untrusted_rpms: bool = False,
) -> None:
image = f"{CONTAINER_IMAGE}:{release}"
target = f"SailfishOS-{release}-{arch}"
@@ -1123,13 +1370,16 @@ if [ -n "${LOCAL_RPM_DIRS:-}" ]; then
done
IFS="$OLDIFS"
if [ "${#rpm_files[@]}" -gt 0 ]; then
- zypper --non-interactive install --allow-unsigned-rpm --oldpackage --force-resolution \
- "${rpm_files[@]}" 2>&1 | tee -a "$logfile"
+ zypper_args=( --non-interactive install --oldpackage --force-resolution )
+ if [ "${ALLOW_UNTRUSTED_RPMS:-0}" = "1" ]; then
+ zypper_args+=( --allow-unsigned-rpm )
+ fi
+ zypper "${zypper_args[@]}" "${rpm_files[@]}" 2>&1 | tee -a "$logfile"
fi
fi
mb2_args=( -t "$TARGET" )
-if [ "${IS_GECKO_BUILD:-0}" = "1" ]; then
+if [ "${IS_GECKO_BUILD:-0}" = "1" ] || [ "${NO_VCS_APPLY:-0}" = "1" ]; then
mb2_args+=( --no-vcs-apply )
fi
mb2_args+=( build )
@@ -1234,6 +1484,10 @@ PY
"-e",
f"LOCAL_RPM_DIRS={':'.join(local_rpm_mounts)}",
"-e",
+ f"NO_VCS_APPLY={'1' if no_vcs_apply else '0'}",
+ "-e",
+ f"ALLOW_UNTRUSTED_RPMS={'1' if allow_untrusted_rpms else '0'}",
+ "-e",
f"IS_GECKO_BUILD={'1' if is_gecko_build else '0'}",
image,
"bash",
@@ -1295,14 +1549,81 @@ def resolve_project_dir(project_dir: Path) -> Path:
)
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Build a SailfishOS project in place with Docker and mb2")
+def build_preflight_payload(
+ *,
+ project_dir: Path,
+ context: BuildContext,
+ builds: list[LocalSdkBuild | str],
+ artifacts_dir: Path,
+ local_rpms: dict[Path, list[Path]],
+ debug_build: bool,
+ clean: bool,
+ no_vcs_apply: bool,
+ allow_untrusted_rpms: bool,
+ pull_policy: str,
+ image_available: bool | None,
+) -> dict[str, object]:
+ planned_builds = [
+ {
+ "arch": build.arch if isinstance(build, LocalSdkBuild) else build,
+ "target": build.target if isinstance(build, LocalSdkBuild) else f"SailfishOS-{context.release}-{build}",
+ }
+ for build in builds
+ ]
+ permission_strategy = "local-sdk-user"
+ if context.backend == "docker":
+ permission_strategy = "scoped-acl" if shutil.which("setfacl") else "configured-fallback"
+ would_pull = bool(
+ context.backend == "docker"
+ and (pull_policy == "always" or (pull_policy == "missing" and image_available is False))
+ )
+ return {
+ "schema_version": 1,
+ "helper_version": HELPER_VERSION,
+ "project_dir": str(project_dir),
+ "backend": context.backend,
+ "release": context.release,
+ "image": context.image,
+ "image_available": image_available,
+ "local_sdk": context.local_sdk,
+ "builds": planned_builds,
+ "debug": debug_build,
+ "clean": clean,
+ "no_vcs_apply": no_vcs_apply,
+ "artifacts_dir": str(artifacts_dir),
+ "local_rpms": {
+ str(directory): [str(rpm) for rpm in rpms]
+ for directory, rpms in local_rpms.items()
+ },
+ "allow_untrusted_rpms": allow_untrusted_rpms,
+ "pull_policy": pull_policy,
+ "would_pull": would_pull,
+ "permission_strategy": permission_strategy,
+ "mutates_project": False,
+ }
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Build a SailfishOS project with Docker or an installed SDK")
+ parser.add_argument("--version", action="version", version=f"%(prog)s {HELPER_VERSION}")
parser.add_argument("--project-dir", default=".", help="Project root containing rpm/*.spec")
parser.add_argument("--release", help="SailfishOS release, for example 3.4.0.24")
parser.add_argument("--arch", action="append", default=[], help="Architecture to build, may be repeated")
+ parser.add_argument(
+ "--target",
+ action="append",
+ default=[],
+ help="Exact installed local SDK target to build; may be repeated",
+ )
parser.add_argument("--all", action="store_true", help="Build every architecture supported by the chosen SDK image")
parser.add_argument("--list-arches", action="store_true", help="Print supported architectures and exit")
parser.add_argument(
+ "--backend",
+ choices=("auto", "docker", "local"),
+ default="auto",
+ help="Build backend. Auto uses --local-sdk/--target when supplied, otherwise Docker",
+ )
+ parser.add_argument(
"--permission-fallback",
choices=("error", "chmod"),
default=DEFAULT_PERMISSION_FALLBACK,
@@ -1324,7 +1645,13 @@ def parse_args() -> argparse.Namespace:
default=[],
help="Directory of locally built RPMs to install into the SDK target before building; may be repeated",
)
- parser.add_argument("--no-pull", action="store_true", help="Skip docker pull before building")
+ parser.add_argument(
+ "--pull-policy",
+ choices=("always", "missing", "never"),
+ default="always",
+ help="When to pull the release Docker image",
+ )
+ parser.add_argument("--no-pull", action="store_true", help=argparse.SUPPRESS)
parser.add_argument(
"--local-sdk",
nargs="?",
@@ -1335,20 +1662,65 @@ def parse_args() -> argparse.Namespace:
"when no path is supplied."
),
)
- return parser.parse_args()
-
-
-def main() -> int:
- args = parse_args()
+ vcs_group = parser.add_mutually_exclusive_group()
+ vcs_group.add_argument(
+ "--no-vcs-apply",
+ dest="no_vcs_apply",
+ action="store_true",
+ default=None,
+ help="Tell mb2 not to apply VCS changes before building",
+ )
+ vcs_group.add_argument(
+ "--vcs-apply",
+ dest="no_vcs_apply",
+ action="store_false",
+ help="Allow mb2 to apply VCS changes (local SDK builds default to no VCS apply)",
+ )
+ parser.add_argument(
+ "--allow-untrusted-rpms",
+ action="store_true",
+ help="Allow unsigned RPMs supplied with --local-rpms-dir",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Validate inputs and print the build plan without pulling, cleaning, changing ACLs, or building",
+ )
+ parser.add_argument("--json", action="store_true", help="Print --dry-run output as JSON")
+ args = parser.parse_args(argv)
+ if args.json and not args.dry_run:
+ parser.error("--json requires --dry-run")
+ if args.target and args.backend == "docker":
+ parser.error("--target requires --backend local or auto")
+ if args.local_sdk and args.backend == "docker":
+ parser.error("--local-sdk cannot be combined with --backend docker")
+ return args
+
+
+def concise_error(error: BaseException) -> str:
+ if isinstance(error, subprocess.CalledProcessError):
+ command = error.cmd if isinstance(error.cmd, list) else [str(error.cmd)]
+ return f"Command exited with status {error.returncode}: {shlex.join(command)[:500]}"
+ return str(error) or error.__class__.__name__
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv)
require_tool("docker")
requested_project_dir = Path(args.project_dir).resolve()
project_dir = resolve_project_dir(requested_project_dir)
- local_sdk_path = Path(args.local_sdk).expanduser().resolve(strict=False) if args.local_sdk else None
+ if args.no_pull:
+ args.pull_policy = "never"
+
+ local_requested = args.backend == "local" or args.local_sdk is not None or bool(args.target)
+ local_sdk_value = args.local_sdk or (str(DEFAULT_LOCAL_SDK) if local_requested else None)
+ local_sdk_path = Path(local_sdk_value).expanduser().resolve(strict=False) if local_sdk_value else None
local_builds: list[LocalSdkBuild] | None = None
- if local_sdk_path:
+ if local_requested and args.backend != "docker":
+ assert local_sdk_path is not None
local_release = local_sdk_requested_release(args.release)
local_builds = select_local_sdk_builds(
local_sdk_path,
@@ -1356,11 +1728,21 @@ def main() -> int:
args.arch,
args.all or args.list_arches,
project_dir,
+ args.target,
)
if local_builds:
- release = local_release
- elif local_release == LIVE_RELEASE:
- raise SystemExit("Release 'live' requires a matching installed local SDK target.")
+ if local_release == LIVE_RELEASE:
+ installed_by_name = {target.target: target for target in list_local_sdk_targets(local_sdk_path)}
+ selected = installed_by_name.get(local_builds[0].target)
+ release = (selected.release or selected.version_id) if selected else LIVE_RELEASE
+ release = release or LIVE_RELEASE
+ else:
+ release = local_release
+ elif args.backend == "local" or args.target or local_release == LIVE_RELEASE:
+ available = ", ".join(target.target for target in list_local_sdk_targets(local_sdk_path)) or "none"
+ raise SystemExit(
+ f"No matching installed local SDK target for release {local_release}. Available targets: {available}"
+ )
else:
log(
f"No matching local SDK target for release {local_release}; "
@@ -1374,91 +1756,185 @@ def main() -> int:
use_local_sdk = local_sdk_path is not None and local_builds is not None
- if not use_local_sdk and not args.no_pull:
- pull_image(release)
+ image: str | None = None
+ image_available: bool | None = None
+ if use_local_sdk:
+ image = local_sdk_build_engine_image(host_user())
+ image_available = docker_image_exists(image)
+ if not image_available and not args.dry_run:
+ raise SystemExit(f"Local SDK wrapper image is not available: {image}")
+ else:
+ image = f"{CONTAINER_IMAGE}:{release}"
+ image_available = docker_image_exists(image)
- supported_arches = [] if use_local_sdk else list_supported_arches(release)
+ supported_arches: list[str] = []
+ if not use_local_sdk and not args.dry_run:
+ image, _ = ensure_image(release, args.pull_policy)
+ image_available = True
+ supported_arches = list_supported_arches(release)
+ elif not use_local_sdk and image_available:
+ supported_arches = list_supported_arches(release)
if args.list_arches:
if use_local_sdk:
print("\n".join(build.arch for build in local_builds))
return 0
+ if args.dry_run and not supported_arches:
+ raise SystemExit(f"Cannot list architectures because Docker image {image} is not available locally.")
print("\n".join(supported_arches))
return 0
if use_local_sdk:
builds: list[LocalSdkBuild | str] = local_builds
+ elif args.dry_run and not supported_arches:
+ if args.all:
+ builds = ["<all-supported-architectures>"]
+ else:
+ requested = args.arch or ([parse_last_arch(project_dir)] if parse_last_arch(project_dir) else [])
+ if not requested:
+ raise SystemExit("Pass --arch or make the Docker image available so targets can be discovered.")
+ builds = [arch for arch in requested if arch]
else:
builds = resolve_arches(args.arch, args.all, supported_arches, project_dir)
artifacts_dir = Path(args.artifacts_dir).resolve() if args.artifacts_dir else default_artifacts_dir(project_dir)
local_rpm_dirs = [Path(path).resolve() for path in args.local_rpms_dir]
-
- if not use_local_sdk:
- ensure_container_write_access(project_dir, args.permission_fallback)
-
- all_copied_rpms: list[Path] = []
- for build in builds:
- if isinstance(build, LocalSdkBuild):
- arch = build.arch
+ selected_local_rpms = validate_local_rpm_dirs(local_rpm_dirs)
+ no_vcs_apply = args.no_vcs_apply if args.no_vcs_apply is not None else use_local_sdk
+
+ context = BuildContext(
+ backend="local" if use_local_sdk else "docker",
+ release=release,
+ image=image,
+ image_id=docker_image_id(image) if image_available and image else None,
+ local_sdk=str(local_sdk_path) if use_local_sdk else None,
+ )
+ if args.dry_run:
+ payload = build_preflight_payload(
+ project_dir=project_dir,
+ context=context,
+ builds=builds,
+ artifacts_dir=artifacts_dir,
+ local_rpms=selected_local_rpms,
+ debug_build=args.debug,
+ clean=args.clean,
+ no_vcs_apply=no_vcs_apply,
+ allow_untrusted_rpms=args.allow_untrusted_rpms,
+ pull_policy=args.pull_policy,
+ image_available=image_available,
+ )
+ if args.json:
+ print(json.dumps(payload, indent=2, sort_keys=True))
else:
- arch = build
- previous_arch = parse_last_arch(project_dir)
- if previous_arch and previous_arch != arch:
- cleanup_in_place_artifacts(project_dir, previous_arch, arch)
- elif args.clean:
- cleanup_generated_artifacts(project_dir, "Explicit cleanup requested")
-
- try:
- if isinstance(build, LocalSdkBuild):
- build_local_sdk_arch(
- project_dir,
- local_sdk_path,
- release,
- arch,
- build.target,
- debug_build=args.debug,
- local_rpm_dirs=local_rpm_dirs,
- )
- else:
- build_arch(
- project_dir,
- release,
- arch,
- debug_build=args.debug,
- local_rpm_dirs=local_rpm_dirs,
- )
- write_target_marker(project_dir, arch)
-
- manifest_paths = generated_candidate_paths(project_dir)
- write_manifest(project_dir, manifest_paths)
+ print(f"Backend: {payload['backend']}")
+ print(f"Release: {payload['release']}")
+ print(f"Builds: {', '.join(item['target'] for item in payload['builds'])}")
+ print(f"Artifacts: {payload['artifacts_dir']}")
+ print(f"Would pull image: {'yes' if payload['would_pull'] else 'no'}")
+ return 0
- copied = copy_rpms(project_dir, release, arch, args.debug, artifacts_dir)
- verify_expected_rpms(copied, args.debug)
- write_build_metadata(
- project_dir,
- release=release,
- arch=arch,
- debug_build=args.debug,
- artifacts_dir=artifacts_dir,
- status="success",
- rpms=copied,
- )
- all_copied_rpms.extend(copied)
- log(
- f"Copied {len(copied)} RPM(s) for {arch} to "
- f"{variant_destination_dir(artifacts_dir, release, arch, args.debug)}"
- )
- except Exception:
+ started_at = datetime.now(timezone.utc)
+ all_copied_rpms: list[Path] = []
+ build_records: list[dict[str, object]] = []
+ rpm_context = stage_local_sdk_rpms(project_dir, selected_local_rpms) if use_local_sdk else nullcontext(local_rpm_dirs)
+ with project_build_lock(project_dir), rpm_context as effective_local_rpm_dirs:
+ if not use_local_sdk:
+ ensure_container_write_access(project_dir, args.permission_fallback)
+
+ for build in builds:
+ arch = build.arch if isinstance(build, LocalSdkBuild) else build
+ target = build.target if isinstance(build, LocalSdkBuild) else f"SailfishOS-{release}-{arch}"
+ record: dict[str, object] = {
+ "arch": arch,
+ "target": target,
+ "status": "running",
+ "rpms": [],
+ }
+ build_records.append(record)
write_build_metadata(
project_dir,
- release=release,
- arch=arch,
+ context=context,
+ builds=build_records,
debug_build=args.debug,
artifacts_dir=artifacts_dir,
- status="failed",
- rpms=[],
+ status="running",
+ started_at=started_at,
)
- raise
+ build_started = time.monotonic()
+ try:
+ previous_arch = parse_last_arch(project_dir)
+ if previous_arch and previous_arch != arch:
+ cleanup_in_place_artifacts(project_dir, previous_arch, arch)
+ elif args.clean:
+ cleanup_generated_artifacts(project_dir, "Explicit cleanup requested")
+
+ if isinstance(build, LocalSdkBuild):
+ assert local_sdk_path is not None
+ build_local_sdk_arch(
+ project_dir,
+ local_sdk_path,
+ release,
+ arch,
+ build.target,
+ debug_build=args.debug,
+ local_rpm_dirs=effective_local_rpm_dirs,
+ no_vcs_apply=no_vcs_apply,
+ allow_untrusted_rpms=args.allow_untrusted_rpms,
+ )
+ else:
+ build_arch(
+ project_dir,
+ release,
+ arch,
+ debug_build=args.debug,
+ local_rpm_dirs=effective_local_rpm_dirs,
+ no_vcs_apply=no_vcs_apply,
+ allow_untrusted_rpms=args.allow_untrusted_rpms,
+ )
+ write_target_marker(project_dir, arch)
+ write_manifest(project_dir, generated_candidate_paths(project_dir))
+ copied = copy_rpms(project_dir, release, arch, args.debug, artifacts_dir)
+ verify_expected_rpms(copied, args.debug)
+ record.update(
+ status="success",
+ duration_seconds=round(time.monotonic() - build_started, 3),
+ rpms=copied,
+ )
+ all_copied_rpms.extend(copied)
+ log(
+ f"Copied {len(copied)} RPM(s) for {arch} to "
+ f"{variant_destination_dir(artifacts_dir, release, arch, args.debug)}"
+ )
+ except BaseException as error:
+ failure_class = classify_failure(error, build_log_path(project_dir))
+ message = concise_error(error)
+ record.update(
+ status="failed",
+ duration_seconds=round(time.monotonic() - build_started, 3),
+ failure_class=failure_class,
+ failure_message=message,
+ )
+ write_build_metadata(
+ project_dir,
+ context=context,
+ builds=build_records,
+ debug_build=args.debug,
+ artifacts_dir=artifacts_dir,
+ status="failed",
+ started_at=started_at,
+ failure_class=failure_class,
+ failure_message=message,
+ )
+ raise
+
+ write_build_metadata(
+ project_dir,
+ context=context,
+ builds=build_records,
+ debug_build=args.debug,
+ artifacts_dir=artifacts_dir,
+ status="success",
+ started_at=started_at,
+ )
print("Built RPMs:")
for rpm in all_copied_rpms:
@@ -1467,4 +1943,11 @@ def main() -> int:
if __name__ == "__main__":
- sys.exit(main())
+ try:
+ sys.exit(main())
+ except subprocess.CalledProcessError as error:
+ log(f"Build failed: {concise_error(error)}")
+ sys.exit(error.returncode or 1)
+ except OSError as error:
+ log(f"Build failed: {concise_error(error)}")
+ sys.exit(1)