#!/usr/bin/env python3 """Compute which packages to build based on git changes and tier ordering. Outputs a JSON object describing the build plan: {"tiers": [{"tier": 1, "packages": ["pkg-a", "pkg-b"]}, ...]} Usage: ci/build-plan.py [BASE_SHA] [HEAD_SHA] If no SHAs are given, outputs all packages (full rebuild). If BASE_SHA is given, only packages that changed OR transitively depend on a changed package are included. """ import glob import json import os import re import subprocess import sys from collections import defaultdict REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ORDER_FILE = os.path.join(REPO, "build-order.txt") def parse_build_order(): """Return list of (tier_number, [packages]) from build-order.txt.""" tiers = [] current_tier = None current_pkgs = [] with open(ORDER_FILE) as f: for line in f: line = line.strip() m = re.match(r"^\[tier(\d+)\]$", line) if m: if current_tier is not None: tiers.append((current_tier, current_pkgs)) current_tier = int(m.group(1)) current_pkgs = [] elif line and not line.startswith("#"): current_pkgs.append(line) if current_tier is not None: tiers.append((current_tier, current_pkgs)) return tiers def build_dependency_graph(): """Return forward-dependency graph: pkg -> set of pkgs it depends on.""" specs = sorted(glob.glob(os.path.join(REPO, "*", "*.spec"))) specs = [s for s in specs if os.path.basename(s)[:-5] == os.path.basename(os.path.dirname(s))] provides = {} brs = defaultdict(set) for spec in specs: pkg = os.path.basename(spec)[:-5] out = subprocess.run( ["rpmspec", "-P", spec], capture_output=True, text=True ).stdout if not out: out = open(spec, errors="replace").read() names = {pkg} in_files = False for line in out.splitlines(): s = line.strip() m = re.match(r"%package\s+(?:-n\s+(\S+)|(\S+))", s) if m: names.add(m.group(1) or f"{pkg}-{m.group(2)}") continue if s.startswith("%files"): in_files = True elif s.startswith("%") and re.match( r"%(prep|build|install|conf|check|changelog|description|post|pre)", s ): in_files = s.startswith("%files") m = re.match(r"(Provides|BuildRequires)\s*:\s*(.+)", s) if m: for dep in re.split(r"\s+(?=[A-Za-z_%(/])", m.group(2).strip()): cap = dep.split()[0].replace("%{?_isa}", "").strip() if not cap or cap.startswith(("%", "(")): continue if m.group(1) == "Provides": names.add(cap) else: brs[pkg].add(cap) if in_files: for cm in re.findall(r"/cmake/([A-Za-z0-9_.+-]+)/?", s): names.add(f"cmake({cm})") for pc in re.findall(r"/([A-Za-z0-9_.+-]+)\.pc\b", s): names.add(f"pkgconfig({pc})") for n in names: provides.setdefault(n, pkg) forward = defaultdict(set) for pkg, caps in brs.items(): for cap in caps: owner = provides.get(cap) if owner and owner != pkg: forward[pkg].add(owner) return forward def compute_affected(changed_pkgs, forward_graph): """Given changed packages, compute all packages that need rebuilding.""" reverse = defaultdict(set) for pkg, deps in forward_graph.items(): for dep in deps: reverse[dep].add(pkg) affected = set(changed_pkgs) queue = list(changed_pkgs) while queue: pkg = queue.pop(0) for dependent in reverse.get(pkg, ()): if dependent not in affected: affected.add(dependent) queue.append(dependent) return affected def main(): tiers = parse_build_order() all_pkgs = [pkg for _, pkgs in tiers for pkg in pkgs] if len(sys.argv) >= 3: base, head = sys.argv[1], sys.argv[2] changed_files = subprocess.run( ["git", "diff", "--name-only", base, head], capture_output=True, text=True, cwd=REPO, ).stdout.split() changed_pkgs = { os.path.basename(f).replace(".spec", "") for f in changed_files if f.endswith(".spec") } for f in changed_files: if "/" in f and not f.endswith(".spec"): pkg = f.split("/")[0] if pkg in all_pkgs: changed_pkgs.add(pkg) else: changed_pkgs = set(all_pkgs) if not changed_pkgs: print(json.dumps({"tiers": [], "changed": [], "total": 0})) return forward = build_dependency_graph() affected = compute_affected(changed_pkgs, forward) plan = [] for tier_num, pkgs in tiers: tier_affected = [p for p in pkgs if p in affected] if tier_affected: plan.append({"tier": tier_num, "packages": sorted(tier_affected)}) result = { "tiers": plan, "changed": sorted(changed_pkgs), "total": sum(len(t["packages"]) for t in plan), } print(json.dumps(result)) if __name__ == "__main__": main()