|
| 1 | +"""Validate plugin catalogue submissions in a pull request.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import argparse |
| 6 | +import base64 |
| 7 | +import json |
| 8 | +import os |
| 9 | +import struct |
| 10 | +import subprocess |
| 11 | +import sys |
| 12 | +from pathlib import Path |
| 13 | +from urllib.error import HTTPError, URLError |
| 14 | +from urllib.parse import urlparse |
| 15 | +from urllib.request import Request, urlopen |
| 16 | + |
| 17 | +CATALOGUE = Path("catalogue.json") |
| 18 | +ICONS = Path("icons") |
| 19 | + |
| 20 | +SECTION_NAMES = ("official", "native", "device", "stream-deck") |
| 21 | +ALLOWED_SECTIONS = {"native", "device"} |
| 22 | + |
| 23 | + |
| 24 | +class ValidationError(Exception): |
| 25 | + pass |
| 26 | + |
| 27 | + |
| 28 | +def load_catalogue(path: Path) -> dict[str, dict]: |
| 29 | + try: |
| 30 | + with path.open(encoding="utf-8") as file: |
| 31 | + return json.load(file, object_pairs_hook=_unique_object) |
| 32 | + except (OSError, json.JSONDecodeError) as exc: |
| 33 | + raise ValidationError(f"{path} is not valid JSON: {exc}") from exc |
| 34 | + |
| 35 | + |
| 36 | +def load_catalogue_text(contents: str) -> dict[str, dict]: |
| 37 | + try: |
| 38 | + return json.loads(contents, object_pairs_hook=_unique_object) |
| 39 | + except json.JSONDecodeError as exc: |
| 40 | + raise ValidationError(f"catalogue is not valid JSON: {exc}") from exc |
| 41 | + |
| 42 | + |
| 43 | +def validate_catalogue_format(path: Path, catalogue: dict[str, dict]) -> None: |
| 44 | + try: |
| 45 | + contents = path.read_text(encoding="utf-8") |
| 46 | + except OSError as exc: |
| 47 | + raise ValidationError(f"could not read {path}: {exc}") from exc |
| 48 | + |
| 49 | + for line_number, line in enumerate(contents.splitlines(), start=1): |
| 50 | + if line.rstrip() != line: |
| 51 | + raise ValidationError(f"{path}:{line_number}: trailing whitespace is not allowed") |
| 52 | + if line and line[0] == " ": |
| 53 | + raise ValidationError(f"{path}:{line_number}: indentation must use tabs") |
| 54 | + if not contents.endswith("\n"): |
| 55 | + raise ValidationError(f"{path}: file must end with a newline") |
| 56 | + |
| 57 | + expected = json.dumps(catalogue, indent="\t", ensure_ascii=False) + "\n" |
| 58 | + if contents != expected: |
| 59 | + raise ValidationError( |
| 60 | + f"{path}: formatting must match tab-indented JSON generated by json.dumps" |
| 61 | + ) |
| 62 | + |
| 63 | + |
| 64 | +def _unique_object(pairs: list[tuple[str, object]]) -> dict: |
| 65 | + result: dict[str, object] = {} |
| 66 | + for key, value in pairs: |
| 67 | + if key in result: |
| 68 | + raise ValidationError(f"duplicate catalogue key: {key}") |
| 69 | + result[key] = value |
| 70 | + return result |
| 71 | + |
| 72 | + |
| 73 | +def changed_files(base: str, head: str) -> set[str]: |
| 74 | + output = subprocess.check_output( |
| 75 | + ["git", "diff", "--name-only", f"{base}...{head}", "--"], |
| 76 | + text=True, |
| 77 | + ) |
| 78 | + return {line for line in output.splitlines() if line} |
| 79 | + |
| 80 | + |
| 81 | +def section_for(plugin_id: str, catalogue: dict[str, dict]) -> str: |
| 82 | + ids = list(catalogue) |
| 83 | + try: |
| 84 | + index = ids.index(plugin_id) |
| 85 | + except ValueError as exc: |
| 86 | + raise ValidationError(f"{plugin_id} is not present in the catalogue") from exc |
| 87 | + |
| 88 | + section_starts = catalogue_section_starts(catalogue) |
| 89 | + for position, start in enumerate(section_starts): |
| 90 | + end = section_starts[position + 1] if position + 1 < len(section_starts) else len(ids) |
| 91 | + if start <= index < end: |
| 92 | + return SECTION_NAMES[position] |
| 93 | + raise ValidationError(f"could not determine the catalogue section for {plugin_id}") |
| 94 | + |
| 95 | + |
| 96 | +def catalogue_section_starts(catalogue: dict[str, dict]) -> list[int]: |
| 97 | + """Find section starts from the URL sort resets in the ordered catalogue.""" |
| 98 | + ids = list(catalogue) |
| 99 | + repositories = [catalogue[plugin_id].get("repository", "").casefold() for plugin_id in ids] |
| 100 | + resets = [ |
| 101 | + index for index in range(1, len(ids)) if repositories[index] < repositories[index - 1] |
| 102 | + ] |
| 103 | + if len(resets) < len(SECTION_NAMES) - 1: |
| 104 | + raise ValidationError("could not determine all catalogue sections from repository ordering") |
| 105 | + return [0, *resets[: len(SECTION_NAMES) - 1]] |
| 106 | + |
| 107 | + |
| 108 | +def validate_order(catalogue: dict[str, dict], relevant_ids: set[str]) -> None: |
| 109 | + ids = list(catalogue) |
| 110 | + for plugin_id in relevant_ids: |
| 111 | + section = section_for(plugin_id, catalogue) |
| 112 | + if section not in ALLOWED_SECTIONS: |
| 113 | + continue |
| 114 | + index = ids.index(plugin_id) |
| 115 | + repository = catalogue[plugin_id].get("repository", "") |
| 116 | + if ( |
| 117 | + index |
| 118 | + and catalogue[ids[index - 1]].get("repository", "").casefold() > repository.casefold() |
| 119 | + ): |
| 120 | + raise ValidationError(f"{plugin_id}: plugin is not sorted by repository URL") |
| 121 | + if ( |
| 122 | + index + 1 < len(ids) |
| 123 | + and repository.casefold() > catalogue[ids[index + 1]].get("repository", "").casefold() |
| 124 | + ): |
| 125 | + raise ValidationError(f"{plugin_id}: plugin is not sorted by repository URL") |
| 126 | + |
| 127 | + |
| 128 | +def github_json(path: str) -> dict: |
| 129 | + url = f"https://api.github.com{path}" |
| 130 | + headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} |
| 131 | + token = os.environ.get("GITHUB_TOKEN") |
| 132 | + if token: |
| 133 | + headers["Authorization"] = "Bearer " + token |
| 134 | + try: |
| 135 | + with urlopen(Request(url, headers=headers), timeout=20) as response: |
| 136 | + return json.load(response) |
| 137 | + except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc: |
| 138 | + raise ValidationError(f"GitHub API request failed for {path}: {exc}") from exc |
| 139 | + |
| 140 | + |
| 141 | +def repository_parts(url: str) -> tuple[str, str]: |
| 142 | + parsed = urlparse(url) |
| 143 | + if parsed.scheme != "https" or parsed.netloc.lower() != "github.com": |
| 144 | + raise ValidationError(f"repository must be an HTTPS GitHub URL: {url}") |
| 145 | + parts = parsed.path.strip("/").split("/") |
| 146 | + if len(parts) != 2 or not all(parts): |
| 147 | + raise ValidationError(f"repository URL must identify an owner and repository: {url}") |
| 148 | + return parts[0], parts[1].removesuffix(".git") |
| 149 | + |
| 150 | + |
| 151 | +def validate_repository(plugin_id: str, entry: dict, changed: set[str]) -> None: |
| 152 | + for field in ("name", "author", "repository", "description"): |
| 153 | + if field not in entry: |
| 154 | + raise ValidationError(f"{plugin_id}: missing catalogue field '{field}'") |
| 155 | + |
| 156 | + owner, repo = repository_parts(entry["repository"]) |
| 157 | + repo_info = github_json(f"/repos/{owner}/{repo}") |
| 158 | + release = github_json(f"/repos/{owner}/{repo}/releases/latest") |
| 159 | + if not any( |
| 160 | + asset.get("name", "").lower().endswith((".zip", ".streamdeckplugin")) |
| 161 | + for asset in release.get("assets", []) |
| 162 | + ): |
| 163 | + raise ValidationError( |
| 164 | + f"{plugin_id}: latest release must include a .zip or .streamDeckPlugin artifact" |
| 165 | + ) |
| 166 | + if "openaction" not in github_json(f"/repos/{owner}/{repo}/topics").get("names", []): |
| 167 | + raise ValidationError(f"{plugin_id}: repository is missing the openaction topic") |
| 168 | + if entry["description"] != repo_info.get("description"): |
| 169 | + raise ValidationError( |
| 170 | + f"{plugin_id}: description does not match the repository sidebar description" |
| 171 | + ) |
| 172 | + |
| 173 | + tree = github_json( |
| 174 | + f"/repos/{owner}/{repo}/git/trees/{repo_info['default_branch']}?recursive=1" |
| 175 | + ).get("tree", []) |
| 176 | + manifest_paths = [ |
| 177 | + item["path"] |
| 178 | + for item in tree |
| 179 | + if item.get("type") == "blob" and item["path"].endswith("manifest.json") |
| 180 | + ] |
| 181 | + if not manifest_paths: |
| 182 | + raise ValidationError(f"{plugin_id}: could not find a manifest.json in the repository") |
| 183 | + manifest_path = ( |
| 184 | + "assets/manifest.json" if "assets/manifest.json" in manifest_paths else manifest_paths[0] |
| 185 | + ) |
| 186 | + manifest_response = github_json(f"/repos/{owner}/{repo}/contents/{manifest_path}") |
| 187 | + try: |
| 188 | + manifest = json.loads(base64.b64decode(manifest_response["content"]).decode("utf-8")) |
| 189 | + except (KeyError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc: |
| 190 | + raise ValidationError(f"{plugin_id}: manifest.json is not valid JSON") from exc |
| 191 | + if manifest.get("Name") != entry["name"] or manifest.get("Author") != entry["author"]: |
| 192 | + raise ValidationError(f"{plugin_id}: name and author must match manifest.json") |
| 193 | + action_ids = [action.get("UUID", "") for action in manifest.get("Actions", [])] |
| 194 | + if action_ids and not any(action_id.startswith(f"{plugin_id}.") for action_id in action_ids): |
| 195 | + raise ValidationError(f"{plugin_id}: catalogue key is not the manifest bundle ID") |
| 196 | + |
| 197 | + icon_path = ICONS / f"{plugin_id}.png" |
| 198 | + if not icon_path.is_file(): |
| 199 | + raise ValidationError(f"{plugin_id}: missing icon {icon_path}") |
| 200 | + if plugin_id not in {path.removeprefix("icons/").removesuffix(".png") for path in changed}: |
| 201 | + raise ValidationError(f"{plugin_id}: submission must add or update its icon") |
| 202 | + try: |
| 203 | + with icon_path.open("rb") as icon: |
| 204 | + if icon.read(8) != b"\x89PNG\r\n\x1a\n": |
| 205 | + raise ValueError("not a PNG") |
| 206 | + length = struct.unpack(">I", icon.read(4))[0] |
| 207 | + if icon.read(4) != b"IHDR" or length < 8: |
| 208 | + raise ValueError("missing PNG dimensions") |
| 209 | + width, height = struct.unpack(">II", icon.read(8)) |
| 210 | + except (OSError, ValueError, struct.error) as exc: |
| 211 | + raise ValidationError(f"{plugin_id}: icon is not a valid PNG") from exc |
| 212 | + if width < 144 or height < 144: |
| 213 | + raise ValidationError(f"{plugin_id}: icon must be at least 144x144 pixels") |
| 214 | + |
| 215 | + |
| 216 | +def main() -> int: |
| 217 | + parser = argparse.ArgumentParser() |
| 218 | + parser.add_argument("--base", required=True) |
| 219 | + parser.add_argument("--head", default="HEAD") |
| 220 | + args = parser.parse_args() |
| 221 | + try: |
| 222 | + base_catalogue = load_catalogue_text( |
| 223 | + subprocess.check_output(["git", "show", f"{args.base}:catalogue.json"], text=True) |
| 224 | + ) |
| 225 | + head_catalogue = load_catalogue(CATALOGUE) |
| 226 | + validate_catalogue_format(CATALOGUE, head_catalogue) |
| 227 | + changed = changed_files(args.base, args.head) |
| 228 | + changed_ids = { |
| 229 | + plugin_id |
| 230 | + for plugin_id in set(base_catalogue) | set(head_catalogue) |
| 231 | + if base_catalogue.get(plugin_id) != head_catalogue.get(plugin_id) |
| 232 | + } |
| 233 | + if not changed_ids: |
| 234 | + print("No catalogue entries changed; plugin submission checks skipped.") |
| 235 | + return 0 |
| 236 | + validate_order(head_catalogue, changed_ids) |
| 237 | + list(head_catalogue) |
| 238 | + for plugin_id in changed_ids: |
| 239 | + if plugin_id not in head_catalogue: |
| 240 | + raise ValidationError( |
| 241 | + f"{plugin_id}: catalogue entries may not be removed in a submission PR" |
| 242 | + ) |
| 243 | + section = section_for(plugin_id, head_catalogue) |
| 244 | + if section not in ALLOWED_SECTIONS: |
| 245 | + raise ValidationError( |
| 246 | + f"{plugin_id}: submissions are only allowed in Native or Device sections" |
| 247 | + ) |
| 248 | + validate_repository(plugin_id, head_catalogue[plugin_id], changed) |
| 249 | + print( |
| 250 | + f"Validated {len(changed_ids)} plugin catalogue entr{'y' if len(changed_ids) == 1 else 'ies'}." |
| 251 | + ) |
| 252 | + return 0 |
| 253 | + except (ValidationError, subprocess.CalledProcessError) as exc: |
| 254 | + print(f"::error::{exc}", file=sys.stderr) |
| 255 | + return 1 |
| 256 | + |
| 257 | + |
| 258 | +if __name__ == "__main__": |
| 259 | + sys.exit(main()) |
0 commit comments