diff --git a/.github/scripts/validate_plugins.py b/.github/scripts/validate_plugins.py new file mode 100644 index 0000000..4f5eb44 --- /dev/null +++ b/.github/scripts/validate_plugins.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Validate `plugins.yaml` against the registry guidelines documented in README.md. + +This script enforces the contribution guidelines described in the project README: + +* `plugins.yaml` is valid YAML and has the expected top-level structure. +* Every plugin entry has the required fields with the correct types. +* `name` is unique, uses snake_case (no spaces). +* `category` is one of the standard categories listed in the README. +* `description` is short (<= 100 characters as recommended by the guidelines). +* `repository` is a valid public Git URL (http(s) or git@). +* `version` is either `latest` or looks like a (semver-ish) tag. +* Entries are sorted alphabetically by `name`. +* The `Available Plugins` table in `README.md` stays in sync with `plugins.yaml`. +* Optionally (when `--check-remote` is given and `GITHUB_TOKEN` is available), + each referenced GitHub repository is reachable, public, has a README, + has a LICENSE, and — when `version` is not `latest` — exposes a tag/release + matching the declared version. + +Exits with status 0 when all checks pass, 1 otherwise. All discovered problems +are printed to stdout to make the GitHub Actions log easy to read. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +import yaml + + +REQUIRED_FIELDS: dict[str, type] = { + "name": str, + "description": str, + "repository": str, + "version": str, + "author": str, + "category": str, +} +OPTIONAL_FIELDS: dict[str, type] = { + "tags": list, +} + +ALLOWED_CATEGORIES = { + "PerceptionStrategy", + "LocalizationStrategy", + "MappingStrategy", + "PlanningStrategy", + "ControlStrategy", + "Executer", + "WorldBridge", +} + +NAME_RE = re.compile(r"^[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*$") +# The README asks for snake_case names with no spaces. Existing entries +# (e.g. `ORBit_perception`) mix cases, so we accept letters and digits +# separated by underscores rather than enforcing strict lowercase. +VERSION_RE = re.compile(r"^(latest|v?\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)$") +URL_RE = re.compile(r"^(https?://|git@)[\w.@:/\-~]+?(?:\.git)?/?$") +GITHUB_URL_RE = re.compile( + r"^https?://github\.com/(?P[\w.\-]+)/(?P[\w.\-]+?)(?:\.git)?/?$" +) +DESCRIPTION_MAX_LEN = 100 + +REPO_ROOT = Path(__file__).resolve().parents[2] +PLUGINS_YAML = REPO_ROOT / "plugins.yaml" + + +class Problems: + def __init__(self) -> None: + self.errors: list[str] = [] + self.warnings: list[str] = [] + + def error(self, msg: str) -> None: + self.errors.append(msg) + + def warn(self, msg: str) -> None: + self.warnings.append(msg) + + def report(self) -> int: + for w in self.warnings: + print(f"::warning::{w}") + for e in self.errors: + print(f"::error::{e}") + if self.errors: + print(f"\nValidation failed with {len(self.errors)} error(s) " + f"and {len(self.warnings)} warning(s).") + return 1 + print(f"Validation passed ({len(self.warnings)} warning(s)).") + return 0 + + +def load_plugins(problems: Problems) -> list[dict[str, Any]]: + if not PLUGINS_YAML.is_file(): + problems.error(f"{PLUGINS_YAML} does not exist") + return [] + try: + data = yaml.safe_load(PLUGINS_YAML.read_text()) + except yaml.YAMLError as exc: + problems.error(f"plugins.yaml is not valid YAML: {exc}") + return [] + if not isinstance(data, dict) or "plugins" not in data: + problems.error("plugins.yaml must be a mapping with a top-level `plugins` key") + return [] + plugins = data["plugins"] + if not isinstance(plugins, list): + problems.error("`plugins` must be a list") + return [] + return plugins + + +def validate_entry(idx: int, entry: Any, problems: Problems) -> None: + label = f"plugins[{idx}]" + if not isinstance(entry, dict): + problems.error(f"{label} must be a mapping, got {type(entry).__name__}") + return + + name = entry.get("name", f"") + label = f"plugin `{name}`" + + # Unknown fields + known = set(REQUIRED_FIELDS) | set(OPTIONAL_FIELDS) + for key in entry: + if key not in known: + problems.warn(f"{label}: unknown field `{key}`") + + # Required fields presence + type + for field, expected in REQUIRED_FIELDS.items(): + if field not in entry: + problems.error(f"{label}: missing required field `{field}`") + continue + value = entry[field] + if not isinstance(value, expected) or (isinstance(value, str) and not value.strip()): + problems.error(f"{label}: field `{field}` must be a non-empty {expected.__name__}") + + # Optional fields type + for field, expected in OPTIONAL_FIELDS.items(): + if field in entry and not isinstance(entry[field], expected): + problems.error(f"{label}: field `{field}` must be a {expected.__name__}") + + if "tags" in entry and isinstance(entry["tags"], list): + for i, tag in enumerate(entry["tags"]): + if not isinstance(tag, str) or not tag.strip(): + problems.error(f"{label}: tags[{i}] must be a non-empty string") + + # Name format + if isinstance(entry.get("name"), str): + if " " in entry["name"]: + problems.error(f"{label}: `name` must not contain spaces") + elif not NAME_RE.match(entry["name"]): + problems.error( + f"{label}: `name` must be snake_case " + "(letters/digits separated by underscores)" + ) + + # Description length + desc = entry.get("description") + if isinstance(desc, str) and len(desc) > DESCRIPTION_MAX_LEN: + problems.warn( + f"{label}: `description` is {len(desc)} characters, " + f"keep it under {DESCRIPTION_MAX_LEN}" + ) + + # Category + cat = entry.get("category") + if isinstance(cat, str) and cat not in ALLOWED_CATEGORIES: + problems.error( + f"{label}: category `{cat}` is not one of " + f"{sorted(ALLOWED_CATEGORIES)}" + ) + + # Repository URL + repo = entry.get("repository") + if isinstance(repo, str) and not URL_RE.match(repo): + problems.error(f"{label}: `repository` is not a valid URL: {repo!r}") + + # Version format + version = entry.get("version") + if isinstance(version, str) and not VERSION_RE.match(version): + problems.warn( + f"{label}: `version` {version!r} is not `latest` and does not look " + "like a semver tag (e.g. `1.2.0` or `v1.2.0`)" + ) + + +def validate_collection(plugins: list[dict[str, Any]], problems: Problems) -> None: + names = [p.get("name") for p in plugins if isinstance(p, dict) and isinstance(p.get("name"), str)] + + # Uniqueness + seen: dict[str, int] = {} + for n in names: + seen[n] = seen.get(n, 0) + 1 + for n, count in seen.items(): + if count > 1: + problems.error(f"Duplicate plugin name `{n}` appears {count} times") + + # Alphabetical sort (case-insensitive, as the README asks for sorted entries) + sorted_names = sorted(names, key=str.lower) + if names != sorted_names: + out_of_order = [ + f"{a!r} should come after {b!r}" + for a, b in zip(names, sorted_names) + if a != b + ] + problems.error( + "plugins.yaml entries must be sorted alphabetically by `name`. " + f"First mismatch: {out_of_order[0] if out_of_order else 'unknown'}" + ) + + +def parse_readme_table(problems: Problems) -> list[dict[str, str]] | None: + # Deprecated: the README no longer mirrors plugins.yaml in a table. + return None + + +def validate_readme_in_sync( + plugins: list[dict[str, Any]], problems: Problems +) -> None: + # Deprecated: README no longer lists individual plugins. Kept as a no-op + # to preserve the public function surface. + return + + +# --------------------------------------------------------------------------- +# Optional remote checks (GitHub API) +# --------------------------------------------------------------------------- + +def _gh_get(path: str, token: str | None) -> tuple[int, Any]: + url = f"https://api.github.com{path}" + req = urllib.request.Request(url, headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "avlite-plugins-validator", + }) + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.status, json.loads(resp.read() or b"null") + except urllib.error.HTTPError as exc: + body: Any = None + try: + body = json.loads(exc.read() or b"null") + except Exception: + body = None + return exc.code, body + except (urllib.error.URLError, TimeoutError) as exc: + return 0, str(exc) + + +def validate_remote(plugins: list[dict[str, Any]], problems: Problems) -> None: + token = os.environ.get("GITHUB_TOKEN") + if not token: + problems.warn( + "GITHUB_TOKEN is not set; remote repository checks will be unauthenticated " + "and may be rate-limited." + ) + + for entry in plugins: + if not isinstance(entry, dict): + continue + name = entry.get("name", "") + repo_url = entry.get("repository", "") + version = entry.get("version", "") + if not isinstance(repo_url, str): + continue + m = GITHUB_URL_RE.match(repo_url) + if not m: + problems.warn( + f"plugin `{name}`: repository {repo_url!r} is not a github.com URL; " + "skipping remote checks" + ) + continue + owner, repo = m.group("owner"), m.group("repo") + + status, payload = _gh_get(f"/repos/{owner}/{repo}", token) + if status == 0: + problems.warn(f"plugin `{name}`: could not reach GitHub ({payload})") + continue + if status == 404: + problems.error( + f"plugin `{name}`: repository {repo_url} is not accessible (404). " + "It must be public." + ) + continue + if status >= 400 or not isinstance(payload, dict): + problems.warn( + f"plugin `{name}`: GitHub API returned {status} for {repo_url}" + ) + continue + if payload.get("private"): + problems.error(f"plugin `{name}`: repository {repo_url} is private") + if not payload.get("license"): + problems.error( + f"plugin `{name}`: repository {repo_url} has no detected LICENSE" + ) + + # README presence + status, _ = _gh_get(f"/repos/{owner}/{repo}/readme", token) + if status == 404: + problems.error( + f"plugin `{name}`: repository {repo_url} has no README" + ) + elif status >= 400 and status != 0: + problems.warn( + f"plugin `{name}`: could not verify README (HTTP {status})" + ) + + # Tag matches version + if isinstance(version, str) and version and version != "latest": + status, _ = _gh_get(f"/repos/{owner}/{repo}/git/ref/tags/{version}", token) + if status == 404: + # try with a leading 'v' + alt = version if version.startswith("v") else f"v{version}" + status2, _ = _gh_get( + f"/repos/{owner}/{repo}/git/ref/tags/{alt}", token + ) + if status2 == 404: + problems.error( + f"plugin `{name}`: no tag matching version `{version}` " + f"found in {repo_url}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check-remote", + action="store_true", + help="Also verify that referenced GitHub repositories exist, are public, " + "have a LICENSE/README, and expose the declared version tag.", + ) + args = parser.parse_args() + + problems = Problems() + plugins = load_plugins(problems) + if plugins: + for i, entry in enumerate(plugins): + validate_entry(i, entry, problems) + validate_collection(plugins, problems) + if args.check_remote: + validate_remote(plugins, problems) + + return problems.report() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 0000000..bb507f6 --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,42 @@ +name: Validate plugin registry PR + +on: + pull_request: + branches: [main] + paths: + - "plugins.yaml" + - "README.md" + - ".github/scripts/validate_plugins.py" + - ".github/workflows/validate-pr.yml" + push: + branches: [main] + paths: + - "plugins.yaml" + - "README.md" + - ".github/scripts/validate_plugins.py" + - ".github/workflows/validate-pr.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate plugins.yaml against README guidelines + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: python -m pip install --upgrade pip pyyaml + + - name: Validate plugins.yaml and README + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python .github/scripts/validate_plugins.py --check-remote diff --git a/README.md b/README.md index 8f5c7c0..0ae38de 100644 --- a/README.md +++ b/README.md @@ -20,34 +20,30 @@ A central registry of community-maintained plugins for [AVLite](https://github.c Use one of the following standard categories for `category`. If your plugin doesn't fit, open an issue to propose a new one rather than inventing one ad hoc: -- `perception` — sensing, detection, tracking, segmentation, fusion -- `planning` — global/local planners, behavior planning, decision-making -- `control` — vehicle controllers, actuation -- `localization` — mapping, SLAM, pose estimation -- `simulation` — simulators, scenario generation, synthetic data -- `visualization` — UIs, dashboards, debug tooling -- `utility` — shared libraries, helpers, integrations +- `PerceptionStrategy` — sensing, detection, tracking, segmentation, fusion +- `LocalizationStrategy` — pose estimation, SLAM-based localization +- `MappingStrategy` — map building, SLAM mapping, environment representation +- `PlanningStrategy` — global/local planners, behavior planning, decision-making +- `ControlStrategy` — vehicle controllers, actuation +- `Executer` — runtime execution, scheduling, orchestration +- `WorldBridge` — bridges to simulators, middleware, or external world interfaces ### Example Entry ```yaml plugins: - - name: ORBit_perception - description: ORBit perception plugin for AVLite - repository: https://github.com/AV-Lab/ORBit_perception + - name: my_perception_plugin + description: One-line summary of what the plugin does + repository: https://github.com/your-org/your-plugin-repo version: latest - author: AV-Lab - category: perception + author: your-org + category: PerceptionStrategy tags: - perception - computer-vision ``` -## Available Plugins - -| Name | Category | Description | Repository | -| ---- | -------- | ----------- | ---------- | -| ORBit_perception | perception | ORBit perception plugin for AVLite | https://github.com/AV-Lab/ORBit_perception | +The authoritative list of registered plugins lives in [`plugins.yaml`](plugins.yaml). Tools and the AVLite runtime consume that file directly. ## Contributing @@ -55,9 +51,8 @@ To add or update a plugin in this registry: 1. **Fork** this repository and create a feature branch. 2. **Edit `plugins.yaml`** and append (or update) your plugin entry following the [schema](#plugin-registry-schema) above. Keep entries alphabetically sorted by `name` to minimize merge conflicts. -3. **Update the [Available Plugins](#available-plugins) table** in this README so the human-readable listing stays in sync with `plugins.yaml`. -4. **Verify your plugin repository** is public, has a clear `README`, a valid `LICENSE`, and a tagged release matching the `version` you list (unless you intentionally use `latest`). -5. **Open a pull request** with a short description of the plugin and a link to its repository. A maintainer will review and merge. +3. **Verify your plugin repository** is public, has a clear `README`, a valid `LICENSE`, and a tagged release matching the `version` you list (unless you intentionally use `latest`). +4. **Open a pull request** with a short description of the plugin and a link to its repository. A maintainer will review and merge. ### Guidelines @@ -68,7 +63,7 @@ To add or update a plugin in this registry: ### Removing or Renaming a Plugin -If a plugin is no longer maintained or is being renamed, open a PR that updates or removes the corresponding entry in `plugins.yaml` and the [Available Plugins](#available-plugins) table, and explain the reason in the PR description. +If a plugin is no longer maintained or is being renamed, open a PR that updates or removes the corresponding entry in `plugins.yaml`, and explain the reason in the PR description. ## License diff --git a/plugins.yaml b/plugins.yaml index eb65e95..1c8377a 100644 --- a/plugins.yaml +++ b/plugins.yaml @@ -3,12 +3,9 @@ # Each plugin entry should follow the structure below plugins: - - name: ORBit_perception - description: ORBit perception plugin for AVLite - repository: https://github.com/AV-Lab/ORBit_perception + - name: sample_avlite_plugin + description: Sample AVLite plugin demonstrating the plugin interface + repository: https://github.com/AV-Lab/sample-avlite-plugin version: latest author: AV-Lab - category: perception - tags: - - perception - - computer-vision + category: PerceptionStrategy