#!/usr/bin/env python3 import subprocess import io import os import sys import yaml import argparse import logging from datetime import datetime import tarfile import shutil from git import Repo, GitCommandError import tempfile from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED import fnmatch import re from debian.copyright import Header, Copyright import uuid from common import clean_old_logs BASE_DIR = "/srv/lubuntu-ci/repos" DEBFULLNAME = "Lugito" DEBEMAIL = "info@lubuntu.me" OUTPUT_DIR = os.path.join(BASE_DIR, "build_output") SUPPRESSED_LINTIAN_TAGS = [ "orig-tarball-missing-upstream-signature", "package-has-long-file-name", "adopted-extended-field" ] BASE_OUTPUT_DIR = "/srv/lubuntu-ci/output" LOG_DIR = os.path.join(BASE_OUTPUT_DIR, "logs", "source_builds") BASE_LINTIAN_DIR = os.path.join(BASE_OUTPUT_DIR, f"lintian.tmp.{str(uuid.uuid4())[:8]}") REAL_LINTIAN_DIR = os.path.join(BASE_OUTPUT_DIR, "lintian") os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(OUTPUT_DIR, exist_ok=True) os.makedirs(BASE_LINTIAN_DIR, exist_ok=True) current_time = datetime.utcnow().strftime("%H-%M-%S") log_file = os.path.join(LOG_DIR, f"{current_time}.log") logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", handlers=[ logging.FileHandler(log_file), logging.StreamHandler() ] ) logger = logging.getLogger("TimeBasedLogger") def run_command(cmd, cwd=None, env=None, show_output=False): logging.info(f"Executing: {' '.join(cmd)} in {cwd or 'current directory'}") try: result = subprocess.run( cmd, cwd=cwd, env=env, check=True, capture_output=True, text=True ) if show_output: if result.stdout: logging.info(f"Output: {result.stdout.strip()}") if result.stderr: logging.warning(f"Output: {result.stderr.strip()}") logging.info(f"Command succeeded: {' '.join(cmd)}") except subprocess.CalledProcessError as e: logging.error(f"Command failed: {' '.join(cmd)}") logging.error(e.stderr) raise def parse_version(changelog_path): try: with open(changelog_path, "r") as f: first_line = f.readline().strip() version_match = first_line.split("(")[1].split(")")[0] # Remove Debian revision upstream_version = version_match.split("-")[0] # Remove '+git...' and '~release' if present upstream_version = re.sub(r'(\+git[0-9]+)?(~[a-z]+)?$', '', upstream_version) logging.info(f"Upstream version extracted: {upstream_version}") current_date = datetime.now().strftime("%Y%m%d%H%M") version = f"{upstream_version}+git{current_date}" logging.info(f"Parsed VERSION: {version}") return version except (IndexError, FileNotFoundError) as e: logging.error(f"Error parsing version from {changelog_path}: {e}") raise def get_exclusions(packaging): exclusions = [] with io.open(os.path.join(packaging, "debian/copyright"), "rt", encoding="utf-8") as f: copyright_obj = Copyright(f) for paragraph in copyright_obj.all_paragraphs(): if isinstance(paragraph, Header): if paragraph.files_excluded: for file_name in paragraph.files_excluded: exclusions.append(file_name) break return exclusions def create_tarball(name, source_dir, exclusions=[]): tar_filename = f"{name}_MAIN.orig.tar.gz" logging.info(f"Creating tarball: {tar_filename}") exclusions.append(".git/") def exclusion_func(tarinfo): for exclusion in exclusions: if exclusion in tarinfo.name: return None return tarinfo with tarfile.open(tar_filename, "w:gz") as tar: tar.add(source_dir, arcname=os.path.basename(source_dir), filter=exclusion_func) logging.info(f"Tarball created and compressed: {tar_filename}") def update_changelog(packaging_dir, release, version, env): name = os.path.basename(packaging_dir) logging.info(f"Updating changelog for {name} to version {version}-0ubuntu1~ppa1") run_command(["git", "checkout", "debian/changelog"], cwd=packaging_dir) cmd = [ "dch", "--distribution", release, "--package", name, "--newversion", f"{version}-0ubuntu1~ppa1", "--urgency", "low", "CI upload." ] run_command(cmd, cwd=packaging_dir, env=env) def build_package(packaging_dir, env): name = os.path.basename(packaging_dir) logging.info(f"Building source package for {name}") temp_dir = tempfile.mkdtemp() try: temp_packaging_dir = os.path.join(temp_dir, name) os.makedirs(temp_packaging_dir, exist_ok=True) shutil.copytree(packaging_dir + "/debian", temp_packaging_dir + "/debian") tarball_name = f"{name}_{env['VERSION']}.orig.tar.gz" tarball_source = os.path.join(BASE_DIR, tarball_name) tarball_dest = os.path.join(temp_dir, tarball_name) shutil.copyfile(tarball_source, tarball_dest) cmd_build = ["debuild", "--no-lintian", "-S", "-d", "-sa"] run_command(cmd_build, cwd=temp_packaging_dir, env=env) run_command(["git", "checkout", "debian/changelog"], cwd=packaging_dir) pattern = f"{name}_{env['VERSION']}*" for filename in os.listdir(temp_dir): if fnmatch.fnmatch(filename, pattern): source_file = os.path.join(temp_dir, filename) dest_file = os.path.join(OUTPUT_DIR, filename) shutil.copyfile(source_file, dest_file) logging.info(f"Copied {filename} to {OUTPUT_DIR}") changes_files = [f for f in os.listdir(OUTPUT_DIR) if f.startswith(f"{name}_{env['VERSION']}") and f.endswith("_source.changes")] if changes_files: changes_file = os.path.join(OUTPUT_DIR, changes_files[-1]) logging.info(f"Built package, changes file: {changes_file}") return changes_file else: logging.error("No changes file found after build.") raise FileNotFoundError("Changes file not found.") finally: shutil.rmtree(temp_dir) def load_config(config_path): try: with open(config_path, "r") as f: config = yaml.safe_load(f) if "packages" not in config or "releases" not in config: raise ValueError("Config file must contain 'packages' and 'releases' sections.") return config except Exception as e: logging.error(f"Error loading config file: {e}") sys.exit(1) def clone_or_update_repo(destination, repo_url, repo_branch=None): if os.path.exists(destination): logging.info(f"Repository already exists at {destination}, checking branch and remote URL.") try: repo = Repo(destination) current_remote_url = repo.remotes.origin.url if current_remote_url != repo_url: logging.info(f"Remote URL differs for {destination}. Removing and recloning.") shutil.rmtree(destination) else: repo.git.reset("--hard", "HEAD") current_branch = repo.active_branch.name if repo_branch and current_branch != repo_branch: logging.info(f"Branch differs for {destination}. Removing and recloning.") shutil.rmtree(destination) else: logging.info(f"Repository matches desired remote and branch, pulling updates.") repo.git.checkout(repo_branch or current_branch) try: repo.remotes.origin.pull() repo.submodule_update(recursive=True) logging.info(f"Pulled latest changes for {destination}") except GitCommandError as e: if 'non-fast-forward' in str(e): logging.error(f"Pull failed due to non-fast-forward update: {e}") logging.info(f"Removing repository {destination} and cloning again.") shutil.rmtree(destination) else: logging.error(f"Pull failed for {destination}: {e}") raise else: return except Exception as e: logging.error(f"Error updating repository {destination}: {e}") logging.info(f"Removing repository {destination} and cloning again.") shutil.rmtree(destination) try: logging.info(f"Cloning repository {repo_url} into {destination}") repo = Repo.clone_from(repo_url, destination, recurse_submodules=True) if repo_branch: repo.git.checkout(repo_branch) logging.info(f"Checked out {repo_branch} in {destination}") except GitCommandError as e: logging.error(f"Git clone failed for {repo_url}: {e}") raise def publish_lintian(): if os.path.exists(BASE_LINTIAN_DIR): for root, dirs, files in os.walk(BASE_LINTIAN_DIR): for file in files: # Determine the source and destination paths src_path = os.path.join(root, file) rel_path = os.path.relpath(src_path, BASE_LINTIAN_DIR) dest_path = os.path.join(REAL_LINTIAN_DIR, rel_path) # Ensure the destination directory exists os.makedirs(os.path.dirname(dest_path), exist_ok=True) # Copy the file shutil.copy2(src_path, dest_path) # Remove the temporary directory shutil.rmtree(BASE_LINTIAN_DIR) def run_source_lintian(name, sources_path): logging.info(f"Running Lintian for {name}") with tempfile.NamedTemporaryFile(mode='w+', suffix='.txt') as temp_file: temp_file.write("\n".join(SUPPRESSED_LINTIAN_TAGS)) temp_file.flush() temp_file_path = temp_file.name cmd = [ "lintian", "-EvIL", "+pedantic", "--suppress-tags-from-file", f"{temp_file_path}", sources_path ] result = subprocess.run( cmd, capture_output=True, text=True ) stderr, stdout = None, None if result.stderr: stderr = result.stderr.strip() if result.stdout: stdout = result.stdout.strip() lintian_output = None if stderr == stdout: lintian_output = stderr else: lintian_output = f"{stderr}\n{stdout}".strip() if lintian_output: pkgdir = os.path.join(BASE_LINTIAN_DIR, name) if not os.path.exists(pkgdir): os.mkdir(pkgdir) output_file = os.path.join(pkgdir, "source.txt") with open(output_file, "a") as f: f.write(lintian_output) logging.info(f"Lintian run for {name} is complete") def main(): parser = argparse.ArgumentParser(description="Automate Lubuntu package builds.") parser.add_argument("config", help="Path to the YAML configuration file.") parser.add_argument("--skip-dput", action="store_true", help="Skip the dput upload step.") parser.add_argument("--skip-cleanup", action="store_true", help="Skip removal of build_output.") args = parser.parse_args() config = load_config(args.config) packages = config["packages"] releases = config["releases"] os.makedirs(BASE_DIR, exist_ok=True) logging.info(f"Using base directory: {BASE_DIR}") os.chdir(BASE_DIR) with ThreadPoolExecutor(max_workers=5) as executor: def dput_source(name, upload_target, changes_files, devel_changes_files): if changes_files: hr_changes = ", ".join(changes_files) logging.info(f"Uploading {hr_changes} to {upload_target} using dput") cmd_upload = ["dput", upload_target] + changes_files run_command(cmd_upload, cwd=OUTPUT_DIR) logging.info(f"Completed upload of {hr_changes} to {upload_target}") for file in devel_changes_files: if file: futures.add(executor.submit(run_source_lintian, name, file)) def prepare_package(pkg): name = pkg.get("name") if not name: logging.warning(f"Skipping package due to missing name: {pkg}") return upstream_url = pkg.get("upstream_url") or f"https://github.com/lxqt/{name}.git" upstream_destination = os.path.join(BASE_DIR, f"upstream-{name}") clone_or_update_repo(upstream_destination, upstream_url) packaging_url = pkg.get("packaging_url") or f"https://git.lubuntu.me/Lubuntu/{name}-packaging.git" packaging_branch = pkg.get("packaging_branch") or f"ubuntu/{releases[0]}" if releases else None packaging_destination = os.path.join(BASE_DIR, name) clone_or_update_repo(packaging_destination, packaging_url, packaging_branch) exclusions = get_exclusions(packaging_destination) create_tarball(name, upstream_destination, exclusions) run_command(["update-maintainer"], cwd=packaging_destination) futures.add(executor.submit(process_package, pkg)) def process_package(pkg): name = pkg.get("name") upload_target = pkg.get("upload_target", "ppa:lubuntu-ci/unstable-ci-proposed") if not name: logging.warning(f"Skipping package due to missing name: {pkg}") return [] package_changes = [] packaging_destination = os.path.join(BASE_DIR, name) changelog_path = os.path.join(packaging_destination, "debian", "changelog") version = parse_version(changelog_path) for release in releases: logging.info(f"Building {name} for {release}") try: release_version = f"{version}~{release}" tarball_name = f"{name}_{release_version}.orig.tar.gz" tarball_source = os.path.join(BASE_DIR, f"{name}_MAIN.orig.tar.gz") tarball_dest = os.path.join(BASE_DIR, tarball_name) shutil.copyfile(tarball_source, tarball_dest) env = os.environ.copy() env["DEBFULLNAME"] = DEBFULLNAME env["DEBEMAIL"] = DEBEMAIL env["VERSION"] = release_version env["UPLOAD_TARGET"] = upload_target # Update changelog and build package update_changelog(packaging_destination, release, release_version, env) changes_file = build_package(packaging_destination, env) if changes_file: package_changes.append((changes_file, env)) os.remove(os.path.join(BASE_DIR, tarball_name)) except Exception as e: logging.error(f"Error processing package '{name}' for release '{release}': {e}") changes_files = [os.path.basename(cf) for cf, env in package_changes] devel_changes_files = set(os.path.join(OUTPUT_DIR, file) if releases[0] in file else None for file in changes_files) if args.skip_dput: for changes_file in devel_changes_files: if changes_file: futures.add(executor.submit(run_source_lintian, name, changes_file)) else: upload_target = package_changes[0][1]["UPLOAD_TARGET"] futures.add(executor.submit(dput_source, name, upload_target, changes_files, devel_changes_files)) os.remove(os.path.join(BASE_DIR, f"{name}_MAIN.orig.tar.gz")) futures = set(executor.submit(prepare_package, pkg) for pkg in packages) while futures: done, not_done = wait(futures, return_when=FIRST_COMPLETED) for future in done: try: result = future.result() except Exception as e: logging.exception("Task generated an exception") finally: futures.remove(future) if not args.skip_cleanup: shutil.rmtree(OUTPUT_DIR) logging.info("Publishing Lintian output...") publish_lintian() clean_old_logs(LOG_DIR) logging.info("Script completed successfully.") if __name__ == "__main__": main()