← Files AMDARCHIVED FILE

.github/scripts/federation_guard.py

13 KB · Nov 12, 2026 · 18:37 UTC

↓ Download file

See the change to this file →

#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Report the two federation rules a pull request can break.

Pure reporting: this script decides nothing and touches nothing. It reads the
base branch's tree, the pull request's changed paths, and the
`.github/federation.json` the pull request proposes, then writes a JSON report.
The `federation-guard` workflow is what closes a pull request or fails a check
from that report, because those need a token and this needs to be runnable by
hand.

Rule 1 -- vendored skills are edited upstream, not here.
    A federated skill folder is a mirror of a folder in a product repo.
    `federate_skills.py` re-imports it with rmtree + copytree, so anything
    committed here is deleted by the next nightly run: an edit that looks
    merged is really just pending its own reversal. That includes the skill's
    `evals/` folder, which is imported like everything else.

    Federated means declared in `.github/federation.json`, and nothing else.
    The vendored copies carry a `.federated.json` marker too, but the marker is
    the importer's bookkeeping and the two disagree on `main` today; the
    declaration is the catalog's statement of intent, and it is the file a
    reviewer reads.

    The declarations are read from the *base* branch, so the pull request that
    federates a skill for the first time does not trip over its own new entry,
    and deleting an entry in the same commit that edits the skill does not slip
    past the rule.

Rule 2 -- a new federated skill needs its product repo approved.
    `.github/skill_owners.json` records the repos whose engineering owner and
    product release owner have both signed off, via the `product-repo-approval`
    workflow. An entry with a `path` approves only that subdirectory, for
    super-repos whose projects each have their own owners. A pull request that
    declares a skill no entry covers is asking the catalog to vendor code
    nobody has vouched for.

    Only skills the pull request *adds to the catalog* are held to this. A
    skill whose folder already exists under `skills/` on the base branch is
    part of the catalog already, whether or not it is declared, so starting to
    federate it from upstream does not need a new approval and the rule cannot
    retroactively break the catalog. The one exception is keyed on the (repo,
    local skill name) pair of a skill that is already declared: moving its
    upstream path is maintenance, but pointing it at a different repo is a new
    approval question.

    The registry is read from the base branch too. Reading the pull request's
    copy would let the same pull request that federates a skill also grant it
    approval, which is the gate approving itself.

Usage:
    uv run .github/scripts/federation_guard.py \
        --changed-files changed.txt \
        --head-federation head-federation.json \
        --report report.json

`--changed-files` is a file holding one repo-relative path per line (what
`gh api .../pulls/N/files --jq '.[].filename'` prints). `--head-federation` is
the pull request's copy of `.github/federation.json`; leave it out when the
pull request does not change that file and rule 2 is skipped.

The exit status is 0 whenever the evaluation itself succeeded, violations or
not. What to do about a violation is the workflow's call.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))

import federate_skills as fed  # noqa: E402

REPO_ROOT = SCRIPTS_DIR.parent.parent
SKILLS_PREFIX = "skills/"


def read_changed_files(path: Path) -> list[str]:
    """Read one repo-relative path per line, normalized to POSIX separators.

    `utf-8-sig` because a list written by a PowerShell one-liner on a
    maintainer's machine starts with a byte-order mark, which would otherwise
    hide the first path in it.
    """
    lines = path.read_text(encoding="utf-8-sig").splitlines()
    return [line.strip().replace("\\", "/") for line in lines if line.strip()]


