Skip to content

Commit c9581d6

Browse files
authored
Merge pull request #3 from AV-Lab/copilot/ensure-pr-guidelines-are-met
Add CI workflow validating PRs against README plugin guidelines
2 parents 9154998 + 085f9bf commit c9581d6

4 files changed

Lines changed: 416 additions & 28 deletions

File tree

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
#!/usr/bin/env python3
2+
"""Validate `plugins.yaml` against the registry guidelines documented in README.md.
3+
4+
This script enforces the contribution guidelines described in the project README:
5+
6+
* `plugins.yaml` is valid YAML and has the expected top-level structure.
7+
* Every plugin entry has the required fields with the correct types.
8+
* `name` is unique, uses snake_case (no spaces).
9+
* `category` is one of the standard categories listed in the README.
10+
* `description` is short (<= 100 characters as recommended by the guidelines).
11+
* `repository` is a valid public Git URL (http(s) or git@).
12+
* `version` is either `latest` or looks like a (semver-ish) tag.
13+
* Entries are sorted alphabetically by `name`.
14+
* The `Available Plugins` table in `README.md` stays in sync with `plugins.yaml`.
15+
* Optionally (when `--check-remote` is given and `GITHUB_TOKEN` is available),
16+
each referenced GitHub repository is reachable, public, has a README,
17+
has a LICENSE, and — when `version` is not `latest` — exposes a tag/release
18+
matching the declared version.
19+
20+
Exits with status 0 when all checks pass, 1 otherwise. All discovered problems
21+
are printed to stdout to make the GitHub Actions log easy to read.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import argparse
27+
import json
28+
import os
29+
import re
30+
import sys
31+
import urllib.error
32+
import urllib.request
33+
from pathlib import Path
34+
from typing import Any
35+
36+
import yaml
37+
38+
39+
REQUIRED_FIELDS: dict[str, type] = {
40+
"name": str,
41+
"description": str,
42+
"repository": str,
43+
"version": str,
44+
"author": str,
45+
"category": str,
46+
}
47+
OPTIONAL_FIELDS: dict[str, type] = {
48+
"tags": list,
49+
}
50+
51+
ALLOWED_CATEGORIES = {
52+
"PerceptionStrategy",
53+
"LocalizationStrategy",
54+
"MappingStrategy",
55+
"PlanningStrategy",
56+
"ControlStrategy",
57+
"Executer",
58+
"WorldBridge",
59+
}
60+
61+
NAME_RE = re.compile(r"^[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*$")
62+
# The README asks for snake_case names with no spaces. Existing entries
63+
# (e.g. `ORBit_perception`) mix cases, so we accept letters and digits
64+
# separated by underscores rather than enforcing strict lowercase.
65+
VERSION_RE = re.compile(r"^(latest|v?\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)$")
66+
URL_RE = re.compile(r"^(https?://|git@)[\w.@:/\-~]+?(?:\.git)?/?$")
67+
GITHUB_URL_RE = re.compile(
68+
r"^https?://github\.com/(?P<owner>[\w.\-]+)/(?P<repo>[\w.\-]+?)(?:\.git)?/?$"
69+
)
70+
DESCRIPTION_MAX_LEN = 100
71+
72+
REPO_ROOT = Path(__file__).resolve().parents[2]
73+
PLUGINS_YAML = REPO_ROOT / "plugins.yaml"
74+
75+
76+
class Problems:
77+
def __init__(self) -> None:
78+
self.errors: list[str] = []
79+
self.warnings: list[str] = []
80+
81+
def error(self, msg: str) -> None:
82+
self.errors.append(msg)
83+
84+
def warn(self, msg: str) -> None:
85+
self.warnings.append(msg)
86+
87+
def report(self) -> int:
88+
for w in self.warnings:
89+
print(f"::warning::{w}")
90+
for e in self.errors:
91+
print(f"::error::{e}")
92+
if self.errors:
93+
print(f"\nValidation failed with {len(self.errors)} error(s) "
94+
f"and {len(self.warnings)} warning(s).")
95+
return 1
96+
print(f"Validation passed ({len(self.warnings)} warning(s)).")
97+
return 0
98+
99+
100+
def load_plugins(problems: Problems) -> list[dict[str, Any]]:
101+
if not PLUGINS_YAML.is_file():
102+
problems.error(f"{PLUGINS_YAML} does not exist")
103+
return []
104+
try:
105+
data = yaml.safe_load(PLUGINS_YAML.read_text())
106+
except yaml.YAMLError as exc:
107+
problems.error(f"plugins.yaml is not valid YAML: {exc}")
108+
return []
109+
if not isinstance(data, dict) or "plugins" not in data:
110+
problems.error("plugins.yaml must be a mapping with a top-level `plugins` key")
111+
return []
112+
plugins = data["plugins"]
113+
if not isinstance(plugins, list):
114+
problems.error("`plugins` must be a list")
115+
return []
116+
return plugins
117+
118+
119+
def validate_entry(idx: int, entry: Any, problems: Problems) -> None:
120+
label = f"plugins[{idx}]"
121+
if not isinstance(entry, dict):
122+
problems.error(f"{label} must be a mapping, got {type(entry).__name__}")
123+
return
124+
125+
name = entry.get("name", f"<index {idx}>")
126+
label = f"plugin `{name}`"
127+
128+
# Unknown fields
129+
known = set(REQUIRED_FIELDS) | set(OPTIONAL_FIELDS)
130+
for key in entry:
131+
if key not in known:
132+
problems.warn(f"{label}: unknown field `{key}`")
133+
134+
# Required fields presence + type
135+
for field, expected in REQUIRED_FIELDS.items():
136+
if field not in entry:
137+
problems.error(f"{label}: missing required field `{field}`")
138+
continue
139+
value = entry[field]
140+
if not isinstance(value, expected) or (isinstance(value, str) and not value.strip()):
141+
problems.error(f"{label}: field `{field}` must be a non-empty {expected.__name__}")
142+
143+
# Optional fields type
144+
for field, expected in OPTIONAL_FIELDS.items():
145+
if field in entry and not isinstance(entry[field], expected):
146+
problems.error(f"{label}: field `{field}` must be a {expected.__name__}")
147+
148+
if "tags" in entry and isinstance(entry["tags"], list):
149+
for i, tag in enumerate(entry["tags"]):
150+
if not isinstance(tag, str) or not tag.strip():
151+
problems.error(f"{label}: tags[{i}] must be a non-empty string")
152+
153+
# Name format
154+
if isinstance(entry.get("name"), str):
155+
if " " in entry["name"]:
156+
problems.error(f"{label}: `name` must not contain spaces")
157+
elif not NAME_RE.match(entry["name"]):
158+
problems.error(
159+
f"{label}: `name` must be snake_case "
160+
"(letters/digits separated by underscores)"
161+
)
162+
163+
# Description length
164+
desc = entry.get("description")
165+
if isinstance(desc, str) and len(desc) > DESCRIPTION_MAX_LEN:
166+
problems.warn(
167+
f"{label}: `description` is {len(desc)} characters, "
168+
f"keep it under {DESCRIPTION_MAX_LEN}"
169+
)
170+
171+
# Category
172+
cat = entry.get("category")
173+
if isinstance(cat, str) and cat not in ALLOWED_CATEGORIES:
174+
problems.error(
175+
f"{label}: category `{cat}` is not one of "
176+
f"{sorted(ALLOWED_CATEGORIES)}"
177+
)
178+
179+
# Repository URL
180+
repo = entry.get("repository")
181+
if isinstance(repo, str) and not URL_RE.match(repo):
182+
problems.error(f"{label}: `repository` is not a valid URL: {repo!r}")
183+
184+
# Version format
185+
version = entry.get("version")
186+
if isinstance(version, str) and not VERSION_RE.match(version):
187+
problems.warn(
188+
f"{label}: `version` {version!r} is not `latest` and does not look "
189+
"like a semver tag (e.g. `1.2.0` or `v1.2.0`)"
190+
)
191+
192+
193+
def validate_collection(plugins: list[dict[str, Any]], problems: Problems) -> None:
194+
names = [p.get("name") for p in plugins if isinstance(p, dict) and isinstance(p.get("name"), str)]
195+
196+
# Uniqueness
197+
seen: dict[str, int] = {}
198+
for n in names:
199+
seen[n] = seen.get(n, 0) + 1
200+
for n, count in seen.items():
201+
if count > 1:
202+
problems.error(f"Duplicate plugin name `{n}` appears {count} times")
203+
204+
# Alphabetical sort (case-insensitive, as the README asks for sorted entries)
205+
sorted_names = sorted(names, key=str.lower)
206+
if names != sorted_names:
207+
out_of_order = [
208+
f"{a!r} should come after {b!r}"
209+
for a, b in zip(names, sorted_names)
210+
if a != b
211+
]
212+
problems.error(
213+
"plugins.yaml entries must be sorted alphabetically by `name`. "
214+
f"First mismatch: {out_of_order[0] if out_of_order else 'unknown'}"
215+
)
216+
217+
218+
def parse_readme_table(problems: Problems) -> list[dict[str, str]] | None:
219+
# Deprecated: the README no longer mirrors plugins.yaml in a table.
220+
return None
221+
222+
223+
def validate_readme_in_sync(
224+
plugins: list[dict[str, Any]], problems: Problems
225+
) -> None:
226+
# Deprecated: README no longer lists individual plugins. Kept as a no-op
227+
# to preserve the public function surface.
228+
return
229+
230+
231+
# ---------------------------------------------------------------------------
232+
# Optional remote checks (GitHub API)
233+
# ---------------------------------------------------------------------------
234+
235+
def _gh_get(path: str, token: str | None) -> tuple[int, Any]:
236+
url = f"https://api.github.com{path}"
237+
req = urllib.request.Request(url, headers={
238+
"Accept": "application/vnd.github+json",
239+
"User-Agent": "avlite-plugins-validator",
240+
})
241+
if token:
242+
req.add_header("Authorization", f"Bearer {token}")
243+
try:
244+
with urllib.request.urlopen(req, timeout=15) as resp:
245+
return resp.status, json.loads(resp.read() or b"null")
246+
except urllib.error.HTTPError as exc:
247+
body: Any = None
248+
try:
249+
body = json.loads(exc.read() or b"null")
250+
except Exception:
251+
body = None
252+
return exc.code, body
253+
except (urllib.error.URLError, TimeoutError) as exc:
254+
return 0, str(exc)
255+
256+
257+
def validate_remote(plugins: list[dict[str, Any]], problems: Problems) -> None:
258+
token = os.environ.get("GITHUB_TOKEN")
259+
if not token:
260+
problems.warn(
261+
"GITHUB_TOKEN is not set; remote repository checks will be unauthenticated "
262+
"and may be rate-limited."
263+
)
264+
265+
for entry in plugins:
266+
if not isinstance(entry, dict):
267+
continue
268+
name = entry.get("name", "<unknown>")
269+
repo_url = entry.get("repository", "")
270+
version = entry.get("version", "")
271+
if not isinstance(repo_url, str):
272+
continue
273+
m = GITHUB_URL_RE.match(repo_url)
274+
if not m:
275+
problems.warn(
276+
f"plugin `{name}`: repository {repo_url!r} is not a github.com URL; "
277+
"skipping remote checks"
278+
)
279+
continue
280+
owner, repo = m.group("owner"), m.group("repo")
281+
282+
status, payload = _gh_get(f"/repos/{owner}/{repo}", token)
283+
if status == 0:
284+
problems.warn(f"plugin `{name}`: could not reach GitHub ({payload})")
285+
continue
286+
if status == 404:
287+
problems.error(
288+
f"plugin `{name}`: repository {repo_url} is not accessible (404). "
289+
"It must be public."
290+
)
291+
continue
292+
if status >= 400 or not isinstance(payload, dict):
293+
problems.warn(
294+
f"plugin `{name}`: GitHub API returned {status} for {repo_url}"
295+
)
296+
continue
297+
if payload.get("private"):
298+
problems.error(f"plugin `{name}`: repository {repo_url} is private")
299+
if not payload.get("license"):
300+
problems.error(
301+
f"plugin `{name}`: repository {repo_url} has no detected LICENSE"
302+
)
303+
304+
# README presence
305+
status, _ = _gh_get(f"/repos/{owner}/{repo}/readme", token)
306+
if status == 404:
307+
problems.error(
308+
f"plugin `{name}`: repository {repo_url} has no README"
309+
)
310+
elif status >= 400 and status != 0:
311+
problems.warn(
312+
f"plugin `{name}`: could not verify README (HTTP {status})"
313+
)
314+
315+
# Tag matches version
316+
if isinstance(version, str) and version and version != "latest":
317+
status, _ = _gh_get(f"/repos/{owner}/{repo}/git/ref/tags/{version}", token)
318+
if status == 404:
319+
# try with a leading 'v'
320+
alt = version if version.startswith("v") else f"v{version}"
321+
status2, _ = _gh_get(
322+
f"/repos/{owner}/{repo}/git/ref/tags/{alt}", token
323+
)
324+
if status2 == 404:
325+
problems.error(
326+
f"plugin `{name}`: no tag matching version `{version}` "
327+
f"found in {repo_url}"
328+
)
329+
330+
331+
def main() -> int:
332+
parser = argparse.ArgumentParser(description=__doc__)
333+
parser.add_argument(
334+
"--check-remote",
335+
action="store_true",
336+
help="Also verify that referenced GitHub repositories exist, are public, "
337+
"have a LICENSE/README, and expose the declared version tag.",
338+
)
339+
args = parser.parse_args()
340+
341+
problems = Problems()
342+
plugins = load_plugins(problems)
343+
if plugins:
344+
for i, entry in enumerate(plugins):
345+
validate_entry(i, entry, problems)
346+
validate_collection(plugins, problems)
347+
if args.check_remote:
348+
validate_remote(plugins, problems)
349+
350+
return problems.report()
351+
352+
353+
if __name__ == "__main__":
354+
sys.exit(main())

.github/workflows/validate-pr.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Validate plugin registry PR
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
paths:
7+
- "plugins.yaml"
8+
- "README.md"
9+
- ".github/scripts/validate_plugins.py"
10+
- ".github/workflows/validate-pr.yml"
11+
push:
12+
branches: [main]
13+
paths:
14+
- "plugins.yaml"
15+
- "README.md"
16+
- ".github/scripts/validate_plugins.py"
17+
- ".github/workflows/validate-pr.yml"
18+
workflow_dispatch:
19+
20+
permissions:
21+
contents: read
22+
23+
jobs:
24+
validate:
25+
name: Validate plugins.yaml against README guidelines
26+
runs-on: ubuntu-latest
27+
steps:
28+
- name: Checkout repository
29+
uses: actions/checkout@v4
30+
31+
- name: Set up Python
32+
uses: actions/setup-python@v5
33+
with:
34+
python-version: "3.11"
35+
36+
- name: Install dependencies
37+
run: python -m pip install --upgrade pip pyyaml
38+
39+
- name: Validate plugins.yaml and README
40+
env:
41+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42+
run: python .github/scripts/validate_plugins.py --check-remote

0 commit comments

Comments
 (0)