#!/usr/bin/python3 # -*- coding: utf-8 -*- """ DEB package builder for Ubuntu 26.04 Migrated from make_rpm.py (openSUSE) to Ubuntu/DEB ecosystem """ import argparse import configparser import getpass import os import re import shutil import stat import subprocess import sys from datetime import datetime from pathlib import Path def verbose(): verb = os.getenv("EMERGE_VERBOSE") if verb is not None and verb.isdigit() and int(verb) > 0: return int(verb) return 0 def debug(message): if verbose() >= 1: print(f"debug: {message}") return True def info(message): if verbose() >= 0: print(f"info: {message}") return True def warning(message): print(f"warning: {message}") return True def error(message): die(message) def die(message): print(f"emerge fatal error: {message}", file=sys.stderr) sys.exit(1) def system(cmdstring, file=None): debug(f"Executing '{cmdstring}'") stdout = subprocess.PIPE if file is not None: stdout = file p = subprocess.Popen(cmdstring, shell=True, stdout=stdout, stderr=sys.stderr) ret = p.wait() if ret != 0: die(f"Error executing {cmdstring} ({ret})") ret = "" if file is None: ret = p.stdout.read().strip() return ret def get_os_name(): """Return Ubuntu version name""" if os.path.exists("/etc/os-release"): with open("/etc/os-release", "r") as f: for line in f.readlines(): if line.startswith("PRETTY_NAME="): name = line.split("=")[1].strip().strip('"') return name.replace(" ", "_") return "Ubuntu_unknown" def remove_directory(dest_dir): info("--------------------------------") info(f"Removing {dest_dir}") info("--------------------------------") if os.path.exists(dest_dir) and os.path.isdir(dest_dir): shutil.rmtree(dest_dir) def copy_src_dir_to_dest_dir(srcdir, dstdir, perm=None, with_subdirs=True, release_scope=False): """Copy source directory to destination directory. Args: srcdir: Source directory path dstdir: Destination directory path perm: Optional permissions to set with_subdirs: Whether to copy subdirectories release_scope: If True, only copy release scope files (TetraIF, Z/src, Lts/Common) """ info("--------------------------------") info(f"Copying {srcdir} to {dstdir}") if release_scope: info("Release scope mode: only copying TetraIF, Z/src, Lts/Common") info("--------------------------------") while srcdir.endswith("/"): srcdir = srcdir[:-1] # Release scope directories and files to include release_items = [ 'TetraIF', 'Z/src', 'Lts/Common', 'Lts/Proxies/TrainingAreaBase', # Required for training_area_telegrams.h used by TetraIF 'Lts/Services/ProjectDataManagement/stubs', # Stub headers for pdm.hpp (needed by TrainingAreaBase) 'CMakeLists.txt', 'cmake', 'tests', 'new_docker_build.sh', 'specs', 'translations', 'CTestCustom.ctest', '.gitignore', '.envrc', 'doxygen', 'external', 'src', 'lts-cc.conf' ] # Directories to exclude from include folder (bundled libraries not needed for release) include_exclude = ['GeographicLib'] if with_subdirs: for root, dirs, files in os.walk(srcdir): # Filter directories for release scope if release_scope: rel_root = os.path.relpath(root, srcdir) if rel_root == '.': # At root level, only keep release scope directories # Need to handle multi-level paths like 'Lts/Proxies/TrainingAreaBase' def should_keep_dir(d): # Check if directory is directly in release_items if d in release_items: return True # Check if directory is the first part of a multi-level release item for item in release_items: if '/' in item: first_part = item.split('/')[0] if d == first_part: return True return False kept_dirs = [d for d in dirs if should_keep_dir(d)] info(f"Root level: keeping {kept_dirs} from {dirs}") dirs[:] = kept_dirs elif rel_root == 'Z': # In Z directory, only keep src dirs[:] = [d for d in dirs if d == 'src'] elif rel_root == 'Lts': # In Lts directory, only keep Common, Proxies (for TrainingAreaBase), and Services (for ProjectDataManagement stubs) kept_dirs = [d for d in dirs if d in ('Common', 'Proxies', 'Services')] info(f"Lts level: keeping {kept_dirs} from {dirs}") dirs[:] = kept_dirs elif rel_root == 'Lts/Proxies': # In Lts/Proxies, only keep TrainingAreaBase kept_dirs = [d for d in dirs if d == 'TrainingAreaBase'] info(f"Lts/Proxies level: keeping {kept_dirs} from {dirs}") dirs[:] = kept_dirs elif rel_root.startswith('Lts/Proxies/TrainingAreaBase'): # Keep all subdirectories under TrainingAreaBase (DDS code wrapped in conditionals) pass elif rel_root == 'Lts/Services': # In Lts/Services, only keep ProjectDataManagement (for stub headers) kept_dirs = [d for d in dirs if d == 'ProjectDataManagement'] info(f"Lts/Services level: keeping {kept_dirs} from {dirs}") dirs[:] = kept_dirs elif rel_root == 'Lts/Services/ProjectDataManagement': # In ProjectDataManagement, only keep stubs kept_dirs = [d for d in dirs if d == 'stubs'] info(f"Lts/Services/ProjectDataManagement level: keeping {kept_dirs} from {dirs}") dirs[:] = kept_dirs elif rel_root.startswith('Lts/Services/ProjectDataManagement/stubs'): # Keep all subdirectories under stubs (contains ProjectDataManagement/pdm.hpp) pass elif rel_root.startswith('Lts/Common'): # Keep all subdirectories under Lts/Common except LtsUtilsGuiDds (requires DDS) if rel_root == 'Lts/Common/LtsUtils/LtsUtilsGui': dirs[:] = [d for d in dirs if d != 'LtsUtilsGuiDds'] else: pass elif rel_root.startswith('Z/src'): # Keep all subdirectories under Z/src pass elif rel_root.startswith('TetraIF'): # Keep all subdirectories under TetraIF pass elif rel_root == 'include': # In include directory, exclude bundled libraries dirs[:] = [d for d in dirs if d not in include_exclude] else: # Skip other directories dirs[:] = [] tmpdir = root.replace(srcdir, dstdir) debug(f"dstdir = '{tmpdir}'") if not os.path.exists(tmpdir): debug(f"Creating dir '{tmpdir}'") os.makedirs(tmpdir) if perm: os.chmod(tmpdir, perm | stat.S_IREAD | stat.S_IRGRP | stat.S_IROTH) for file in files: # Skip non-release files at root level if release_scope and rel_root == '.': if file not in release_items and not any(file == item.split('/')[0] for item in release_items if '/' in item): continue # Include 'include' directory but GeographicLib will be filtered during walk src_path = os.path.join(root, file) # Skip broken symlinks if os.path.islink(src_path) and not os.path.exists(src_path): debug(f"Skipping broken symlink: {src_path}") continue if os.path.isfile(tmpdir) and os.path.exists(tmpdir): os.remove(tmpdir) shutil.copy(src_path, tmpdir, follow_symlinks=True) if perm: f = os.path.join(tmpdir, file) add_perm = 0 if os.path.isdir(f): add_perm = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH else: mode = os.stat(f).st_mode if (stat.S_IXUSR & mode) == stat.S_IXUSR: add_perm = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH os.chmod(f, perm | add_perm) else: for f in os.listdir(srcdir): if os.path.isdir(f): continue if release_scope and f not in release_items: continue shutil.copy(os.path.join(srcdir, f), os.path.join(dstdir, f)) def create_debian_control(deb_dir, project_name, version, build_number, cmake_flags): """Create debian/control file for DEB package""" control_content = f"""Source: legatus-{project_name.lower()} Section: devel Priority: optional Maintainer: Legatus Team Build-Depends: debhelper (>= 13), cmake, ninja-build, qt6-base-dev, qt6-tools-dev, libboost-all-dev, libjson-glib-dev, libgeographiclib-dev Package: legatus-{project_name.lower()} Architecture: any Depends: ${{shlibs:Depends}}, ${{misc:Depends}} Description: Legatus {project_name} LTS package Legatus {project_name} component built with Qt6/C++23 Version: {version}-{build_number} Build flags: {cmake_flags} """ control_file = os.path.join(deb_dir, "control") with open(control_file, "w") as f: f.write(control_content) info(f"Created {control_file}") def create_debian_changelog(deb_dir, project_name, version, build_number): """Create debian/changelog file""" timestamp = datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000") changelog_content = f"""legatus-{project_name.lower()} ({version}-{build_number}) unstable; urgency=medium * Automated build for Ubuntu 26.04 * Qt6/C++23 migration -- Legatus Team {timestamp} """ changelog_file = os.path.join(deb_dir, "changelog") with open(changelog_file, "w") as f: f.write(changelog_content) info(f"Created {changelog_file}") def create_debian_rules(deb_dir, cmake_flags="", release_scope=False): """Create debian/rules file Args: deb_dir: debian directory path cmake_flags: Additional CMake flags to pass release_scope: If True, skip tests for release build """ # Build CMake flags string cmake_opts = "-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr" if cmake_flags: cmake_opts = f"{cmake_opts} {cmake_flags}" # For release builds, skip tests to avoid test infrastructure issues test_override = "" if release_scope: test_override = """ override_dh_auto_test: # Skip tests for release build - tests run separately in CI @echo "Skipping tests for release build" """ rules_content = f"""#!/usr/bin/make -f %: dh $@ --buildsystem=cmake --builddirectory=build override_dh_clean: dh_clean rm -rf build override_dh_auto_configure: dh_auto_configure -- {cmake_opts} override_dh_auto_build: dh_auto_build --parallel override_dh_auto_install: dh_auto_install {test_override}""" rules_file = os.path.join(deb_dir, "rules") with open(rules_file, "w") as f: f.write(rules_content) os.chmod(rules_file, stat.S_IREAD | stat.S_IWRITE | stat.S_IXUSR | stat.S_IRGRP | stat.S_IROTH | stat.S_IXGRP | stat.S_IXOTH) info(f"Created {rules_file}") def build_deb(source_dir, output_dir, project_name, version, build_number, cmake_flags, release_scope=False): """Build DEB package using debuild Args: source_dir: Source directory path output_dir: Output directory for packages project_name: Project name (qatar|ahtc|guez) version: Version string build_number: Build number cmake_flags: CMake build flags release_scope: If True, only include release scope files (TetraIF, Z/src, Lts/Common) """ info("--------------------------------") info(f"Building DEB package for {project_name} {version}-{build_number}") if release_scope: info("Release scope mode: only packaging TetraIF, Z/src, Lts/Common") info("--------------------------------") # Create output directory os.makedirs(output_dir, exist_ok=True) # Create temporary build directory build_base = os.path.join(source_dir, "deb_build_tmp") remove_directory(build_base) os.makedirs(build_base) # Copy source to build directory pkg_name = f"legatus-{project_name.lower()}" pkg_dir = os.path.join(build_base, f"{pkg_name}-{version}") copy_src_dir_to_dest_dir(source_dir, pkg_dir, release_scope=release_scope) # Create debian directory deb_dir = os.path.join(pkg_dir, "debian") os.makedirs(deb_dir) # Create debian control files create_debian_control(deb_dir, project_name, version, build_number, cmake_flags) create_debian_changelog(deb_dir, project_name, version, build_number) # Add RELEASE_BUILD=ON for release scope builds deb_cmake_flags = cmake_flags if release_scope: if deb_cmake_flags: deb_cmake_flags = f"{deb_cmake_flags} -DRELEASE_BUILD=ON" else: deb_cmake_flags = "-DRELEASE_BUILD=ON" create_debian_rules(deb_dir, deb_cmake_flags, release_scope) # Create debian/source/format source_dir_path = os.path.join(deb_dir, "source") os.makedirs(source_dir_path) with open(os.path.join(source_dir_path, "format"), "w") as f: f.write("3.0 (quilt)\n") # Create debian/compat (for older debhelper compatibility) with open(os.path.join(deb_dir, "compat"), "w") as f: f.write("13\n") # Build the package info("Running debuild...") os.chdir(pkg_dir) # Set environment for debuild env = os.environ.copy() env["DEB_BUILD_OPTIONS"] = "parallel=$(nproc)" cmd = f"debuild -us -uc -b" debug(f"Executing: {cmd}") ret = subprocess.run(cmd, shell=True, env=env) if ret.returncode != 0: die(f"debuild failed with code {ret.returncode}") # Move generated packages to output directory parent_dir = os.path.dirname(pkg_dir) for f in os.listdir(parent_dir): if f.endswith(".deb") or f.endswith(".dsc") or f.endswith(".changes") or f.endswith(".buildinfo"): src = os.path.join(parent_dir, f) dst = os.path.join(output_dir, f) if os.path.exists(dst): os.remove(dst) shutil.move(src, output_dir) info(f"Moved {f} to {output_dir}") # Cleanup os.chdir(source_dir) remove_directory(build_base) info(f"DEB package build completed: {output_dir}") def main(): parser = argparse.ArgumentParser(description="Build DEB packages for Legatus LTS") parser.add_argument("-c", "--config", required=True, help="Config type (LTS)") parser.add_argument("--project_name", required=True, help="Project name (qatar|ahtc|guez)") parser.add_argument("--version", required=True, help="Version (Major.minor)") parser.add_argument("--output_dir", required=True, help="Output directory for packages") parser.add_argument("--source_dir", required=True, help="Source directory path") parser.add_argument("--cmake_build_flags", default="", help="CMake build flags") parser.add_argument("--build_number", default="1", help="Build number") parser.add_argument("--release_scope", action="store_true", help="Only include release scope files (TetraIF, Z/src, Lts/Common)") args = parser.parse_args() info(f"Building {args.config} package for {args.project_name} v{args.version}") info(f"Output directory: {args.output_dir}") info(f"Source directory: {args.source_dir}") info(f"CMake flags: {args.cmake_build_flags}") info(f"Release scope: {args.release_scope}") # Use provided source directory source_dir = args.source_dir info(f"Source directory: {source_dir}") build_deb( source_dir=source_dir, output_dir=args.output_dir, project_name=args.project_name, version=args.version, build_number=args.build_number, cmake_flags=args.cmake_build_flags, release_scope=args.release_scope ) info("Build completed successfully!") if __name__ == "__main__": main()