184 lines
		
	
	
		
			6.0 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			184 lines
		
	
	
		
			6.0 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
#!/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 <packageSources> section.")
 | 
						|
 | 
						|
    children = list(package_sources)
 | 
						|
    if not children or children[0].tag != "clear":
 | 
						|
        _fail("NuGet.config packageSources must begin with a <clear /> element.")
 | 
						|
 | 
						|
    adds = [child for child in children if child.tag == "add"]
 | 
						|
    if not adds:
 | 
						|
        _fail("NuGet.config packageSources must define at least one <add> entry.")
 | 
						|
 | 
						|
    keys = [add.attrib.get("key") for add in adds]
 | 
						|
    if keys[: len(EXPECTED_SOURCE_KEYS)] != EXPECTED_SOURCE_KEYS:
 | 
						|
        formatted = ", ".join(keys) or "<empty>"
 | 
						|
        _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 <clear /> 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:]))
 |