# SPDX-FileCopyrightText: 2026 Slavi Pantaleev # # SPDX-License-Identifier: AGPL-3.0-or-later """Select Molecule scenarios affected by a Git comparison, using only the stdlib. Shared dependencies use literal molecule-shared/... paths and image variable names. Follow these references transitively, in both revisions. This is deliberately not an Ansible interpreter: unfamiliar image-pin syntax or unresolved dependencies run all scenarios instead of risking an incomplete automerge gate. """ import argparse import json import os import posixpath import re import subprocess import sys from pathlib import Path IMAGE_VARS = "molecule-shared/vars.yml" GLOBAL_FILES = { "molecule-shared/requirements.txt", "molecule-shared/requirements.yml", "molecule-shared/playbook-context.yml", ".github/workflows/molecule.yml", "bin/molecule-select-roles.py", "bin/test-molecule-select-roles.py", } SCENARIO = re.compile(r"roles/custom/([^/]+)/molecule/default/molecule\.yml$") SHARED_PATH = re.compile(r"molecule-shared/[\w./-]+") IMAGE_NAME = re.compile(r"\bmolecule_shared_image_\w+\b") IMAGE_PIN = re.compile(r"(molecule_shared_image_\w+):\s*(['\"])([\w./:@+-]+)\2\s*(?:#.*)?$") class Uncertain(Exception): """The change cannot safely be narrowed to particular scenarios.""" class Git: def __init__(self, root): self.root = root def run(self, *args, input=None): return subprocess.run( ["git", *args], cwd=self.root, input=input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, ).stdout def commit(self, ref): return self.run("rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}").decode().strip() def snapshot(self, ref): entries = {} for entry in self.run("ls-tree", "-rz", ref, "--", "roles/custom", "molecule-shared").split(b"\0"): if not entry: continue metadata, path = entry.decode().split("\t", 1) mode, kind, oid = metadata.split() if path.startswith("molecule-shared/") or "/molecule/" in path: if kind != "blob": raise Uncertain(f"Unsupported Git entry: {path}") entries[path] = (mode, oid) # Read the blobs in one process; a subprocess per scenario file is slow. oids = list(dict.fromkeys(oid for mode, oid in entries.values())) data = self.run("cat-file", "--batch", input="".join(f"{oid}\n" for oid in oids).encode()) blobs = {} offset = 0 for oid in oids: end = data.index(b"\n", offset) size = int(data[offset:end].split()[2]) blobs[oid] = data[end + 1:end + 1 + size].decode() offset = end + size + 2 return {path: (mode, blobs[oid]) for path, (mode, oid) in entries.items()} def comparison_base(git, head, env): """Preserve push/PR comparison semantics, including new Renovate branches.""" try: if env.get("EVENT_NAME") == "pull_request": return git.commit(env["BASE_SHA"]) if env.get("EVENT_NAME") == "push": before = env.get("BEFORE_SHA", "") if before and set(before) != {"0"}: try: return git.commit(before) except subprocess.CalledProcessError: pass default = env.get("DEFAULT_BRANCH", "") if default and env.get("GITHUB_REF") != f"refs/heads/{default}": return git.run("merge-base", head, f"refs/remotes/origin/{default}").decode().strip() except (KeyError, subprocess.CalledProcessError): pass return None def image_pins(snapshot): """Accept only flat, quoted literal image pins; other YAML runs all scenarios.""" if IMAGE_VARS not in snapshot or snapshot[IMAGE_VARS][0] == "120000": raise Uncertain("Missing or symlinked shared image pins") pins = {} document_started = False for line in snapshot[IMAGE_VARS][1].splitlines(): if not line.strip() or line.startswith("#"): continue if line == "---" and not pins and not document_started: document_started = True continue match = IMAGE_PIN.fullmatch(line) if not match or match[1] in pins: raise Uncertain("Unrecognized shared image-pin format") pins[match[1]] = match[3] if not pins: raise Uncertain("No shared image pins") return pins def dependencies(snapshot, role): """Return file and variable dependencies, following shared files and symlinks. Scan all scenario files, including nested tasks and fixtures. Ignore full-line comments. Do not scan vars.yml's definitions: loading the mapping does not mean a scenario uses every image in it. """ pending = [path for path in snapshot if path.startswith(f"roles/custom/{role}/molecule/")] files, variables = set(), set() while pending: path = pending.pop() if path in files: continue files.add(path) if path not in snapshot: raise Uncertain(f"Unresolved dependency: {path}") mode, content = snapshot[path] if mode == "120000": pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(path), content))) continue if path == IMAGE_VARS: continue content = "\n".join(line for line in content.splitlines() if not line.lstrip().startswith("#")) references = SHARED_PATH.findall(content) if content.count("molecule-shared/") != len(references): raise Uncertain(f"Nonliteral shared dependency in {path}") pending.extend(references) names = IMAGE_NAME.findall(content) if content.count("molecule_shared_image_") != len(names): raise Uncertain(f"Nonliteral shared image variable in {path}") variables.update(names) return files, variables def select_roles(git, base, head="HEAD", role=""): # Failure to enumerate the head must fail the job, never report an empty gate. paths = git.run("ls-tree", "-rz", "--name-only", head, "--", "roles/custom").decode().split("\0") available = {match[1] for path in paths if (match := SCENARIO.fullmatch(path))} if role: if role not in available: raise ValueError(f"No scenario at roles/custom/{role}/molecule/default") return [role] if not base: print("No comparison requested or available; testing every scenario", file=sys.stderr) return sorted(available) try: # Disable rename detection so both old and new paths contribute consumers. diff = git.run("diff", "--no-renames", "--name-only", "-z", base, head, "--") changed = set(diff.decode().split("\0")) - {""} if changed & GLOBAL_FILES: raise Uncertain("Test infrastructure changed: " + ", ".join(sorted(changed & GLOBAL_FILES))) selected = {path.split("/")[2] for path in changed if path.startswith("roles/custom/")} shared = {path for path in changed if path.startswith("molecule-shared/")} if shared: snapshots = [git.snapshot(base), git.snapshot(head)] changed_images = set() if IMAGE_VARS in shared: old, new = map(image_pins, snapshots) if old.keys() != new.keys(): raise Uncertain("Shared image variables added or removed") changed_images = {key for key in old if old[key] != new[key]} shared.remove(IMAGE_VARS) if shared or changed_images: consumers = {dependency: set() for dependency in shared | changed_images} for snapshot in snapshots: for candidate in available: files, variables = dependencies(snapshot, candidate) for dependency in consumers.keys() & (files | variables): consumers[dependency].add(candidate) for dependency, roles in sorted(consumers.items()): if not roles: raise Uncertain(f"No known consumers for {dependency}") print(f"{dependency}: {len(roles)} scenario(s)", file=sys.stderr) selected.update(roles) return sorted(selected & available) except (Uncertain, subprocess.CalledProcessError, UnicodeError) as exc: print(f"Testing every scenario: {exc}", file=sys.stderr) return sorted(available) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--base", help="Compare against this revision; otherwise use GitHub event variables") parser.add_argument("--head", default="HEAD", help="Revision to test (default: HEAD)") parser.add_argument("--role", default=os.environ.get("INPUT_ROLE", ""), help="Run one named role") args = parser.parse_args() git = Git(Path.cwd()) base = args.base if args.base is not None else comparison_base(git, args.head, os.environ) roles = select_roles(git, base, args.head, args.role) result = json.dumps(roles, separators=(",", ":")) print(result) if output := os.environ.get("GITHUB_OUTPUT"): with open(output, "a") as stream: stream.write(f"roles={result}\n") if __name__ == "__main__": main()