← Files AMDARCHIVED FILE
.github/scripts/changed_skills.py
4.96 KB · Sep 30, 2026 · 23:13 UTC
#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Select which skills SkillSpector should scan based on a git diff.
The SkillSpector per-skill matrix used to fan out over *every* skill on every
run, so a one-line edit to a single skill triggered a full re-scan of the whole
catalog. This script narrows the matrix to just the skills that actually
changed.
Behaviour:
* Diff the working tree against ``--base`` and collect the changed paths.
* If any *infra* path changed (this script, the SkillSpector workflow, the
gate, or the allowlist), scan EVERY skill -- a change to the scanning
machinery can affect the result for all skills.
* Otherwise, scan only the skills with changes under ``skills/<name>/``.
* Print the selected skill names as a compact JSON array (for a CI matrix).
The array may be empty, in which case there is nothing to scan.
If the base ref is missing or unresolvable (a manual ``workflow_dispatch`` run,
a brand-new branch with no parent, a shallow checkout, ...), fall back to
scanning every skill so we never silently skip a scan.
Usage:
uv run .github/scripts/changed_skills.py --base "$BASE_SHA"
uv run .github/scripts/changed_skills.py --base origin/main --skills-dir skills
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_SKILLS_DIR = REPO_ROOT / "skills"
# Paths that, when touched, force a full re-scan of every skill because they
# change the scanning machinery itself rather than a single skill's content.
INFRA_PATHS = (
".github/workflows/skillspector.yml",
".github/scripts/skillspector_gate.py",
".github/scripts/changed_skills.py",
".github/skillspector-allow.yml",
)
def discover_skills(root: Path) -> list[str]:
"""List skill directory names under `root`, ignoring dotfiles."""
if not root.exists():
return []
return sorted(
p.name for p in root.iterdir() if p.is_dir() and not p.name.startswith(".")
)
def _ref_exists(ref: str) -> bool:
"""Return True if `ref` resolves to a commit in the local repo."""
result = subprocess.run(
["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
)
return result.returncode == 0
def changed_paths(base: str) -> list[str]:
"""Return paths changed between `base` and the working tree's HEAD.
Uses a three-dot diff so the comparison is against the merge base of
`base` and HEAD -- the set of changes introduced on this branch/PR.
"""
result = subprocess.run(
["git", "diff", "--name-only", f"{base}...HEAD"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
)
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def select_skills(base: str | None, skills_dir: Path) -> list[str]:
"""Pick the skills to scan for the given diff base.
Returns every skill when the base is unusable or when infra changed, and
only the changed skills otherwise.
"""
all_skills = discover_skills(skills_dir)
if not base or not _ref_exists(base):
print(
f"Base ref {base!r} is missing or unresolvable; scanning all skills.",
file=sys.stderr,
)
return all_skills
try:
paths = changed_paths(base)
except subprocess.CalledProcessError as exc:
print(
f"git diff against {base!r} failed ({exc}); scanning all skills.",
file=sys.stderr,
)
return all_skills
if any(p in INFRA_PATHS for p in paths):
print("SkillSpector infra changed; scanning all skills.", file=sys.stderr)
return all_skills
prefix = f"{skills_dir.relative_to(REPO_ROOT).as_posix()}/"
changed: set[str] = set()
known = set(all_skills)
for path in paths:
if not path.startswith(prefix):
continue
name = path[len(prefix) :].split("/", 1)[0]
# Only scan skills that still exist on disk (skip pure deletions).
if name in known:
changed.add(name)
return sorted(changed)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--base",
default="",
help="Git ref/SHA to diff against. Empty or unresolvable means "
"scan every skill.",
)
parser.add_argument(
"--skills-dir",
type=Path,
default=DEFAULT_SKILLS_DIR,
help=f"Directory containing skill folders (default: {DEFAULT_SKILLS_DIR}).",
)
args = parser.parse_args(argv)
skills = select_skills(args.base, args.skills_dir.resolve())
print(json.dumps(skills, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 11bb396c49a371dceae72bf1e3a88bd1cf9040c1871b028b0a489680985b9440