#!/usr/bin/env python3 """Read examples-manifest.yml: emit the CI matrix, or gate coverage.""" import argparse import collections import json import re import subprocess import sys from pathlib import Path REPO = Path(__file__).resolve().parents[2] MANIFEST = REPO / ".github" / "examples-manifest.yml" # Dirs that are not examples and never will be. NOT_EXAMPLES = {".git", ".github", "certs"} # A dir is "buildable source" -- and therefore must be accounted for -- if it # holds any of these. Gating on Makefile alone missed SGX (own scripts), Android # (gradle), ESP32 (idf/cmake) and the README-gcc-one-liner dirs, which is exactly # the "someone added an example and forgot to wire it up" case the gate exists for. BUILD_MARKERS = ("Makefile", "CMakeLists.txt", "build.gradle", "build.gradle.kts") # Project files are named after their project, so they match by suffix, not name. # A .sln is a container for these, not a build unit itself. BUILD_MARKER_SUFFIXES = (".csproj", ".vcxproj") # Not just .c: an example written in another language is still an example, and a # gate that cannot see it cannot force a decision about it. .ino in particular # looked covered -- arduino.yml compiles the examples inside the *installed* # Arduino wolfSSL library, not this repo's Arduino/sketches/*. SOURCE_SUFFIXES = (".c", ".ino", ".java", ".cpp", ".cs") # Dirs that carry a build marker but are not independently buildable units: # IDE projects, generated tool config, and subproject components. NOISE = ( "/VisualGDB", # IDE projects mirroring a parent Makefile's targets "/.config/", # generated CCS/XDC config under tirtos_ccs_examples "/components/", # ESP-IDF components of a parent project "/wolfssl_library/", # the Arduino template's bundled library, not a unit "/src/com/", # java package tree of an android project declared above it ) def is_noise(d): s = f"/{d}/" return any(n in s for n in NOISE) or d.endswith("/main") def load(path=MANIFEST): try: import yaml except ImportError: sys.exit("PyYAML required: pip install pyyaml") with open(path) as fh: data = yaml.safe_load(fh) validate(data) return data def validate(data): profiles = data.get("profiles") or {} # Matches the `overlay` input of .github/actions/setup-wolfssl: either a # bare repo name (latest on master) or "name@branchOrTagOrCommit". The # action resolves the name to a URL, so an unknown one fails there, not here. for name, p in profiles.items(): overlay = (p or {}).get("overlay") if overlay and not re.fullmatch(r"[a-z0-9-]+(@[^\s@]+)?", overlay): sys.exit( f"manifest: profile '{name}': overlay must be 'name' or " f"'name@branchOrTagOrCommit', got '{overlay}'" ) seen = set() for e in data.get("examples") or []: for key in ("id", "path"): if key not in e: sys.exit(f"manifest: entry missing '{key}': {e}") if e["id"] in seen: sys.exit(f"manifest: duplicate id '{e['id']}'") seen.add(e["id"]) mode = e.get("mode", "run") if mode not in ("run", "check", "build-only", "skip"): sys.exit(f"manifest: {e['id']}: bad mode '{mode}'") if mode == "skip" and not e.get("reason"): sys.exit(f"manifest: {e['id']}: mode 'skip' requires a 'reason'") tier = e.get("tier", "host") if tier not in ("host", "emulated", "cross"): sys.exit(f"manifest: {e['id']}: bad tier '{tier}'") # A host example that only builds is an untested example, so say why. # Cross-tier entries are exempt: for a cross-compile target, building is # the test. if mode == "build-only" and tier == "host" and not e.get("reason"): sys.exit( f"manifest: {e['id']}: host 'build-only' requires a 'reason'.\n" "Add a run: recipe, or state why it cannot run in CI." ) if mode != "skip": if not e.get("profile"): sys.exit(f"manifest: {e['id']}: needs a 'profile'") if e["profile"] not in profiles: sys.exit(f"manifest: {e['id']}: unknown profile '{e['profile']}'") if not (REPO / e["path"]).is_dir(): sys.exit(f"manifest: {e['id']}: path does not exist: {e['path']}") def job_built_paths(): """Paths some cross-tier workflow actually builds. Read from the workflows rather than declared in the manifest: a manifest that states its own coverage would just agree with itself. """ import yaml wf_dir = REPO / ".github/workflows" # one workflow per target, so scan them all rather than a single file paths = set() jobs_seen = set() for f in sorted(wf_dir.glob("*.yml")): try: wf = yaml.safe_load(f.read_text()) except Exception: continue if not isinstance(wf, dict): continue for name, job in (wf.get("jobs") or {}).items(): if not isinstance(job, dict): continue jobs_seen.add(name) matrix = ((job.get("strategy") or {}).get("matrix") or {}) for item in matrix.get("example") or []: if isinstance(item, str): paths.add(item) for item in matrix.get("include") or []: if isinstance(item, dict) and isinstance(item.get("example"), str): paths.add(item["example"]) # arduino.yml runs compile-all-examples.sh over board_list.txt, so it covers # every sketch dir rather than naming them in a matrix if (REPO / ".github/workflows/arduino.yml").is_file(): for d in (REPO / "Arduino/sketches").glob("*/"): if d.is_dir(): paths.add("Arduino/sketches/" + d.name) # jobs that build one hardcoded dir rather than a matrix # csharp: the client is the peer in every matrix leg rather than an axis for job, path in (("puf", "puf"), ("rpi-pico", "RPi-Pico"), ("fullstack", "fullstack/freertos-wolfip-wolfssl-https"), ("java", "java/https-url"), ("rt1060", "RT1060"), ("csharp", "CSharp/wolfSSL-TLS-pq-Client"), ("psa", "psa"), ("bsdkm", "kernel/bsdkm"), ("cmake", "cmake")): if job in jobs_seen: paths.add(path) # the pico job builds the whole cmake tree, so its subdirs come with it pico = REPO / "RPi-Pico/CMakeLists.txt" if pico.is_file() and "RPi-Pico" in paths: for sub in re.findall(r"add_subdirectory\(([^)]+)\)", pico.read_text()): paths.add("RPi-Pico/" + sub.strip().strip('"').strip("'")) return paths def emulated_jobs(): emul = (REPO / ".github/workflows/emulated.yml").read_text() body = emul.split("\njobs:", 1)[-1] return set(re.findall(r"^ ([a-z0-9][a-z0-9-]*):$", body, re.M)) def validate_has_job(data): """A cross/emulated entry that is not skipped must be built by some job. Without this, `mode: build-only` on an entry no workflow touches reads as covered while nothing compiles it. """ built = job_built_paths() emul = emulated_jobs() orphans = [] for e in data.get("examples") or []: tier = e.get("tier", "host") if tier not in ("cross", "emulated") or e.get("mode", "run") == "skip": continue if tier == "emulated": # emulated ids are the job names (sgx-linux -> sgx) if not any(j == e["id"] or e["id"].startswith(j) for j in emul): orphans.append(e["id"]) continue p = e["path"] if not any(p == b or p.startswith(b + "/") for b in built): orphans.append(e["id"]) if orphans: sys.exit( "manifest: no job builds these, so 'build-only' is not true:\n " + "\n ".join(orphans) + "\n\nAdd a job, or set mode: skip with a reason." ) def buildable_dirs(): """Topmost buildable units, relative to the repo root. A dir counts only if no ancestor already has a build marker -- otherwise tls/VisualGDB-tls/* (28 IDE subdirs under tls/Makefile) and ESP32/*/main (an ESP-IDF component of its parent project) would each demand an entry. """ tracked = subprocess.run( ["git", "ls-files"], cwd=REPO, capture_output=True, text=True, check=True ).stdout.splitlines() marker_dirs, source_dirs = set(), set() for f in tracked: p = Path(f) if not p.parts or p.parts[0] in NOT_EXAMPLES: continue if p.name in BUILD_MARKERS or p.suffix in BUILD_MARKER_SUFFIXES: marker_dirs.add(str(p.parent)) elif p.suffix in SOURCE_SUFFIXES and len(p.parts) > 1: source_dirs.add(str(p.parent)) def has_buildable_ancestor(d): return any(str(a) in marker_dirs for a in Path(d).parents if str(a) != ".") # A dir with its own build marker is its own unit (signature/rsa_buffer has a # Makefile independent of signature/'s). A dir with only .c is a unit just # when nothing above it builds -- otherwise every ESP-IDF source subdir counts. units = {d for d in marker_dirs} units |= {d for d in source_dirs if not has_buildable_ancestor(d)} return {d for d in units if not is_noise(d)} def cmd_check(data): validate_has_job(data) declared = {e["path"] for e in data["examples"]} actual = buildable_dirs() missing = sorted(actual - declared) stale = sorted(d for d in declared - actual if not (REPO / d).is_dir()) if stale: print("Manifest entries whose path no longer exists:", file=sys.stderr) for d in stale: print(f" {d}", file=sys.stderr) if missing: print( "\nThese dirs hold buildable source but are not in " ".github/examples-manifest.yml.\nAdd an entry, or add one with " "`mode: skip` and a `reason:`.\n", file=sys.stderr, ) for d in missing: print(f" {d}", file=sys.stderr) if missing or stale: sys.exit(1) print(f"coverage: ok ({len(declared)} entries cover {len(actual)} buildable dirs)") # The job count is one per entry per ref whatever the mode, so it never moves # when an example is promoted. Print the tally, or a reviewer cannot see it. modes = collections.Counter(e.get("mode", "run") for e in data["examples"]) asserts = sum( 1 for e in data["examples"] for s in (e.get("run") or []) if isinstance(s, dict) and "expect" in s ) xfails = sum( 1 for e in data["examples"] for s in (e.get("run") or []) if isinstance(s, dict) and "expect_fail" in s ) print( f"tested: {modes['run']} run, {modes['check']} make-check, " f"{modes['build-only']} build-only, {modes['skip']} skip -- " f"{asserts + modes['check']} output assertions, {xfails} known-fail" ) def live_entries(data, tier): return [ e for e in data["examples"] if e.get("mode") != "skip" and e.get("tier", "host") == tier ] def entry_refs(entry, refs): ref = entry.get("wolfssl_ref") return [ref] if ref else refs def cmd_matrix(data, refs, tier, shas=None): """One matrix entry per (example, ref) -- each example is its own job. A profile is a property of a directory, never an axis: pk/rsa is only ever 'fastmath'. Never cross profiles x examples. `shas` pins each ref to a commit resolved once upstream, so every job shares one cache key instead of racing a moving branch. """ pinned = dict(zip(refs, shas)) if shas else {} out = [] for e in sorted(live_entries(data, tier), key=lambda x: x["id"]): p = data["profiles"][e["profile"]] # wolfssl_ref pins one example to a specific ref (e.g. refs/pull/N/head) # while a fix is in flight upstream. setup-wolfssl still resolves it to a # sha, so the cache key stays sha-based. for ref in entry_refs(e, refs): out.append( { "id": e["id"], "path": e["path"], "profile": e["profile"], # in the job title, so a green tile says whether it ran or # only compiled instead of implying more than it proves "mode_label": "Build" if e.get("mode") == "build-only" else "Run", "wolfssl_ref": ref, # refs/pull/N/head has slashes; anything used in a filename or # artifact name needs the slug, not the ref. "ref_slug": ref.replace("/", "-"), "wolfssl_sha": pinned.get(ref, ref), "flags": " ".join(p.get("flags", "").split()), "cflags": p.get("cflags", ""), # a source overlay the profile needs patched into the wolfSSL # tree before configure (setup-wolfssl applies it) "overlay": p.get("overlay", ""), "deps": " ".join(e.get("deps") or []), } ) if len(out) > 256: sys.exit( f"matrix would be {len(out)} jobs; GitHub caps a matrix at 256.\n" "Reduce refs, or split this tier across workflows." ) print(json.dumps(out)) def cmd_wolfssl_matrix(data, refs, tier, shas=None): """The distinct (profile, ref) builds this tier needs. Built once each and cached, so the per-example jobs restore rather than rebuild -- otherwise every example pays a full wolfSSL build. """ pinned = dict(zip(refs, shas)) if shas else {} profiles = sorted({e["profile"] for e in live_entries(data, tier)}) out = [ { "profile": name, "wolfssl_ref": ref, "wolfssl_sha": pinned.get(ref, ref), "flags": " ".join(data["profiles"][name].get("flags", "").split()), "cflags": data["profiles"][name].get("cflags", ""), "overlay": data["profiles"][name].get("overlay", ""), } for name in profiles for ref in refs ] # A pinned example needs its (profile, ref) seeded too, or its job pays a # full wolfSSL build on every run. for e in live_entries(data, tier): ref = e.get("wolfssl_ref") if ref and not any( o["profile"] == e["profile"] and o["wolfssl_ref"] == ref for o in out ): out.append( { "profile": e["profile"], "wolfssl_ref": ref, "wolfssl_sha": pinned.get(ref, ref), "flags": " ".join( data["profiles"][e["profile"]].get("flags", "").split() ), "cflags": data["profiles"][e["profile"]].get("cflags", ""), "overlay": data["profiles"][e["profile"]].get("overlay", ""), } ) print(json.dumps(out)) def cmd_skips(data): """Report every dir CI does not build, and why. Generated, never hand-kept.""" skips = sorted( (e for e in data["examples"] if e.get("mode") == "skip"), key=lambda e: e["path"], ) bo = sorted( (e for e in data["examples"] if e.get("mode") == "build-only"), key=lambda e: e["path"], ) print(f"# Directories CI does not run\n") print(f"Generated by `manifest.py skips` from `.github/examples-manifest.yml`.\n") print(f"{len(skips)} not built at all; {len(bo)} built but not run.\n") print("## Not built\n") print("| directory | why |") print("|---|---|") for e in skips: print(f"| `{e['path']}` | {e['reason']} |") print("\n## Built, but not run\n") print("| directory | why not run |") print("|---|---|") for e in bo: print(f"| `{e['path']}` | {e.get('reason', 'no run recipe wired yet')} |") def cmd_profiles(data): """Emit the distinct profiles a matrix needs to build, with their flags.""" used = {e["profile"] for e in data["examples"] if e.get("mode") != "skip"} out = [ { "profile": name, "flags": data["profiles"][name].get("flags", "").strip(), "cflags": data["profiles"][name].get("cflags", ""), "overlay": data["profiles"][name].get("overlay", ""), } for name in sorted(used) ] print(json.dumps(out)) def main(): ap = argparse.ArgumentParser() ap.add_argument( "command", choices=["check", "matrix", "wolfssl-matrix", "profiles", "skips"] ) ap.add_argument("--refs", default="master", help="comma-separated wolfSSL refs") ap.add_argument("--shas", default="", help="commit SHAs for --refs, same order") ap.add_argument("--tier", default="host", choices=["host", "emulated", "cross"]) args = ap.parse_args() refs = args.refs.split(",") shas = args.shas.split(",") if args.shas else None if shas and len(shas) != len(refs): sys.exit(f"--shas has {len(shas)} entries but --refs has {len(refs)}") data = load() if args.command == "check": cmd_check(data) elif args.command == "matrix": cmd_matrix(data, refs, args.tier, shas) elif args.command == "wolfssl-matrix": cmd_wolfssl_matrix(data, refs, args.tier, shas) elif args.command == "skips": cmd_skips(data) else: cmd_profiles(data) if __name__ == "__main__": main()