#!/usr/bin/env python3 """Pick the build matrix for a CI run from the set of files a PR touches. Until now this repo had no pull_request CI at all: a change landed unbuilt and the nightly found out about it the next morning, or a user did. Building all 107 devices on every PR to fix that would cost ~50 runner-hours a go, which is why it was never turned on. Almost every change here is one device -- 639 of the file touches in the last 200 commits sit under a single devices// -- so narrowing first is what makes PR CI affordable at all. The rule is deliberately one-directional: a path has to be *recognised* as belonging to particular devices before it can narrow anything. The shared packages, builder.sh, and anything this script has never heard of all widen back out to the full matrix. Getting the classification wrong therefore costs runner time, never coverage. The device -> files mapping is not written down here, it is read off the tree the same way builder.sh reads it. builder.sh line 121 does ITEM=$(find devices -name ${DEVICE}_defconfig | cut -d/ -f1,2) cp -afv ${BUILDER_DIR}/${ITEM}/* ${FIRMWARE_DIR} -- it locates the device by its defconfig and copies that WHOLE devices// tree over the firmware clone. So the devices a file affects are exactly the devices whose defconfig lives in the same directory. That matters: devices/common/ holds 18 targets and devices/apfpv/ holds 2, and treating either as one device would skip 17 real builds. THE NIGHTLY IS NOT NARROWED, and must not be. builder.sh re-clones OpenIPC/firmware at HEAD on every run, so what a nightly builds is mostly decided outside this repo. master.yml already carries the scar: an earlier gate skipped the nightly when this repo's HEAD matched the last published one, and upstream firmware fixes stayed invisible to users until something here happened to change. Every event except pull_request gets the whole matrix. Usage: ci-matrix.py # read GitHub Actions env; append to $GITHUB_OUTPUT # when it is set, otherwise print the same lines ci-matrix.py --stdin # read a file list on stdin, print the decision ci-matrix.py --self-test # check this file still agrees with the tree """ import argparse import glob import json import os import re import sys import urllib.error import urllib.request REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # The build matrix is every device in the tree, minus the opt-outs below. It is # derived rather than listed because adding a camera IS the work here -- 8 of # the 10 PRs open when this landed add a device, and 14% of recent commits do. # A written-down list would mean every one of them also edits this file, and a # change to this file cannot narrow (it is what does the narrowing), so # registering a camera would cost a full 107-device build to prove one. # # This is the opposite call to OpenIPC/firmware's ci-matrix.py, deliberately. # There the matrix is curated -- 29 defconfigs are intentionally never built and # boards are not the product -- so the list is explicit and NOT_BUILT would be # noise. Here devices are the product and the list is just "all of them". # Devices that exist in the tree but are deliberately not built: the opt-out # from the rule above. Every entry must still have a defconfig, so a rename # leaves a name describing nothing and --self-test says so. Changes to these # narrow to nothing, exactly as they do today. NOT_BUILT = { "gk7102ca_lite_umea-qc01x", "gk7102ca_lite_vstarcam-g8896wip", "gk7205v200_rubyfpv_generic", "hi3518ev200_lite_lenovo-snowman-1080p", "hi3518ev200_ultimate_tplink-kasa-kc110", "t31_lite_tp-link-tapo-tc70-v3", "t31_lite_xiaomi-mjsxj05hl", } # Workflows and scripts that cannot change what a build produces. Matched on the # whole filename, never as a prefix: one this list has never heard of is unknown, # and unknown widens. package.sh and repack.sh are developer and end-user tools # that no workflow invokes -- only builder.sh is on the CI path. NO_BUILD_WORKFLOWS = {"build-one.yml", "cleanup.yml", "lint.yml", "manifest.yml"} NO_BUILD_SCRIPTS = {"enrich_manifest.py", "lint-workflow-shell.py"} NO_BUILD_FILES = { ".github/CODEOWNERS", ".gitignore", "LICENSE", "package.sh", "repack.sh", } # CI plumbing: decides how the build runs, cannot change a byte of what it # produces. Gets SMOKE_TARGETS. ci-matrix.py is deliberately absent: it is what # decides the matrix, so it cannot be trusted to decide a smaller one for # itself. Adding a device does not touch it -- that is the point of deriving # the list -- so this costs a full matrix only when the rules themselves move. SMOKE_WORKFLOWS = {"master.yml"} # One target per way a build can differ: every SoC vendor, every architecture, # every toolchain tuple, every variant, and both of the shared device # directories. --self-test enforces that cover rather than the list, so these can # be re-picked freely while it holds. SMOKE_TARGETS = [ "gk7205v200_lite_tiandy-tc-c321n", # Goke lite "hi3518ev200_lite_switcam-hs303", # HiSilicon lite "hi3516ev300_ultimate_rvi-1ncmw2028", # HiSilicon ultimate "ssc325_lite_imou-c22cp", # SigmaStar lite, the musleabihf toolchain "ssc338q_fpv_caddx-fly", # SigmaStar fpv "t31_lite_wyze-v3b", # Ingenic, the only mips "t20_ultimate_azarton-c1", # Ingenic ultimate "hi3536dv100_fpv", # the only gnueabi toolchain in the tree "gk7205v200_fpv", # devices/common, and the fpv flavour "hi3516cv300_mini", # devices/common, mini "gk7205v200_lte", # devices/common, lte "gk7205v200_venc", # devices/common, venc "ssc30kq_rubyfpv_generic", # devices/common, rubyfpv "gk7205v200_otg_generic", # otg "ssc338q_apfpv", # devices/apfpv ] # LICENSE and README anchored to whole filenames; unanchored they would also # swallow LICENSES/vendor.txt and READMEgenerator.c and skip CI for real files. DOCS = re.compile(r"^(?:docs/|archive/|(?:LICENSE|README)(?:\.[^/]*)?$)") MARKDOWN = re.compile(r"^(?!devices/|package/).*\.md$") WORKFLOW = re.compile(r"^\.github/workflows/([^/]+)$") GITHUB_SCRIPT = re.compile(r"^\.github/scripts/([^/]+)$") DEVICE_PATH = re.compile(r"^devices/([^/]+)/(.*)$") DEVICE_DEFCONFIG = re.compile(r"^devices/[^/]+/.*/configs/(.+)_defconfig$") PACKAGE_PATH = re.compile(r"^package/([^/]+)/") FULL_LABEL = "ci:full" class Tree: """Which devices a file reaches, read off the checkout. builder.sh copies a whole devices// tree over the firmware clone, so the unit is the directory, not the device name. """ def __init__(self, root=REPO_ROOT): self.root = root self.directory_of = {} # target -> devices/ self.traits_of = {} for path in sorted(glob.glob(f"{root}/devices/*/**/configs/*_defconfig", recursive=True)): relative = path[len(root) + 1:] target = os.path.basename(path)[: -len("_defconfig")] self.directory_of[target] = "/".join(relative.split("/")[:2]) self.traits_of[target] = self._traits(path) self.built = sorted(t for t in self.directory_of if t not in NOT_BUILT) self.smoke = [t for t in self.built if t in set(SMOKE_TARGETS)] def _traits(self, path): with open(path) as handle: body = handle.read() def string(option): found = re.search(rf'^{option}="([^"]*)"', body, re.M) return found.group(1) if found else "?" if re.search(r"^BR2_aarch64=y", body, re.M): architecture = "aarch64" elif re.search(r"^BR2_mips", body, re.M): architecture = "mips" else: architecture = "arm" return { "vendor:" + string("BR2_OPENIPC_SOC_VENDOR"), "arch:" + architecture, "toolchain:" + string("BR2_TOOLCHAIN_EXTERNAL_CUSTOM_PREFIX"), "variant:" + string("BR2_OPENIPC_VARIANT"), } def targets_in(self, directory): """Every built target whose defconfig lives in devices//.""" return [t for t in self.built if self.directory_of[t] == directory] def devices_in(self, directory): """Every target in devices//, whether CI builds it or not. targets_in() answers "what would this build", which is what narrowing needs. This answers "is this a directory we know", which is what telling a deliberate opt-out apart from an unrecognised path needs. """ return [t for t in self.directory_of if self.directory_of[t] == directory] def classify(tree, changed, labels=(), event="pull_request", draft=False): """Map a list of changed paths to {rows, needs_build, reason}.""" full = list(tree.built) # Everything except a PR builds everything. The nightly especially: what it # produces is decided by firmware HEAD, not by this repo's diff. if event != "pull_request": return _decision(full, True, reason=f"{event} builds everything") if FULL_LABEL in labels: return _decision(full, True, reason=f"{FULL_LABEL} label set") if draft: return _decision([], False, reason="draft pull request") if not changed: return _decision(full, True, reason="no file list available") targets, smoked = set(), False for path in changed: if DOCS.match(path) or MARKDOWN.match(path) or path in NO_BUILD_FILES: continue workflow = WORKFLOW.match(path) if workflow: if workflow.group(1) in NO_BUILD_WORKFLOWS: continue if workflow.group(1) in SMOKE_WORKFLOWS: targets.update(tree.smoke) smoked = True continue return _decision(full, True, reason=f"{path} affects every device") script = GITHUB_SCRIPT.match(path) if script: if script.group(1) in NO_BUILD_SCRIPTS: continue return _decision(full, True, reason=f"{path} affects every device") device = DEVICE_PATH.match(path) if device: # A defconfig names exactly one target. Anything else in the tree # -- overlay, excludes list, kernel config -- is copied wholesale by # builder.sh, so it reaches every target sharing the directory. defconfig = DEVICE_DEFCONFIG.match(path) if defconfig: if defconfig.group(1) in tree.built: targets.add(defconfig.group(1)) continue hits = tree.targets_in(f"devices/{device.group(1)}") if hits: targets.update(hits) continue if tree.devices_in(f"devices/{device.group(1)}"): # The directory is known -- every device in it is in NOT_BUILT. # Reaching nothing is the whole point of that list, and their # defconfigs already behave this way, so an overlay or kernel # config beside one has to as well. Without this the opt-out # inverts: touching a device CI deliberately skips widens to # all of them. continue return _decision(full, True, reason=f"{path} is in no directory CI builds") if PACKAGE_PATH.match(path): # builder.sh copy_extra_packages() copies package/* into the # firmware tree and appends every one to its Config.in, for every # build. Nothing here is per-device. return _decision(full, True, reason=f"{path} is built into every device") # builder.sh itself, a new top-level file, or something this script has # never seen. Widen. return _decision(full, True, reason=f"{path} affects every device") if not targets: return _decision([], False, reason="nothing that reaches a build") if not smoked: return _decision(sorted(targets), True, reason="narrowed to the affected devices") if targets == set(tree.smoke): return _decision(sorted(targets), True, reason="CI plumbing only: smoke set, not every device") return _decision(sorted(targets), True, reason="affected devices plus the CI-plumbing smoke set") def _decision(rows, needs_build, reason): return { # Actions rejects an empty include list outright, so an empty matrix is # expressed by skipping the whole job via needs_build instead. "matrix": {"include": [{"platform": t} for t in rows] or [{"platform": "none"}]}, "needs_build": needs_build, "rows": rows, "reason": reason, } def changed_files_from_api(): """Ask the API which files the PR touches.""" repo = os.environ.get("GITHUB_REPOSITORY") token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") number = _event().get("pull_request", {}).get("number") if not (repo and token and number): return None files, page = [], 1 while True: url = (f"https://api.github.com/repos/{repo}/pulls/{number}/files" f"?per_page=100&page={page}") request = urllib.request.Request(url, headers={ "Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", }) with urllib.request.urlopen(request, timeout=30) as response: batch = json.load(response) files += [entry["filename"] for entry in batch] # A rename drops the old path from the list; it may be the only thing # tying the change to a device, so count it too. files += [entry["previous_filename"] for entry in batch if entry.get("previous_filename")] if len(batch) < 100: return files page += 1 # The endpoint stops at 3000 files. A partial list can only narrow too # far, so say we do not know, which reads as "build everything". if page > 30: print("ci-matrix: PR exceeds the 3000-file listing limit", file=sys.stderr) return None def _event(): path = os.environ.get("GITHUB_EVENT_PATH") if not (path and os.path.exists(path)): return {} with open(path) as handle: return json.load(handle) def self_test(): """Fail loudly when this file drifts from the tree it describes.""" tree = Tree() problems = [] # 1. Every target must be locatable exactly the way builder.sh locates it, # and must resolve to one directory. for target in tree.built: matches = glob.glob(f"{REPO_ROOT}/devices/*/**/configs/{target}_defconfig", recursive=True) if len(matches) > 1: # builder.sh does `find ... | cut -d/ -f1,2` and copies the result # unquoted, so two matches make it copy two trees over each other. problems.append( f"{target} has {len(matches)} defconfigs; builder.sh would " f"resolve it to more than one directory") # 2. Every opt-out must still name something. A renamed device leaves its # old name here describing nothing, and the new one silently builds. for target in sorted(NOT_BUILT): if target not in tree.directory_of: problems.append( f"{target} is in NOT_BUILT but has no defconfig; drop it, or " f"fix the name if the device was renamed") # 3. The smoke set has to be real, smaller, and cover every way a build can # differ -- including both shared device directories, since a plumbing # change that only breaks devices/common/ would otherwise go unproven. for target in SMOKE_TARGETS: if target not in tree.built: problems.append(f"{target} is in SMOKE_TARGETS but is not built") if len(tree.smoke) >= len(tree.built): problems.append("SMOKE_TARGETS is not smaller than the full matrix") covered = set().union(*(tree.traits_of[t] for t in tree.smoke)) if tree.smoke else set() for trait in sorted(set().union(*(tree.traits_of[t] for t in tree.built))): if trait not in covered: problems.append( f"no target in SMOKE_TARGETS has {trait}; a CI-plumbing change " f"would go unproven for it") shared = {tree.directory_of[t] for t in tree.built if len(tree.targets_in(tree.directory_of[t])) > 1} for directory in sorted(shared): if not any(tree.directory_of[t] == directory for t in tree.smoke): problems.append( f"no target in SMOKE_TARGETS lives in {directory}/, which backs " f"{len(tree.targets_in(directory))} devices") # 4. A name in both a no-build and a smoke list is dead config, and a # classified file that no longer exists is a name describing nothing. for name in sorted(NO_BUILD_WORKFLOWS & SMOKE_WORKFLOWS): problems.append( f"{name} is both a no-build and a smoke workflow; the no-build list " f"wins and the smoke entry never fires") for names, directory in [(NO_BUILD_WORKFLOWS | SMOKE_WORKFLOWS, "workflows"), (NO_BUILD_SCRIPTS, "scripts")]: for name in sorted(names): if not os.path.exists(os.path.join(REPO_ROOT, ".github", directory, name)): problems.append( f".github/{directory}/{name} is classified but does not exist") for name in sorted(NO_BUILD_FILES): if not os.path.exists(os.path.join(REPO_ROOT, name)): problems.append(f"{name} is classified but does not exist") # 5. The narrowing must be sound. full = len(tree.built) smoke = len(tree.smoke) common = len(tree.targets_in("devices/common")) apfpv = len(tree.targets_in("devices/apfpv")) cases = [ # One device. (["devices/t31_lite_wyze-v3b/general/overlay/usr/share/openipc/customizer.sh"], 1, "a device overlay is that device"), (["devices/t31_lite_wyze-v3b/br-ext-chip-ingenic/configs/" "t31_lite_wyze-v3b_defconfig"], 1, "a device defconfig"), # Shared directories. This is the case a per-directory-is-a-device rule # would get wrong, and it is 18 builds wide. (["devices/common/general/overlay/etc/inittab"], common, "a file in devices/common reaches every target sharing it"), (["devices/apfpv/general/overlay/etc/udhcpd.conf"], apfpv, "same for devices/apfpv"), # ...but a defconfig inside a shared directory still names one target. (["devices/common/br-ext-chip-goke/configs/gk7205v200_fpv_defconfig"], 1, "a defconfig in a shared directory is still one device"), # A defconfig CI does not build contributes nothing. (["devices/t31_lite_tp-link-tapo-tc70-v3/br-ext-chip-ingenic/configs/" "t31_lite_tp-link-tapo-tc70-v3_defconfig"], 0, "an unbuilt device"), # ...and so does anything else in that device's directory. Its kernel # config is the case that regressed: OpenIPC/builder#126 touched three # NOT_BUILT devices and got the full 107 for it. (["devices/gk7205v200_rubyfpv_generic/br-ext-chip-goke/board/gk7205v200/" "gk7205v200.generic-fpv.config"], 0, "a kernel config in an unbuilt device"), (["devices/t31_lite_tp-link-tapo-tc70-v3/general/overlay/etc/inittab"], 0, "an overlay file in an unbuilt device"), # A directory with no defconfig at all is still unknown, and unknown # still widens -- that is the half of this rule worth keeping. (["devices/a-device-that-does-not-exist/br-ext-chip-goke/board/x.config"], full, "a devices/ directory with no defconfig widens"), # Everything shared or unknown widens. (["builder.sh"], full, "the build script"), (["package/kc110-board-support/Config.in"], full, "package/ is copied into every build"), (["package/demo-openipc/src/demo-openipc.c"], full, "same for its sources"), ([".github/scripts/ci-matrix.py"], full, "this file"), ([".github/workflows/some-new-thing.yml"], full, "an unknown workflow widens"), ([".github/scripts/some-new-thing.sh"], full, "an unknown script widens"), (["some-new-top-level-file"], full, "an unknown path widens"), (["devices/t31_lite_wyze-v3b/x", "builder.sh"], full, "one shared path widens the whole set"), # CI plumbing smoke-tests. ([".github/workflows/master.yml"], smoke, "the build workflow smoke-tests"), ([".github/workflows/master.yml", ".github/workflows/cleanup.yml"], smoke, "smoke plus something that never builds"), ([".github/workflows/master.yml", "builder.sh"], full, "smoke loses to anything that changes a build"), # Nothing that reaches a build. (["README.md"], 0, "readme"), (["NOTES.md"], 0, "notes"), (["CLAUDE.md"], 0, "agent instructions are markdown"), (["LICENSE"], 0, "licence"), ([".github/CODEOWNERS"], 0, "codeowners"), ([".github/workflows/manifest.yml"], 0, "manifest never builds"), ([".github/scripts/enrich_manifest.py"], 0, "manifest script"), ([".github/workflows/lint.yml"], 0, "the workflow linter never builds"), ([".github/scripts/lint-workflow-shell.py"], 0, "its script"), (["repack.sh"], 0, "end-user tool, no workflow runs it"), (["package.sh"], 0, "developer tool, no workflow runs it"), (["archive/gk7205v200_fpv/202607231714/openipc.tgz"], 0, "build output"), # Anchoring: the same names elsewhere are not them. (["devices/t31_lite_wyze-v3b/README.md"], 1, "markdown inside a device is that device"), (["package/demo-openipc/README.md"], full, "markdown inside a package widens"), (["READMEgenerator.c"], full, "README prefix is not a readme"), (["scripts/repack.sh"], full, "same name in a subdirectory is not the tool"), ] for paths, expected, what in cases: got = len(classify(tree, paths)["rows"]) if got != expected: problems.append(f"{what}: expected {expected} rows, got {got}") # 6. Zero rows and "go build something" must never be emitted together. for paths, _, what in cases: decision = classify(tree, paths) if decision["needs_build"] and not decision["rows"]: problems.append(f"{what}: needs_build with an empty matrix") if decision["rows"] and not decision["needs_build"]: problems.append(f"{what}: rows with needs_build false") # 7. The nightly must never be narrowed, whatever the diff looks like. for event in ("schedule", "workflow_dispatch", "push"): decision = classify(tree, ["README.md"], event=event) if len(decision["rows"]) != full: problems.append(f"{event} must build every device, got {len(decision['rows'])}") if len(classify(tree, ["README.md"], labels=[FULL_LABEL])["rows"]) != full: problems.append(f"{FULL_LABEL} must build every device") if classify(tree, ["builder.sh"], draft=True)["needs_build"]: problems.append("a draft pull request must not build") # 8. No workflow may declare a top-level key twice. Legal YAML -- the last # one wins -- but Actions refuses the file and the run produces no jobs. workflows = os.path.join(REPO_ROOT, ".github", "workflows") for name in sorted(os.listdir(workflows)): if not name.endswith((".yml", ".yaml")): continue seen = set() with open(os.path.join(workflows, name)) as handle: for line in handle: key = re.match(r"([A-Za-z_][\w-]*):", line) # column 0 only if not key: continue if key.group(1) in seen: problems.append( f".github/workflows/{name} declares '{key.group(1)}' more " f"than once; Actions will refuse the file") seen.add(key.group(1)) for problem in problems: print(f"ci-matrix: {problem}", file=sys.stderr) if problems: return 1 print(f"ci-matrix: self-test ok ({len(tree.built)} devices, " f"{len(tree.smoke)} smoke, {len(cases)} cases)") return 0 def main(): parser = argparse.ArgumentParser() parser.add_argument("--stdin", action="store_true", help="read changed paths from stdin instead of the API") parser.add_argument("--self-test", action="store_true") args = parser.parse_args() if args.self_test: return self_test() tree = Tree() if args.stdin: changed = [line.strip() for line in sys.stdin if line.strip()] event, labels, draft = "pull_request", [], False else: event = os.environ.get("GITHUB_EVENT_NAME", "pull_request") pull_request = _event().get("pull_request", {}) labels = [label["name"] for label in pull_request.get("labels", [])] draft = bool(pull_request.get("draft")) changed = None if event == "pull_request": try: changed = changed_files_from_api() except (urllib.error.URLError, OSError, ValueError) as exc: # Never fail the run over this; an empty list means full matrix. print(f"ci-matrix: cannot list PR files ({exc})", file=sys.stderr) decision = classify(tree, changed or [], labels, event, draft) print(f"ci-matrix: {len(decision['rows'])}/{len(tree.built)} devices " f"(needs_build={decision['needs_build']}) --- {decision['reason']}", file=sys.stderr) for target in decision["rows"]: print(f" {target}", file=sys.stderr) lines = [ f"matrix={json.dumps(decision['matrix'], separators=(',', ':'))}", f"needs-build={str(decision['needs_build']).lower()}", f"reason={decision['reason']}", ] # Write $GITHUB_OUTPUT here rather than having the workflow redirect stdout # into it. Under a redirect every print() in this file is one keystroke away # from corrupting the step outputs, and a crash between the first line and # the last leaves a half-written file that Actions still reads. Diagnostics # go to stderr precisely so that cannot happen -- which is a rule that has # to hold forever to stay safe, instead of a property of the code. # Falls back to stdout so a local run still shows what it decided. destination = None if args.stdin else os.environ.get("GITHUB_OUTPUT") if destination: with open(destination, "a") as handle: handle.write("\n".join(lines) + "\n") else: print("\n".join(lines)) return 0 if __name__ == "__main__": sys.exit(main())