167 lines
5.4 KiB
Python
Executable file
167 lines
5.4 KiB
Python
Executable file
#!/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()
|