diff --git a/.github/agents-md-integrity/README.md b/.github/agents-md-integrity/README.md new file mode 100644 index 0000000..fa7bd2a --- /dev/null +++ b/.github/agents-md-integrity/README.md @@ -0,0 +1,25 @@ +# agents-md-integrity + +The checker behind the reusable +[`agents-md-integrity.yml`](../workflows/agents-md-integrity.yml) workflow. It +lives here as the single source of truth so consumer repos carry only a thin +caller; the workflow loads this script from a pinned ref of +`Comfy-Org/github-workflows` (never from the caller's checkout, so a PR can't +rewrite the check). + +- **`check_agents_md.py`** — the check. Operates on a repo tree and exits + non-zero (with GitHub annotations) when any hard check fails. Enforces the + Comfy `AGENTS.md` standard ("AGENTS.md, done right", Comfy Engineering Guide + §10): a thin top-level `AGENTS.md` source of truth under a hard line ceiling, + a one-line `@AGENTS.md` `CLAUDE.md` shim, no divergent `.cursorrules`, + per-subtree shims in monorepos, and a CODEOWNERS DRI. Inputs come from env + vars (`MAX_LINES`, `WARN_LINES`, `FORBID_CURSORRULES`, `CHECK_NESTED`, + `REQUIRE_CODEOWNERS`, `AGENTS_FILE`); see the workflow header for the mapping. +- **`tests/`** — `unittest` suite, run by + [`test-agents-md-integrity.yml`](../workflows/test-agents-md-integrity.yml). + +Run locally against any repo: + +```bash +python3 .github/agents-md-integrity/check_agents_md.py --root /path/to/repo +``` diff --git a/.github/agents-md-integrity/check_agents_md.py b/.github/agents-md-integrity/check_agents_md.py new file mode 100644 index 0000000..4662393 --- /dev/null +++ b/.github/agents-md-integrity/check_agents_md.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Check a repo's AGENTS.md against the Comfy AGENTS.md standard. + +The standard (Comfy Engineering Guide, "AGENTS.md, done right", §10): one thin +top-level `AGENTS.md` is the single source of truth, `CLAUDE.md` is a REQUIRED +one-line `@AGENTS.md` shim (optionally with a few Claude-only lines below — +Claude Code reads only CLAUDE.md and does not fall back), there are no +divergent `.cursorrules`, and the file stays under a hard line ceiling (200, +per Anthropic guidance) with an aspirational target (150). In a monorepo every +nested `AGENTS.md` gets a sibling `CLAUDE.md` shim so Claude Code picks it up in +that subtree, and the file is owned by a DRI via CODEOWNERS. + +This script enforces that mechanically so it can wire into CI as a required +status check. It operates on a checked-out repo tree (the CALLER's repo when +run from the reusable workflow) and exits non-zero when any hard check fails. + +Run locally: + python3 .github/agents-md-integrity/check_agents_md.py --root . +""" + +import argparse +import os +import re +import sys + +# CODEOWNERS is honored from exactly one location, in this precedence order +# (GitHub uses the first that exists). +CODEOWNERS_LOCATIONS = (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS") + +# Directories we never descend into when hunting for nested AGENTS.md files: +# vendored / generated / tooling trees that aren't part of the repo's own +# source and would produce noise (or enormous walks). +SKIP_DIRS = frozenset( + { + ".git", + "node_modules", + "vendor", + "dist", + "build", + ".next", + ".venv", + "venv", + "__pycache__", + ".claude", + ".cursor", + # The reusable workflow checks the caller repo out at the workspace root + # and this repo's script into a sibling `_agents_md_integrity/` dir; skip + # that so the checker never scans its own copy of this repo. + "_agents_md_integrity", + } +) + + +def _count_lines(path): + """Line count of a text file (a trailing newline doesn't add a phantom line).""" + with open(path, "r", encoding="utf-8", errors="replace") as f: + return len(f.read().splitlines()) + + +def _has_import(path, import_token): + """True if the file contains the `@AGENTS.md`-style import token on some line.""" + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + if import_token in line: + return True + return False + + +def _codeowners_pattern_to_regex(pattern): + """Translate one CODEOWNERS glob into an anchored full-match regex. + + Follows the gitignore-ish semantics GitHub uses: a leading `/` anchors to + the repo root; a pattern with no internal slash matches at any depth; a + trailing `/` matches everything beneath the directory; `*` matches within a + path segment, `**` across segments. + """ + anchored = pattern.startswith("/") + p = pattern[1:] if anchored else pattern + p = p.rstrip("/") + + # Build the body segment-safely so `*` and `**` get distinct meanings. + body = re.escape(p) + body = body.replace(r"\*\*", ".*").replace(r"\*", "[^/]*").replace(r"\?", "[^/]") + + # An unanchored pattern with no internal slash matches the basename at any + # depth; everything else anchors to the repo root. The trailing group lets + # a directory pattern also match files beneath it. + prefix = r"(?:.*/)?" if (not anchored and "/" not in p) else r"" + return re.compile(r"^" + prefix + body + r"(?:/.*)?$") + + +def _parse_codeowners(text): + """Yield (regex, has_owner) for each rule line, in file order.""" + rules = [] + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + pattern = parts[0] + owners = parts[1:] + try: + rules.append((_codeowners_pattern_to_regex(pattern), bool(owners))) + except re.error: + # A pattern we can't compile shouldn't crash the whole check. + continue + return rules + + +def _codeowners_owns(root, rel_path): + """Return (checked, owned): whether a CODEOWNERS file exists and, if so, + whether the last rule matching `rel_path` assigns an owner. + + Last-match-wins mirrors GitHub; a matching rule with no owners explicitly + unassigns, so it counts as *not* owned. + """ + for loc in CODEOWNERS_LOCATIONS: + full = os.path.join(root, loc) + if os.path.isfile(full): + with open(full, "r", encoding="utf-8", errors="replace") as f: + rules = _parse_codeowners(f.read()) + owned = False + for regex, has_owner in rules: + if regex.match(rel_path): + owned = has_owner # last match wins + return True, owned + return False, False + + +def _iter_nested_agents(root, agents_basename, top_level_rel): + """Yield repo-relative paths of every nested AGENTS.md (not the top-level one). + + `top_level_rel` is the configured agents_file path (normalized) so a pathful + value like `docs/AGENTS.md` isn't also re-checked here as a "nested" file. + """ + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + if agents_basename in filenames: + rel = os.path.relpath(os.path.join(dirpath, agents_basename), root) + if os.path.normpath(rel) != top_level_rel: # skip the top-level file + yield rel + + +def run_checks(root, config): + """Run every integrity check against `root`. + + Returns (failures, warnings): two lists of human-readable strings. An empty + `failures` list means the repo passes; warnings never fail the check. + """ + failures = [] + warnings = [] + + agents_file = config["agents_file"] + agents_basename = os.path.basename(agents_file) + import_token = "@" + agents_basename + max_lines = config["max_lines"] + warn_lines = config["warn_lines"] + + agents_path = os.path.join(root, agents_file) + + # 1. Exists. + if not os.path.isfile(agents_path): + failures.append( + f"'{agents_file}' not found at the repo root. It is the required " + f"source of truth for agent instructions." + ) + # Without the file, the line/shim/nested checks below have nothing to + # anchor on, but CODEOWNERS/cursorrules are still worth reporting, so + # keep going rather than returning early. + else: + # 2. Line ceiling (+ aspirational warn). + n = _count_lines(agents_path) + if n > max_lines: + failures.append( + f"'{agents_file}' is {n} lines, over the hard ceiling of " + f"{max_lines}. Trim it — AGENTS.md must stay thin." + ) + elif n > warn_lines: + warnings.append( + f"'{agents_file}' is {n} lines, over the aspirational target " + f"of {warn_lines} (hard ceiling {max_lines})." + ) + + # 3. CLAUDE.md shim. Claude Code reads only CLAUDE.md and does NOT fall + # back to AGENTS.md, so a missing root shim means the repo's instructions + # are invisible to it — the most common gap in the org audit. Only checked + # when the agents file itself exists (check 1 already fired otherwise; + # don't double-report). + claude_path = os.path.join(root, "CLAUDE.md") + if os.path.isfile(claude_path): + if not _has_import(claude_path, import_token): + failures.append( + f"'CLAUDE.md' exists but has no '{import_token}' import line — " + f"it is a divergent copy. Make it a thin shim whose first line " + f"is '{import_token}' (Claude-only notes may follow)." + ) + elif config["require_shim"] and os.path.isfile(agents_path): + failures.append( + f"no root 'CLAUDE.md' shim. Claude Code reads only 'CLAUDE.md' " + f"and does not fall back to '{agents_basename}', so this repo's " + f"agent instructions are invisible to it. Add a one-line shim " + f"containing '{import_token}'." + ) + + # 4. No legacy .cursorrules. + if config["forbid_cursorrules"]: + cursorrules_path = os.path.join(root, ".cursorrules") + if os.path.isfile(cursorrules_path): + failures.append( + "legacy '.cursorrules' file found at the repo root. Delete it — " + f"'{agents_file}' is the single source of truth." + ) + + # 5. Nested AGENTS.md (monorepo). + if config["check_nested"]: + top_level_rel = os.path.normpath(agents_file) + for rel in sorted(_iter_nested_agents(root, agents_basename, top_level_rel)): + nested_path = os.path.join(root, rel) + sibling_claude = os.path.join(os.path.dirname(nested_path), "CLAUDE.md") + if not ( + os.path.isfile(sibling_claude) + and _has_import(sibling_claude, import_token) + ): + failures.append( + f"nested '{rel}' has no sibling 'CLAUDE.md' containing " + f"'{import_token}', so Claude Code won't pick it up in that " + f"subtree. Add a one-line shim next to it." + ) + n = _count_lines(nested_path) + if n > max_lines: + failures.append( + f"nested '{rel}' is {n} lines, over the hard ceiling of " + f"{max_lines}." + ) + + # 6. CODEOWNERS / DRI (warn unless require_codeowners). + checked, owned = _codeowners_owns(root, agents_file) + if not owned: + if not checked: + msg = ( + f"no CODEOWNERS file found, so '{agents_file}' has no DRI. Add a " + f"CODEOWNERS rule assigning an owner." + ) + else: + msg = ( + f"'{agents_file}' is not matched by any CODEOWNERS rule (no " + f"owner/DRI). Add a rule so it has a single owner." + ) + if config["require_codeowners"]: + failures.append(msg) + else: + warnings.append(msg) + + return failures, warnings + + +def _env_bool(name, default): + val = os.environ.get(name) + if val is None or val == "": + return default + return val.strip().lower() in ("1", "true", "yes", "on") + + +def _env_int(name, default): + val = os.environ.get(name) + if val is None or val == "": + return default + try: + return int(val) + except ValueError: + return default + + +def _emit(failures, warnings): + """Print human lines plus GitHub Actions annotations, and return exit code.""" + for w in warnings: + print(f"WARN: {w}") + print(f"::warning::AGENTS.md integrity: {w}") + for f in failures: + print(f"FAIL: {f}") + print(f"::error::AGENTS.md integrity: {f}") + + if failures: + print(f"\nResult: {len(failures)} check(s) failed.") + return 1 + if warnings: + print(f"\nResult: passed with {len(warnings)} warning(s).") + else: + print("\nResult: AGENTS.md integrity OK.") + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Check AGENTS.md integrity.") + parser.add_argument( + "--root", + default=os.environ.get("AGENTS_CHECK_ROOT", "."), + help="Repo root to check (default: current directory).", + ) + args = parser.parse_args(argv) + + config = { + "agents_file": os.environ.get("AGENTS_FILE", "AGENTS.md") or "AGENTS.md", + "max_lines": _env_int("MAX_LINES", 200), + "warn_lines": _env_int("WARN_LINES", 150), + "forbid_cursorrules": _env_bool("FORBID_CURSORRULES", True), + "check_nested": _env_bool("CHECK_NESTED", True), + "require_shim": _env_bool("REQUIRE_SHIM", True), + "require_codeowners": _env_bool("REQUIRE_CODEOWNERS", False), + } + + print(f"Checking AGENTS.md integrity in '{args.root}'...\n") + failures, warnings = run_checks(args.root, config) + return _emit(failures, warnings) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/agents-md-integrity/tests/test_check_agents_md.py b/.github/agents-md-integrity/tests/test_check_agents_md.py new file mode 100644 index 0000000..7c6172b --- /dev/null +++ b/.github/agents-md-integrity/tests/test_check_agents_md.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Tests for check_agents_md.py. + +Each case builds a throwaway repo tree in a tempdir and asserts which hard +checks fire (failures) vs which only warn. Covers at least one fully-passing +repo and one repo that trips every hard check. + +Run: python3 .github/agents-md-integrity/tests/test_check_agents_md.py +""" + +import importlib.util +import os +import tempfile +import unittest + +_MODULE_PATH = os.path.join(os.path.dirname(__file__), "..", "check_agents_md.py") +_spec = importlib.util.spec_from_file_location("check_agents_md", _MODULE_PATH) +cam = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(cam) + + +DEFAULT_CONFIG = { + "agents_file": "AGENTS.md", + "max_lines": 200, + "warn_lines": 150, + "forbid_cursorrules": True, + "check_nested": True, + "require_shim": True, + "require_codeowners": False, +} + + +def _config(**overrides): + cfg = dict(DEFAULT_CONFIG) + cfg.update(overrides) + return cfg + + +def _write(root, rel, text): + path = os.path.join(root, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + + +class CheckAgentsMdTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = self._tmp.name + + def tearDown(self): + self._tmp.cleanup() + + def _run(self, **overrides): + return cam.run_checks(self.root, _config(**overrides)) + + # --- passing case ----------------------------------------------------- + + def test_fully_compliant_repo_passes(self): + _write(self.root, "AGENTS.md", "\n".join(f"line {i}" for i in range(120))) + _write(self.root, "CLAUDE.md", "@AGENTS.md\n") + _write(self.root, ".github/CODEOWNERS", "/AGENTS.md @comfy-org/backend\n") + # A well-formed nested package. + _write(self.root, "packages/api/AGENTS.md", "nested\n") + _write(self.root, "packages/api/CLAUDE.md", "@AGENTS.md\n") + + failures, warnings = self._run() + self.assertEqual(failures, []) + self.assertEqual(warnings, []) + + def test_warn_line_target_is_not_a_failure(self): + # 170 lines: over warn_lines (150), under max_lines (200). + _write(self.root, "AGENTS.md", "\n".join(f"l{i}" for i in range(170))) + _write(self.root, "CLAUDE.md", "@AGENTS.md\n") + _write(self.root, "CODEOWNERS", "* @owner\n") + + failures, warnings = self._run() + self.assertEqual(failures, []) + self.assertEqual(len(warnings), 1) + self.assertIn("aspirational target", warnings[0]) + + # --- failing cases ---------------------------------------------------- + + def test_missing_agents_md_fails(self): + failures, _ = self._run() + self.assertTrue(any("not found at the repo root" in f for f in failures)) + + def test_every_hard_check_can_fail_at_once(self): + # Over the ceiling. + _write(self.root, "AGENTS.md", "\n".join(f"l{i}" for i in range(250))) + # Divergent CLAUDE.md (no import). + _write(self.root, "CLAUDE.md", "totally different instructions\n") + # Legacy cursorrules. + _write(self.root, ".cursorrules", "old rules\n") + # Nested AGENTS.md, no sibling shim, also over the ceiling. + _write( + self.root, + "packages/web/AGENTS.md", + "\n".join(f"l{i}" for i in range(300)), + ) + # No CODEOWNERS -> require_codeowners escalates to a failure. + failures, warnings = self._run(require_codeowners=True) + + joined = "\n".join(failures) + self.assertIn("over the hard ceiling", joined) # top-level line ceiling + self.assertIn("divergent copy", joined) # CLAUDE.md shim + self.assertIn(".cursorrules", joined) # legacy file + self.assertIn("no sibling 'CLAUDE.md'", joined) # nested shim + self.assertIn("packages/web/AGENTS.md' is 300 lines", joined) # nested ceiling + self.assertTrue(any("DRI" in f for f in failures)) # CODEOWNERS as failure + + def test_divergent_claude_md_fails(self): + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CLAUDE.md", "no import here\n") + _write(self.root, "CODEOWNERS", "* @o\n") + failures, _ = self._run() + self.assertTrue(any("divergent copy" in f for f in failures)) + + def test_claude_md_shim_with_extra_lines_passes(self): + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CLAUDE.md", "@AGENTS.md\n\nClaude-only note.\n") + _write(self.root, "CODEOWNERS", "* @o\n") + failures, _ = self._run() + self.assertEqual(failures, []) + + def test_missing_claude_md_fails(self): + # The shim is REQUIRED: Claude Code reads only CLAUDE.md and does not + # fall back to AGENTS.md, so its absence hides the instructions. + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CODEOWNERS", "* @o\n") + failures, _ = self._run() + self.assertTrue(any("no root 'CLAUDE.md' shim" in f for f in failures)) + + def test_missing_claude_md_passes_when_require_shim_off(self): + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CODEOWNERS", "* @o\n") + failures, _ = self._run(require_shim=False) + self.assertEqual(failures, []) + + def test_missing_agents_md_does_not_also_report_missing_shim(self): + # Empty repo: check 1 (AGENTS.md missing) fires; the shim check stays + # quiet rather than piling a second failure on the same root cause. + failures, _ = self._run() + self.assertEqual(len([f for f in failures if "CLAUDE.md" in f]), 0) + + def test_cursorrules_gate_off(self): + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CLAUDE.md", "@AGENTS.md\n") + _write(self.root, ".cursorrules", "rules\n") + _write(self.root, "CODEOWNERS", "* @o\n") + failures, _ = self._run(forbid_cursorrules=False) + self.assertEqual(failures, []) + + def test_nested_gate_off(self): + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CLAUDE.md", "@AGENTS.md\n") + _write(self.root, "CODEOWNERS", "* @o\n") + _write(self.root, "packages/x/AGENTS.md", "nested, no shim\n") + failures, _ = self._run(check_nested=False) + self.assertEqual(failures, []) + + def test_nested_scan_skips_vendored_dirs(self): + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CLAUDE.md", "@AGENTS.md\n") + _write(self.root, "CODEOWNERS", "* @o\n") + # A vendored AGENTS.md must not trip the nested check. + _write(self.root, "node_modules/pkg/AGENTS.md", "vendored\n") + failures, _ = self._run() + self.assertEqual(failures, []) + + def test_codeowners_missing_warns_by_default(self): + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CLAUDE.md", "@AGENTS.md\n") + failures, warnings = self._run() + self.assertEqual(failures, []) + self.assertTrue(any("no CODEOWNERS file" in w for w in warnings)) + + def test_codeowners_unmatched_warns(self): + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CLAUDE.md", "@AGENTS.md\n") + _write(self.root, ".github/CODEOWNERS", "/src/ @team\n") + failures, warnings = self._run() + self.assertEqual(failures, []) + self.assertTrue(any("not matched by any CODEOWNERS" in w for w in warnings)) + + def test_codeowners_wildcard_matches(self): + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CLAUDE.md", "@AGENTS.md\n") + _write(self.root, "CODEOWNERS", "* @default-team\n") + failures, warnings = self._run(require_codeowners=True) + self.assertEqual(failures, []) + self.assertEqual(warnings, []) + + def test_codeowners_last_match_wins_unassign(self): + # A later, more specific rule with NO owner unassigns AGENTS.md. + _write(self.root, "AGENTS.md", "thin\n") + _write(self.root, "CLAUDE.md", "@AGENTS.md\n") + _write(self.root, "CODEOWNERS", "* @default\n/AGENTS.md\n") + failures, warnings = self._run() + self.assertTrue(any("not matched by any CODEOWNERS" in w for w in warnings)) + + def test_custom_agents_file_name(self): + _write(self.root, "GUIDELINES.md", "thin\n") + _write(self.root, "CLAUDE.md", "@GUIDELINES.md\n") + _write(self.root, "CODEOWNERS", "* @o\n") + failures, _ = self._run(agents_file="GUIDELINES.md") + self.assertEqual(failures, []) + + def test_pathful_agents_file_not_double_checked_as_nested(self): + # A pathful agents_file must be checked as the top-level file only, not + # also flagged as a shim-less nested file. + _write(self.root, "docs/AGENTS.md", "thin\n") + _write(self.root, "CODEOWNERS", "* @o\n") + failures, _ = self._run(agents_file="docs/AGENTS.md", require_shim=False) + self.assertEqual(failures, []) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/workflows/agents-md-integrity.yml b/.github/workflows/agents-md-integrity.yml new file mode 100644 index 0000000..1890bff --- /dev/null +++ b/.github/workflows/agents-md-integrity.yml @@ -0,0 +1,154 @@ +name: AGENTS.md Integrity (reusable) + +# Reusable workflow that checks a repo's AGENTS.md against the Comfy AGENTS.md +# standard (Comfy Engineering Guide, "AGENTS.md, done right", §10): one thin +# top-level AGENTS.md as the single source of truth, a one-line `@AGENTS.md` +# CLAUDE.md shim, no divergent `.cursorrules`, a hard under-200-line ceiling +# (Anthropic guidance) with an aspirational 150-line target, per-subtree shims +# in monorepos, and a CODEOWNERS DRI. It runs in the CALLER's context, so +# `actions/checkout` checks out the calling repo and the checks operate on that +# repo's files. Fails with a non-zero exit (and GitHub annotations) so it can +# be wired as a required status check. +# +# The check script is loaded from THIS repo (public, pinned via `workflows_ref`) +# — never from the caller's checkout — so a PR can't rewrite the checker. +# +# Caller pattern (place in the consumer repo at +# .github/workflows/agents-md-integrity.yml): +# +# name: AGENTS.md Integrity +# on: +# pull_request: +# push: +# branches: [main, master] +# jobs: +# agents-md: +# permissions: +# contents: read +# uses: Comfy-Org/github-workflows/.github/workflows/agents-md-integrity.yml@ # v1 +# with: +# workflows_ref: # pin the checker to the same ref as `uses:` +# +# INPUTS (all optional): +# max_lines Hard line ceiling for AGENTS.md; over it FAILS. +# Default 200 (official Anthropic guidance). +# warn_lines Aspirational target; over it emits a non-fatal WARNING +# (but at/under max_lines still passes). Default 150. +# forbid_cursorrules When true, a legacy root `.cursorrules` file FAILS the +# check (AGENTS.md is the single source of truth). +# Default true. +# check_nested When true, every nested `AGENTS.md` (monorepo subtree) +# must have a sibling `CLAUDE.md` containing `@AGENTS.md` +# and must itself be <= max_lines. Default true. +# require_shim When true, a missing root `CLAUDE.md` FAILS whenever the +# agents file exists (Claude Code reads only CLAUDE.md and +# does not fall back, so no shim = invisible instructions). +# Default true. +# require_codeowners Controls the CODEOWNERS/DRI check severity. When false +# (default) an unowned AGENTS.md only WARNS; when true it +# FAILS. Default false (warn only). +# agents_file Path to the agents file to check. Default `AGENTS.md`. +# workflows_ref Ref of Comfy-Org/github-workflows to load the check +# script from. Pin this to the same ref you pin `uses:` +# to for reproducibility. Default `main`. +# +# No secrets required. + +on: + workflow_call: + inputs: + max_lines: + description: Hard line ceiling for AGENTS.md (over it FAILS). Default 200. + type: number + required: false + default: 200 + warn_lines: + description: >- + Aspirational line target (over it WARNS, but at/under max_lines still + passes). Default 150. + type: number + required: false + default: 150 + forbid_cursorrules: + description: >- + Fail when a legacy root `.cursorrules` file exists. Default true. + type: boolean + required: false + default: true + check_nested: + description: >- + Check nested (monorepo) AGENTS.md files for a sibling `@AGENTS.md` + CLAUDE.md shim and the line ceiling. Default true. + type: boolean + required: false + default: true + require_shim: + description: >- + Fail when the root `CLAUDE.md` shim is missing while the agents file + exists (Claude Code reads only CLAUDE.md, no fallback). Default true. + type: boolean + required: false + default: true + require_codeowners: + description: >- + Escalate the CODEOWNERS/DRI check from a warning to a failure. Default + false (an unowned AGENTS.md only warns). + type: boolean + required: false + default: false + agents_file: + description: Path to the agents file to check. Default `AGENTS.md`. + type: string + required: false + default: AGENTS.md + workflows_ref: + description: >- + Ref of Comfy-Org/github-workflows to load the check script from. Pin + this to the same ref you pin `uses:` to for reproducibility. + type: string + required: false + default: main + +permissions: + contents: read + +jobs: + check: + name: AGENTS.md integrity + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout caller repo + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Load integrity check script + # The checker comes from THIS repo (public, pinned via workflows_ref), + # never from the caller's checkout, so a PR can't rewrite the check. + uses: actions/checkout@v6 + with: + repository: Comfy-Org/github-workflows + ref: ${{ inputs.workflows_ref }} + path: _agents_md_integrity + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Check AGENTS.md integrity + env: + AGENTS_CHECK_ROOT: ${{ github.workspace }} + MAX_LINES: ${{ inputs.max_lines }} + WARN_LINES: ${{ inputs.warn_lines }} + FORBID_CURSORRULES: ${{ inputs.forbid_cursorrules }} + CHECK_NESTED: ${{ inputs.check_nested }} + REQUIRE_SHIM: ${{ inputs.require_shim }} + REQUIRE_CODEOWNERS: ${{ inputs.require_codeowners }} + AGENTS_FILE: ${{ inputs.agents_file }} + run: | + python3 "$GITHUB_WORKSPACE/_agents_md_integrity/.github/agents-md-integrity/check_agents_md.py" \ + --root "$AGENTS_CHECK_ROOT" diff --git a/.github/workflows/test-agents-md-integrity.yml b/.github/workflows/test-agents-md-integrity.yml new file mode 100644 index 0000000..d9527a0 --- /dev/null +++ b/.github/workflows/test-agents-md-integrity.yml @@ -0,0 +1,61 @@ +name: Test agents-md-integrity script + +# Runs the unit tests for the AGENTS.md integrity checker plus a CLI smoke test +# that exercises one passing and one failing repo fixture end-to-end. The +# checker gates every consumer repo's CI, so a regression here silently breaks +# their required status check — cheap to guard with a unit + smoke run on change. + +on: + pull_request: + paths: + - '.github/agents-md-integrity/**' + - '.github/workflows/test-agents-md-integrity.yml' + - '.github/workflows/agents-md-integrity.yml' + push: + branches: [main] + paths: + - '.github/agents-md-integrity/**' + - '.github/workflows/test-agents-md-integrity.yml' + - '.github/workflows/agents-md-integrity.yml' + +permissions: + contents: read + +jobs: + test: + name: unittest + smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Run agents-md-integrity unit tests + run: python3 -m unittest discover -s .github/agents-md-integrity/tests -p 'test_*.py' -v + + - name: Smoke test — a compliant repo passes + run: | + root="$(mktemp -d)" + printf 'thin agents file\n' > "$root/AGENTS.md" + printf '@AGENTS.md\n' > "$root/CLAUDE.md" + mkdir -p "$root/.github" + printf '* @comfy-org/backend\n' > "$root/.github/CODEOWNERS" + python3 .github/agents-md-integrity/check_agents_md.py --root "$root" + + - name: Smoke test — a drifted repo fails + run: | + root="$(mktemp -d)" + python3 -c "open('$root/AGENTS.md','w').write(chr(10).join('l%d'%i for i in range(250)))" + printf 'divergent copy, no import\n' > "$root/CLAUDE.md" + printf 'legacy rules\n' > "$root/.cursorrules" + if python3 .github/agents-md-integrity/check_agents_md.py --root "$root"; then + echo "::error::checker passed a drifted repo — expected a non-zero exit" + exit 1 + fi + echo "checker correctly failed the drifted repo" diff --git a/README.md b/README.md index 216cb65..3a97f10 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This repo is **public** so any repo — public or private, inside or outside the | [`cursor-review-auto-label.yml`](.github/workflows/cursor-review-auto-label.yml) | Companion to `cursor-review.yml`. On PR assignment, applies the review label for an opted-in reviewer (via the CLOUD_CODE_BOT app token, so the label actually triggers the review). The opt-in roster lives in the caller's `vars.CURSOR_REVIEW_OPTED_IN_LOGINS` — no roster is baked into the workflow. Requires `vars.APP_ID` + `CLOUD_CODE_BOT_PRIVATE_KEY`. | | [`assign-reviewers.yml`](.github/workflows/assign-reviewers.yml) | Auto-requests expertise-aware, load-balanced PR reviewers with new-folk randomization. Matches changed paths against a caller-repo `.github/reviewers.yml` (path-glob → reviewers, plus a `default_pool`), drops the author + `vars.REVIEWER_EXCLUDE`, ranks candidates by open review load (steering off anyone at/over `vars.REVIEWER_LOAD_CAP`), and may swap a slot for a `vars.REVIEWER_GROWTH_POOL` member. Requests go through the CLOUD_CODE_BOT app token so they work on fork PRs. Requires `vars.APP_ID` + `CLOUD_CODE_BOT_PRIVATE_KEY`. | | [`assign-prs-to-author.yml`](.github/workflows/assign-prs-to-author.yml) | Housekeeping — assigns every open PR with no assignees to its author (bot-authored PRs skipped by default). Run on a schedule from a thin caller; useful when a team tracks PR ownership via assignees. The calling job needs `pull-requests: write` and `issues: write`. | +| [`agents-md-integrity.yml`](.github/workflows/agents-md-integrity.yml) | Enforces the Comfy `AGENTS.md` standard on the caller repo: a top-level `AGENTS.md` must exist and stay under a hard line ceiling (`max_lines`, default 200; warns over `warn_lines`, default 150), a `CLAUDE.md` (if present) must be a thin `@AGENTS.md` shim rather than a divergent copy, no legacy `.cursorrules` (gated `forbid_cursorrules`), every nested monorepo `AGENTS.md` needs a sibling `@AGENTS.md` shim and to be under the ceiling (gated `check_nested`), and `AGENTS.md` should have a CODEOWNERS DRI (`require_codeowners`, warn-only by default). Fails with a non-zero exit + GitHub annotations so it wires in as a required status check. The checker lives in [`.github/agents-md-integrity/`](.github/agents-md-integrity) (pin `workflows_ref` to the same ref as `uses:`); no secrets required. | ## Usage