Skip to content

Commit a6d6998

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

3 files changed

Lines changed: 292 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: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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

Comments
 (0)