summaryrefslogtreecommitdiff
path: root/src/sailfish_devel_mcp/vendor
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
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')
-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)