def load_approvals(registry: Path) -> set[tuple[str, str]]:
    """Return the approved scopes from `.github/skill_owners.json`.

    Each scope is (repo lowercased, subdirectory). An empty subdirectory is the
    whole repo; otherwise the approval covers only skills under that directory,
    which is how one project in a super-repo is approved without the rest.

    A missing or malformed registry reads as "nothing is approved" rather than
    as an error: the strict reading is the safe one, and `record_skill_owner.py`
    is what validates the file when it writes it. For the same reason an entry
    whose `path` is not a string is dropped, not widened to the whole repo.
    """
    if not registry.is_file():
        return set()
    try:
        data = json.loads(registry.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return set()
    repos = data.get("repos") if isinstance(data, dict) else None
    if not isinstance(repos, list):
        return set()
    approvals = set()
    for entry in repos:
        if not isinstance(entry, dict) or not isinstance(entry.get("repo"), str):
            continue
        path = entry.get("path", "")
        if not isinstance(path, str):
            continue
        approvals.add((entry["repo"].strip().lower(), path.strip().strip("/")))
    return approvals


def is_approved(repo: str, skill_path: str, approvals: set[tuple[str, str]]) -> bool:
    """Whether some approval covers the skill at `skill_path` in `repo`.

    Compared segment by segment, so `projects/rocprofiler` does not cover
    `projects/rocprofiler-sdk`.
    """
    parts = skill_path.strip("/").split("/")
    for approved_repo, subdir in approvals:
        if approved_repo != repo.lower():
            continue
        if not subdir:
            return True
        scope = subdir.split("/")
        if parts[: len(scope)] == scope:
            return True
    return False


def source_ref(skill_dir: Path, branch: str) -> str:
    """A ref that github.com can browse for a skill tracked at `branch`.

    A pattern is not a ref, so it is swapped for the release branch the last
    import resolved it to, or for the repo's default branch before any import.
    """
    if not fed.branches.is_pattern(branch):
        return branch
    return fed.read_marker(skill_dir).get("resolved_ref") or "HEAD"


def vendored_edits(changed: list[str], declared: dict[str, dict]) -> list[dict]:
    """Group the changed paths that edit a vendored skill, by skill."""
    hits: dict[str, dict] = {}
    for path in changed:
        if not path.startswith(SKILLS_PREFIX):
            continue
        name, _, tail = path[len(SKILLS_PREFIX) :].partition("/")
        if not tail:
            # Something directly under `skills/`, not inside a skill.
            continue
        entry = declared.get(name)
        if entry is None:
            continue
        branch = entry.get("branch", fed.branches.DEFAULT_BRANCH)
        hit = hits.setdefault(
            name,
            {
                "skill": name,
                "repo": entry["repo"],
                "source_path": entry["path"],
                "branch": branch,
                "source_ref": entry.get("source_ref", branch),
                "paths": [],
            },
        )
        hit["paths"].append(path)
    for hit in hits.values():
        hit["paths"].sort()
    return [hits[name] for name in sorted(hits)]


def declared_skills(sources: list[fed.Source]) -> dict[tuple[str, str], dict]:
    """Map every declared skill to its (repo, local name) identity."""
    declared: dict[tuple[str, str], dict] = {}
    for source in sources:
        for spec in source.skills:
            key = (source.repo.lower(), spec.dest_name)
            declared[key] = {
                "skill": spec.dest_name,
                "repo": source.repo,
                "path": spec.path,
            }
    return declared


def catalog_skills(base_dir: Path) -> set[str]:
    """The skill folders the base branch ships under `skills/`."""
    skills_dir = base_dir / SKILLS_PREFIX
    if not skills_dir.is_dir():
        return set()
    return {p.name for p in skills_dir.iterdir() if (p / "SKILL.md").is_file()}


def new_skills_needing_approval(
    base: dict[tuple[str, str], dict],
    head: dict[tuple[str, str], dict],
    approvals: set[tuple[str, str]],
    catalog: set[str],
) -> list[dict]:
    declared_names = {name for _, name in base}

    def already_in_catalog(key: tuple[str, str]) -> bool:
        if key in base:
            return True
        name = key[1]
        return name in catalog and name not in declared_names

    return [
        entry
        for key, entry in sorted(head.items())
        if not already_in_catalog(key)
        and not is_approved(entry["repo"], entry["path"], approvals)
    ]


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument(
        "--changed-files",
        type=Path,
        required=True,
        metavar="PATH",
        help="File listing the pull request's changed paths, one per line.",
    )
    parser.add_argument(
        "--head-federation",
        type=Path,
        metavar="PATH",
        help=(
            "The pull request's copy of .github/federation.json. Omit to skip "
            "the product repo approval rule."
        ),
    )
    parser.add_argument(
        "--base-dir",
        type=Path,
        default=REPO_ROOT,
        help=f"Checkout of the base branch (default: {REPO_ROOT}).",
    )
    parser.add_argument(
        "--report",
        type=Path,
        metavar="PATH",
        help="Write the JSON report here (default: stdout only).",
    )
    return parser.parse_args(argv)


def build_report(args: argparse.Namespace) -> dict:
    base_dir = args.base_dir
    report: dict = {
        "vendored_edits": [],
        "new_skills_needing_approval": [],
        "federation_error": "",
    }

    # `.github/federation.json` on the base branch is the only thing that says
    # which skills are federated, so an unreadable one leaves both rules with
    # nothing to go on. Report that instead of passing every pull request.
    try:
        base_sources = fed.parse_federation(base_dir / ".github" / "federation.json")
    except (ValueError, FileNotFoundError) as exc:
        report["federation_error"] = f"base branch copy: {exc}"
        return report
    base = declared_skills(base_sources)
    branch_of = {source.repo: source.branch for source in base_sources}

    report["vendored_edits"] = vendored_edits(
        read_changed_files(args.changed_files),
        {
            entry["skill"]: {
                **entry,
                "branch": branch_of[entry["repo"]],
                "source_ref": source_ref(
                    base_dir / SKILLS_PREFIX / entry["skill"], branch_of[entry["repo"]]
                ),
            }
            for entry in base.values()
        },
    )

    if args.head_federation is None:
        return report

    try:
        head = declared_skills(fed.parse_federation(args.head_federation))
    except (ValueError, FileNotFoundError) as exc:
        # The `validate` workflow is what explains a malformed federation file
        # in detail; recording it here keeps this rule from passing a pull
        # request whose declarations could not be read at all.
        report["federation_error"] = str(exc)
        return report

    report["new_skills_needing_approval"] = new_skills_needing_approval(
        base,
        head,
        load_approvals(base_dir / ".github" / "skill_owners.json"),
        catalog_skills(base_dir),
    )
    return report


def print_summary(report: dict) -> None:
    edits = report["vendored_edits"]
    if edits:
        print("Vendored (federated) skills edited by this pull request:")
        for hit in edits:
            print(f"  skills/{hit['skill']} -- vendored from {hit['repo']}")
            for path in hit["paths"]:
                print(f"    {path}")

    if report["federation_error"]:
        print(f".github/federation.json could not be read: {report['federation_error']}")

    pending = report["new_skills_needing_approval"]
    if pending:
        print("New federated skills whose product repo is not approved:")
        for entry in pending:
            print(f"  {entry['skill']} from {entry['repo']} ({entry['path']})")

    if not edits and not pending and not report["federation_error"]:
        print("Nothing to flag: no vendored skill was edited and no new skill "
              "comes from an unapproved product repo.")


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    report = build_report(args)
    print_summary(report)
    if args.report:
        args.report.parent.mkdir(parents=True, exist_ok=True)
        args.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 445d12e584512c25c88af7d6d9bc02bcd4b5905f1438aae12b6419b6c9a34894