diff --git a/.github/workflows/validate-plugin-submission.yml b/.github/workflows/validate-plugin-submission.yml new file mode 100644 index 0000000..05179d1 --- /dev/null +++ b/.github/workflows/validate-plugin-submission.yml @@ -0,0 +1,31 @@ +name: Validate plugin submission + +on: + pull_request: + paths: + - "catalogue.json" + - "icons/**" + - "validate_submission.py" + +permissions: + contents: read + +jobs: + validate: + name: Validate plugin submission + runs-on: ubuntu-latest + steps: + - name: Check out pull request + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Validate catalogue submission + env: + GITHUB_TOKEN: ${{ github.token }} + run: python validate_submission.py --base "${{ github.event.pull_request.base.sha }}" --head "${{ github.event.pull_request.head.sha }}" diff --git a/README.md b/README.md index a4b9705..e18471f 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,5 @@ To submit your plugin to the OpenAction Marketplace, please follow these steps: 4. **Add an Icon**: Add a high-resolution icon representing your plugin to the `icons/` directory. The icon should match the icon provided in your plugin's manifest / bundle. The file should be named matching your plugin's bundle ID (e.g. `com.yourname.plugin.png`). *Note: You do not need to run the `format_icons.py` script yourself; a maintainer will run it in a standardised environment to format your icon when reviewing your submission.* Once you have added your entry to the appropriate section, submit a Pull Request to this repository for review. + +Pull requests that change the catalogue are checked automatically. The check verifies the repository topic, manifest metadata, repository description, bundle ID, latest-release artifacts, icon format and resolution, section placement, and repository URL ordering before maintainers review the submission. diff --git a/catalogue.json b/catalogue.json index 8267bad..5348ad5 100644 --- a/catalogue.json +++ b/catalogue.json @@ -128,6 +128,15 @@ "repository": "https://github.com/mdvictor/opendeck-volume-controller", "description": "A per-application volume control plugin for the Stream Deck using OpenDeck on Linux." }, + "io.github.millenniumambiguity.warthunder": { + "name": "War Thunder Telemetry", + "author": "millennIumAMbiguity", + "repository": "https://github.com/millennIumAMbiguity/opendeck-warthunder", + "description": "Live War Thunder flight data on Stream Deck keys and the Saitek Multi Panel, for OpenDeck", + "flags": { + "ai_assisted": true + } + }, "fr.jourdois.pipewire": { "name": "PipeWire Audio (Native)", "author": "Stephane Jourdois", @@ -209,6 +218,15 @@ "repository": "https://github.com/MikaBust/opendeck-duo87", "description": "An unofficial plugin for the Womier DUO87 keyboard's detachable screen module." }, + "io.github.millenniumambiguity.saitekmultipanel": { + "name": "Saitek Pro Flight Multi Panel", + "author": "millennIumAMbiguity", + "repository": "https://github.com/millennIumAMbiguity/opendeck-saitek-multipanel", + "description": "OpenDeck device plugin for the Logitech/Saitek Pro Flight Multi Panel", + "flags": { + "ai_assisted": true + } + }, "com.monkeykiller.plugins.opendeck-ss550": { "name": "Redragon Streamcraft SS550", "author": "MonkeyKiller", diff --git a/icons/io.github.millenniumambiguity.saitekmultipanel.png b/icons/io.github.millenniumambiguity.saitekmultipanel.png new file mode 100644 index 0000000..52b8d35 Binary files /dev/null and b/icons/io.github.millenniumambiguity.saitekmultipanel.png differ diff --git a/icons/io.github.millenniumambiguity.warthunder.png b/icons/io.github.millenniumambiguity.warthunder.png new file mode 100644 index 0000000..afc84a9 Binary files /dev/null and b/icons/io.github.millenniumambiguity.warthunder.png differ diff --git a/validate_submission.py b/validate_submission.py new file mode 100644 index 0000000..a856da2 --- /dev/null +++ b/validate_submission.py @@ -0,0 +1,259 @@ +"""Validate plugin catalogue submissions in a pull request.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import struct +import subprocess +import sys +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +CATALOGUE = Path("catalogue.json") +ICONS = Path("icons") + +SECTION_NAMES = ("official", "native", "device", "stream-deck") +ALLOWED_SECTIONS = {"native", "device"} + + +class ValidationError(Exception): + pass + + +def load_catalogue(path: Path) -> dict[str, dict]: + try: + with path.open(encoding="utf-8") as file: + return json.load(file, object_pairs_hook=_unique_object) + except (OSError, json.JSONDecodeError) as exc: + raise ValidationError(f"{path} is not valid JSON: {exc}") from exc + + +def load_catalogue_text(contents: str) -> dict[str, dict]: + try: + return json.loads(contents, object_pairs_hook=_unique_object) + except json.JSONDecodeError as exc: + raise ValidationError(f"catalogue is not valid JSON: {exc}") from exc + + +def validate_catalogue_format(path: Path, catalogue: dict[str, dict]) -> None: + try: + contents = path.read_text(encoding="utf-8") + except OSError as exc: + raise ValidationError(f"could not read {path}: {exc}") from exc + + for line_number, line in enumerate(contents.splitlines(), start=1): + if line.rstrip() != line: + raise ValidationError(f"{path}:{line_number}: trailing whitespace is not allowed") + if line and line[0] == " ": + raise ValidationError(f"{path}:{line_number}: indentation must use tabs") + if not contents.endswith("\n"): + raise ValidationError(f"{path}: file must end with a newline") + + expected = json.dumps(catalogue, indent="\t", ensure_ascii=False) + "\n" + if contents != expected: + raise ValidationError( + f"{path}: formatting must match tab-indented JSON generated by json.dumps" + ) + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValidationError(f"duplicate catalogue key: {key}") + result[key] = value + return result + + +def changed_files(base: str, head: str) -> set[str]: + output = subprocess.check_output( + ["git", "diff", "--name-only", f"{base}...{head}", "--"], + text=True, + ) + return {line for line in output.splitlines() if line} + + +def section_for(plugin_id: str, catalogue: dict[str, dict]) -> str: + ids = list(catalogue) + try: + index = ids.index(plugin_id) + except ValueError as exc: + raise ValidationError(f"{plugin_id} is not present in the catalogue") from exc + + section_starts = catalogue_section_starts(catalogue) + for position, start in enumerate(section_starts): + end = section_starts[position + 1] if position + 1 < len(section_starts) else len(ids) + if start <= index < end: + return SECTION_NAMES[position] + raise ValidationError(f"could not determine the catalogue section for {plugin_id}") + + +def catalogue_section_starts(catalogue: dict[str, dict]) -> list[int]: + """Find section starts from the URL sort resets in the ordered catalogue.""" + ids = list(catalogue) + repositories = [catalogue[plugin_id].get("repository", "").casefold() for plugin_id in ids] + resets = [ + index for index in range(1, len(ids)) if repositories[index] < repositories[index - 1] + ] + if len(resets) < len(SECTION_NAMES) - 1: + raise ValidationError("could not determine all catalogue sections from repository ordering") + return [0, *resets[: len(SECTION_NAMES) - 1]] + + +def validate_order(catalogue: dict[str, dict], relevant_ids: set[str]) -> None: + ids = list(catalogue) + for plugin_id in relevant_ids: + section = section_for(plugin_id, catalogue) + if section not in ALLOWED_SECTIONS: + continue + index = ids.index(plugin_id) + repository = catalogue[plugin_id].get("repository", "") + if ( + index + and catalogue[ids[index - 1]].get("repository", "").casefold() > repository.casefold() + ): + raise ValidationError(f"{plugin_id}: plugin is not sorted by repository URL") + if ( + index + 1 < len(ids) + and repository.casefold() > catalogue[ids[index + 1]].get("repository", "").casefold() + ): + raise ValidationError(f"{plugin_id}: plugin is not sorted by repository URL") + + +def github_json(path: str) -> dict: + url = f"https://api.github.com{path}" + headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = "Bearer " + token + try: + with urlopen(Request(url, headers=headers), timeout=20) as response: + return json.load(response) + except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc: + raise ValidationError(f"GitHub API request failed for {path}: {exc}") from exc + + +def repository_parts(url: str) -> tuple[str, str]: + parsed = urlparse(url) + if parsed.scheme != "https" or parsed.netloc.lower() != "github.com": + raise ValidationError(f"repository must be an HTTPS GitHub URL: {url}") + parts = parsed.path.strip("/").split("/") + if len(parts) != 2 or not all(parts): + raise ValidationError(f"repository URL must identify an owner and repository: {url}") + return parts[0], parts[1].removesuffix(".git") + + +def validate_repository(plugin_id: str, entry: dict, changed: set[str]) -> None: + for field in ("name", "author", "repository", "description"): + if field not in entry: + raise ValidationError(f"{plugin_id}: missing catalogue field '{field}'") + + owner, repo = repository_parts(entry["repository"]) + repo_info = github_json(f"/repos/{owner}/{repo}") + release = github_json(f"/repos/{owner}/{repo}/releases/latest") + if not any( + asset.get("name", "").lower().endswith((".zip", ".streamdeckplugin")) + for asset in release.get("assets", []) + ): + raise ValidationError( + f"{plugin_id}: latest release must include a .zip or .streamDeckPlugin artifact" + ) + if "openaction" not in github_json(f"/repos/{owner}/{repo}/topics").get("names", []): + raise ValidationError(f"{plugin_id}: repository is missing the openaction topic") + if entry["description"] != repo_info.get("description"): + raise ValidationError( + f"{plugin_id}: description does not match the repository sidebar description" + ) + + tree = github_json( + f"/repos/{owner}/{repo}/git/trees/{repo_info['default_branch']}?recursive=1" + ).get("tree", []) + manifest_paths = [ + item["path"] + for item in tree + if item.get("type") == "blob" and item["path"].endswith("manifest.json") + ] + if not manifest_paths: + raise ValidationError(f"{plugin_id}: could not find a manifest.json in the repository") + manifest_path = ( + "assets/manifest.json" if "assets/manifest.json" in manifest_paths else manifest_paths[0] + ) + manifest_response = github_json(f"/repos/{owner}/{repo}/contents/{manifest_path}") + try: + manifest = json.loads(base64.b64decode(manifest_response["content"]).decode("utf-8")) + except (KeyError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValidationError(f"{plugin_id}: manifest.json is not valid JSON") from exc + if manifest.get("Name") != entry["name"] or manifest.get("Author") != entry["author"]: + raise ValidationError(f"{plugin_id}: name and author must match manifest.json") + action_ids = [action.get("UUID", "") for action in manifest.get("Actions", [])] + if action_ids and not any(action_id.startswith(f"{plugin_id}.") for action_id in action_ids): + raise ValidationError(f"{plugin_id}: catalogue key is not the manifest bundle ID") + + icon_path = ICONS / f"{plugin_id}.png" + if not icon_path.is_file(): + raise ValidationError(f"{plugin_id}: missing icon {icon_path}") + if plugin_id not in {path.removeprefix("icons/").removesuffix(".png") for path in changed}: + raise ValidationError(f"{plugin_id}: submission must add or update its icon") + try: + with icon_path.open("rb") as icon: + if icon.read(8) != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a PNG") + length = struct.unpack(">I", icon.read(4))[0] + if icon.read(4) != b"IHDR" or length < 8: + raise ValueError("missing PNG dimensions") + width, height = struct.unpack(">II", icon.read(8)) + except (OSError, ValueError, struct.error) as exc: + raise ValidationError(f"{plugin_id}: icon is not a valid PNG") from exc + if width < 144 or height < 144: + raise ValidationError(f"{plugin_id}: icon must be at least 144x144 pixels") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", required=True) + parser.add_argument("--head", default="HEAD") + args = parser.parse_args() + try: + base_catalogue = load_catalogue_text( + subprocess.check_output(["git", "show", f"{args.base}:catalogue.json"], text=True) + ) + head_catalogue = load_catalogue(CATALOGUE) + validate_catalogue_format(CATALOGUE, head_catalogue) + changed = changed_files(args.base, args.head) + changed_ids = { + plugin_id + for plugin_id in set(base_catalogue) | set(head_catalogue) + if base_catalogue.get(plugin_id) != head_catalogue.get(plugin_id) + } + if not changed_ids: + print("No catalogue entries changed; plugin submission checks skipped.") + return 0 + validate_order(head_catalogue, changed_ids) + list(head_catalogue) + for plugin_id in changed_ids: + if plugin_id not in head_catalogue: + raise ValidationError( + f"{plugin_id}: catalogue entries may not be removed in a submission PR" + ) + section = section_for(plugin_id, head_catalogue) + if section not in ALLOWED_SECTIONS: + raise ValidationError( + f"{plugin_id}: submissions are only allowed in Native or Device sections" + ) + validate_repository(plugin_id, head_catalogue[plugin_id], changed) + print( + f"Validated {len(changed_ids)} plugin catalogue entr{'y' if len(changed_ids) == 1 else 'ies'}." + ) + return 0 + except (ValidationError, subprocess.CalledProcessError) as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main())