#!/usr/bin/env python3 """ Validate NuGet source ordering for StellaOps. Ensures `local-nuget` is the highest priority feed in both NuGet.config and the Directory.Build.props restore configuration. Fails fast with actionable errors so CI/offline kit workflows can assert deterministic restore ordering. """ from __future__ import annotations import argparse import subprocess import sys import xml.etree.ElementTree as ET from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] NUGET_CONFIG = REPO_ROOT / "NuGet.config" ROOT_PROPS = REPO_ROOT / "Directory.Build.props" EXPECTED_SOURCE_KEYS = ["local", "dotnet-public", "nuget.org"] class ValidationError(Exception): """Raised when validation fails.""" def _fail(message: str) -> None: raise ValidationError(message) def _parse_xml(path: Path) -> ET.ElementTree: try: return ET.parse(path) except FileNotFoundError as exc: _fail(f"Missing required file: {path}") except ET.ParseError as exc: _fail(f"Could not parse XML for {path}: {exc}") def validate_nuget_config() -> None: tree = _parse_xml(NUGET_CONFIG) root = tree.getroot() package_sources = root.find("packageSources") if package_sources is None: _fail("NuGet.config must declare a section.") children = list(package_sources) if not children or children[0].tag != "clear": _fail("NuGet.config packageSources must begin with a element.") adds = [child for child in children if child.tag == "add"] if not adds: _fail("NuGet.config packageSources must define at least one entry.") keys = [add.attrib.get("key") for add in adds] if keys[: len(EXPECTED_SOURCE_KEYS)] != EXPECTED_SOURCE_KEYS: formatted = ", ".join(keys) or "" _fail( "NuGet.config packageSources must list feeds in the order " f"{EXPECTED_SOURCE_KEYS}. Found: {formatted}" ) local_value = adds[0].attrib.get("value", "") if Path(local_value).name != "local-nuget": _fail( "NuGet.config local feed should point at the repo-local mirror " f"'local-nuget', found value '{local_value}'." ) clear = package_sources.find("clear") if clear is None: _fail("NuGet.config packageSources must start with to avoid inherited feeds.") def validate_directory_build_props() -> None: tree = _parse_xml(ROOT_PROPS) root = tree.getroot() defaults = None for element in root.findall(".//_StellaOpsDefaultRestoreSources"): defaults = [fragment.strip() for fragment in element.text.split(";") if fragment.strip()] break if defaults is None: _fail("Directory.Build.props must define _StellaOpsDefaultRestoreSources.") expected_props = [ "$(StellaOpsLocalNuGetSource)", "$(StellaOpsDotNetPublicSource)", "$(StellaOpsNuGetOrgSource)", ] if defaults != expected_props: _fail( "Directory.Build.props _StellaOpsDefaultRestoreSources must list feeds " f"in the order {expected_props}. Found: {defaults}" ) restore_nodes = root.findall(".//RestoreSources") if not restore_nodes: _fail("Directory.Build.props must override RestoreSources to force deterministic ordering.") uses_default_first = any( node.text and node.text.strip().startswith("$(_StellaOpsDefaultRestoreSources)") for node in restore_nodes ) if not uses_default_first: _fail( "Directory.Build.props RestoreSources override must place " "$(_StellaOpsDefaultRestoreSources) at the beginning." ) def assert_single_nuget_config() -> None: extra_configs: list[Path] = [] configs: set[Path] = set() for glob in ("NuGet.config", "nuget.config"): try: result = subprocess.run( ["rg", "--files", f"-g{glob}"], check=False, capture_output=True, text=True, cwd=REPO_ROOT, ) except FileNotFoundError as exc: _fail("ripgrep (rg) is required for validation but was not found on PATH.") if result.returncode not in (0, 1): _fail( f"ripgrep failed while searching for {glob}: {result.stderr.strip() or result.returncode}" ) for line in result.stdout.splitlines(): configs.add((REPO_ROOT / line).resolve()) configs.discard(NUGET_CONFIG.resolve()) extra_configs.extend(sorted(configs)) if extra_configs: formatted = "\n ".join(str(path.relative_to(REPO_ROOT)) for path in extra_configs) _fail( "Unexpected additional NuGet.config files detected. " "Consolidate feed configuration in the repo root:\n " f"{formatted}" ) def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Verify StellaOps NuGet feeds prioritise the local mirror." ) parser.add_argument( "--skip-rg", action="store_true", help="Skip ripgrep discovery of extra NuGet.config files (useful for focused runs).", ) return parser.parse_args(argv) def main(argv: list[str]) -> int: args = parse_args(argv) validations = [ ("NuGet.config ordering", validate_nuget_config), ("Directory.Build.props restore override", validate_directory_build_props), ] if not args.skip_rg: validations.append(("single NuGet.config", assert_single_nuget_config)) for label, check in validations: try: check() except ValidationError as exc: sys.stderr.write(f"[FAIL] {label}: {exc}\n") return 1 else: sys.stdout.write(f"[OK] {label}\n") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))