Skip to content

Commit 32d7ec3

Browse files
committed
ci: add GitHub Actions workflow to validate plugin submissions
1 parent 8e8a6e7 commit 32d7ec3

3 files changed

Lines changed: 300 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Validate plugin submission
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "catalogue.json"
7+
- "icons/**"
8+
- "validate_submission.py"
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
validate:
15+
name: Validate plugin submission
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Check out pull request
19+
uses: actions/checkout@v4
20+
with:
21+
fetch-depth: 0
22+
23+
- name: Set up Python
24+
uses: actions/setup-python@v5
25+
with:
26+
python-version: "3.x"
27+
28+
- name: Validate catalogue submission
29+
env:
30+
GITHUB_TOKEN: ${{ github.token }}
31+
run: python validate_submission.py --base "${{ github.event.pull_request.base.sha }}" --head "${{ github.event.pull_request.head.sha }}"

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@ To submit your plugin to the OpenAction Marketplace, please follow these steps:
2929
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.*
3030

3131
Once you have added your entry to the appropriate section, submit a Pull Request to this repository for review.
32+
33+
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.

‎validate_submission.py‎

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
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+
section_starts = catalogue_section_starts(catalogue)
111+
for plugin_id in relevant_ids:
112+
section = section_for(plugin_id, catalogue)
113+
if section not in ALLOWED_SECTIONS:
114+
continue
115+
index = ids.index(plugin_id)
116+
section_index = SECTION_NAMES.index(section)
117+
section_start = section_starts[section_index]
118+
section_end = (
119+
section_starts[section_index + 1]
120+
if section_index + 1 < len(section_starts)
121+
else len(ids)
122+
)
123+
repository = catalogue[plugin_id].get("repository", "")
124+
if (
125+
index > section_start
126+
and catalogue[ids[index - 1]].get("repository", "").casefold() > repository.casefold()
127+
):
128+
raise ValidationError(f"{plugin_id}: plugin is not sorted by repository URL")
129+
if (
130+
index + 1 < section_end
131+
and repository.casefold() > catalogue[ids[index + 1]].get("repository", "").casefold()
132+
):
133+
raise ValidationError(f"{plugin_id}: plugin is not sorted by repository URL")
134+
135+
136+
def github_json(path: str) -> dict:
137+
url = f"https://api.github.com{path}"
138+
headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
139+
token = os.environ.get("GITHUB_TOKEN")
140+
if token:
141+
headers["Authorization"] = "Bearer " + token
142+
try:
143+
with urlopen(Request(url, headers=headers), timeout=20) as response:
144+
return json.load(response)
145+
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc:
146+
raise ValidationError(f"GitHub API request failed for {path}: {exc}") from exc
147+
148+
149+
def repository_parts(url: str) -> tuple[str, str]:
150+
parsed = urlparse(url)
151+
if parsed.scheme != "https" or parsed.netloc.lower() != "github.com":
152+
raise ValidationError(f"repository must be an HTTPS GitHub URL: {url}")
153+
parts = parsed.path.strip("/").split("/")
154+
if len(parts) != 2 or not all(parts):
155+
raise ValidationError(f"repository URL must identify an owner and repository: {url}")
156+
return parts[0], parts[1].removesuffix(".git")
157+
158+
159+
def validate_repository(plugin_id: str, entry: dict, changed: set[str]) -> None:
160+
for field in ("name", "author", "repository", "description"):
161+
if field not in entry:
162+
raise ValidationError(f"{plugin_id}: missing catalogue field '{field}'")
163+
164+
owner, repo = repository_parts(entry["repository"])
165+
repo_info = github_json(f"/repos/{owner}/{repo}")
166+
release = github_json(f"/repos/{owner}/{repo}/releases/latest")
167+
if not any(
168+
asset.get("name", "").lower().endswith((".zip", ".streamdeckplugin"))
169+
for asset in release.get("assets", [])
170+
):
171+
raise ValidationError(
172+
f"{plugin_id}: latest release must include a .zip or .streamDeckPlugin artifact"
173+
)
174+
if "openaction" not in github_json(f"/repos/{owner}/{repo}/topics").get("names", []):
175+
raise ValidationError(f"{plugin_id}: repository is missing the openaction topic")
176+
if entry["description"] != repo_info.get("description"):
177+
raise ValidationError(
178+
f"{plugin_id}: description does not match the repository sidebar description"
179+
)
180+
181+
tree = github_json(
182+
f"/repos/{owner}/{repo}/git/trees/{repo_info['default_branch']}?recursive=1"
183+
).get("tree", [])
184+
manifest_paths = [
185+
item["path"]
186+
for item in tree
187+
if item.get("type") == "blob" and item["path"].endswith("manifest.json")
188+
]
189+
if not manifest_paths:
190+
raise ValidationError(f"{plugin_id}: could not find a manifest.json in the repository")
191+
manifest_path = (
192+
"assets/manifest.json" if "assets/manifest.json" in manifest_paths else manifest_paths[0]
193+
)
194+
manifest_response = github_json(f"/repos/{owner}/{repo}/contents/{manifest_path}")
195+
try:
196+
manifest = json.loads(base64.b64decode(manifest_response["content"]).decode("utf-8"))
197+
except (KeyError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
198+
raise ValidationError(f"{plugin_id}: manifest.json is not valid JSON") from exc
199+
if manifest.get("Name") != entry["name"] or manifest.get("Author") != entry["author"]:
200+
raise ValidationError(f"{plugin_id}: name and author must match manifest.json")
201+
action_ids = [action.get("UUID", "") for action in manifest.get("Actions", [])]
202+
if action_ids and not any(action_id.startswith(f"{plugin_id}.") for action_id in action_ids):
203+
raise ValidationError(f"{plugin_id}: catalogue key is not the manifest bundle ID")
204+
205+
icon_path = ICONS / f"{plugin_id}.png"
206+
if not icon_path.is_file():
207+
raise ValidationError(f"{plugin_id}: missing icon {icon_path}")
208+
if plugin_id not in {path.removeprefix("icons/").removesuffix(".png") for path in changed}:
209+
raise ValidationError(f"{plugin_id}: submission must add or update its icon")
210+
try:
211+
with icon_path.open("rb") as icon:
212+
if icon.read(8) != b"\x89PNG\r\n\x1a\n":
213+
raise ValueError("not a PNG")
214+
length = struct.unpack(">I", icon.read(4))[0]
215+
if icon.read(4) != b"IHDR" or length < 8:
216+
raise ValueError("missing PNG dimensions")
217+
width, height = struct.unpack(">II", icon.read(8))
218+
except (OSError, ValueError, struct.error) as exc:
219+
raise ValidationError(f"{plugin_id}: icon is not a valid PNG") from exc
220+
if width < 144 or height < 144:
221+
raise ValidationError(f"{plugin_id}: icon must be at least 144x144 pixels")
222+
223+
224+
def main() -> int:
225+
parser = argparse.ArgumentParser()
226+
parser.add_argument("--base", required=True)
227+
parser.add_argument("--head", default="HEAD")
228+
args = parser.parse_args()
229+
try:
230+
base_catalogue = load_catalogue_text(
231+
subprocess.check_output(["git", "show", f"{args.base}:catalogue.json"], text=True)
232+
)
233+
head_catalogue = load_catalogue(CATALOGUE)
234+
validate_catalogue_format(CATALOGUE, head_catalogue)
235+
changed = changed_files(args.base, args.head)
236+
changed_ids = {
237+
plugin_id
238+
for plugin_id in set(base_catalogue) | set(head_catalogue)
239+
if base_catalogue.get(plugin_id) != head_catalogue.get(plugin_id)
240+
}
241+
if not changed_ids:
242+
print("No catalogue entries changed; plugin submission checks skipped.")
243+
return 0
244+
validate_order(head_catalogue, changed_ids)
245+
list(head_catalogue)
246+
for plugin_id in changed_ids:
247+
if plugin_id not in head_catalogue:
248+
raise ValidationError(
249+
f"{plugin_id}: catalogue entries may not be removed in a submission PR"
250+
)
251+
section = section_for(plugin_id, head_catalogue)
252+
if section not in ALLOWED_SECTIONS:
253+
raise ValidationError(
254+
f"{plugin_id}: submissions are only allowed in Native or Device sections"
255+
)
256+
validate_repository(plugin_id, head_catalogue[plugin_id], changed)
257+
print(
258+
f"Validated {len(changed_ids)} plugin catalogue entr{'y' if len(changed_ids) == 1 else 'ies'}."
259+
)
260+
return 0
261+
except (ValidationError, subprocess.CalledProcessError) as exc:
262+
print(f"::error::{exc}", file=sys.stderr)
263+
return 1
264+
265+
266+
if __name__ == "__main__":
267+
sys.exit(main())

0 commit comments

Comments
 (0)