diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 94dc27d1..d4e06c60 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,8 @@ name: Catalog consistency checks -on: pull_request +on: + pull_request: + push: jobs: build: @@ -15,7 +17,7 @@ jobs: python-version: 3.11 - name: Install toml python lib run: | - pip3 install toml + pip3 install toml jsonschema - name: Check TOML validity for apps.toml run: | python3 -c "import toml; toml.load(open('apps.toml'))" diff --git a/apps.toml b/apps.toml index e502b4ab..611dcf40 100644 --- a/apps.toml +++ b/apps.toml @@ -864,7 +864,7 @@ url = "https://github.com/YunoHost-Apps/element_ynh" [eleventy] category = "publishing" -level = 7 +level = 1 potential_alternative_to = [ "Blogger", "Blogspot", "Wix" ] state = "working" subtags = [ "websites", "blog" ] @@ -928,6 +928,7 @@ url = "https://github.com/YunoHost-Apps/ethercalc_ynh" [etherpad] category = "office" +level = 2 potential_alternative_to = [ "Google Docs", "G Suite", "Microsoft Word", "Microsoft Office", "Office 365" ] state = "working" subtags = [ "text" ] @@ -1657,6 +1658,7 @@ url = "https://github.com/YunoHost-Apps/jellyfin_ynh" [jellyfin-vue] category = "multimedia" +level = 2 potential_alternative_to = [ "Plex", "Netflix" ] state = "working" subtags = [ "music", "mediacenter" ] @@ -2046,7 +2048,7 @@ url = "https://github.com/YunoHost-Apps/matomo_ynh" [matrix-appservice-irc] category = "communication" -state = "inprogress" +state = "working" subtags = [ "chat" ] url = "https://github.com/YunoHost-Apps/matrix-appservice-irc_ynh" @@ -2067,7 +2069,7 @@ url = "https://github.com/YunoHost-Apps/matterbridge_ynh" [mattermost] category = "communication" -level = 8 +level = 6 potential_alternative_to = [ "Slack" ] state = "working" subtags = [ "chat" ] @@ -2356,7 +2358,7 @@ url = "https://github.com/YunoHost-Apps/my_webapp_ynh" [mybb] category = "communication" -level = 6 +level = 8 state = "working" subtags = [ "forum" ] url = "https://github.com/YunoHost-Apps/mybb_ynh" @@ -2946,7 +2948,7 @@ url = "https://github.com/YunoHost-Apps/prometheus_ynh" [prosody] category = "communication" -level = 8 +level = 6 state = "working" url = "https://github.com/YunoHost-Apps/prosody_ynh" @@ -2964,10 +2966,11 @@ state = "notworking" url = "https://github.com/YunoHost-Apps/proxitok_ynh" [psitransfer] -category = "small_utilities" +category = "synchronization" level = 8 potential_alternative_to = [ "WeTransfer" ] state = "working" +subtags = [ "files" ] url = "https://github.com/YunoHost-Apps/psitransfer_ynh" [pterodactyl] @@ -3119,7 +3122,7 @@ url = "https://github.com/YunoHost-Apps/roadiz_ynh" [rocketchat] antifeatures = [ "not-totally-free" ] category = "communication" -level = 6 +level = 8 potential_alternative_to = [ "Slack" ] state = "working" subtags = [ "chat" ] @@ -3134,9 +3137,10 @@ subtags = [ "email" ] url = "https://github.com/YunoHost-Apps/roundcube_ynh" [rportd] +antifeatures = [ "deprecated-software" ] category = "system_tools" -level = 8 -state = "working" +level = 0 +state = "notworking" subtags = [ "monitoring" ] url = "https://github.com/YunoHost-Apps/rportd_ynh" @@ -3226,7 +3230,7 @@ url = "https://github.com/YunoHost-Apps/seafile_ynh" [searx] antifeatures = [ "deprecated-software" ] category = "small_utilities" -level = 8 +level = 7 state = "working" url = "https://github.com/YunoHost-Apps/searx_ynh" @@ -3544,7 +3548,7 @@ url = "https://github.com/drfred1981/subsonic_ynh" [sutom] category = "games" -level = 6 +level = 8 state = "working" url = "https://github.com/YunoHost-Apps/sutom_ynh" diff --git a/logos/homeassistant.png b/logos/homeassistant.png index 238c7109..13ca064c 100644 Binary files a/logos/homeassistant.png and b/logos/homeassistant.png differ diff --git a/schemas/apps.toml.schema.json b/schemas/apps.toml.schema.json new file mode 100644 index 00000000..4833c35d --- /dev/null +++ b/schemas/apps.toml.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/YunoHost/apps/blob/master/schemas/apps.toml.schema.json", + "title": "Yunohost's apps.toml schema", + "version": "0", + + "type": "object", + "required": [], + "additionalProperties": false, + "patternProperties": { + "^[a-z0-9_-]*$": { + "type": "object", + "required": ["url", "state"], + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "subtags": { + "type": "array", + "items": { + "type": "string" + }, + "additionalItems": false + }, + "level": { + "type": "integer", + "minimum": 0, + "maximum": 8 + }, + "state": { + "type": "string", + "enum": ["working", "notworking", "inprogress"] + }, + "url": { + "type": "string", + "format": "url" + }, + "antifeatures": { + "type": "array", + "items": { + "type": "string" + }, + "additionalItems": false + }, + "potential_alternative_to": { + "type": "array", + "items": { + "type": "string" + }, + "additionalItems": false + }, + "revision": { + "type": "string", + "pattern": "^[a-z0-9]{32,64}$" + }, + "branch": { + "type": "string" + } + } + } + } + +} diff --git a/schemas/categories.toml.schema.json b/schemas/categories.toml.schema.json new file mode 100644 index 00000000..ebb9b95e --- /dev/null +++ b/schemas/categories.toml.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/YunoHost/apps/blob/master/schemas/categories.toml.schema.json", + "title": "Yunohost's categories.toml schema", + "version": "0", + + "$defs": { + "translated_string": { + "type": "object", + "required": ["en"], + "additionalProperties": false, + "patternProperties": { + "^[a-z]{2}$": { + "type": "string" + } + } + } + }, + + "type": "object", + "required": [], + "additionalProperties": false, + "patternProperties": { + "^[a-z0-9_-]*$": { + "type": "object", + "required": ["icon", "title", "description"], + "additionalProperties": false, + "properties": { + "icon": { + "type": "string" + }, + "title": { "$ref": "#/$defs/translated_string" }, + "description": { "$ref": "#/$defs/translated_string" }, + "subtags": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z_]*$": { + "type": "object", + "required": ["title"], + "additionalProperties": false, + "properties": { + "title": { "$ref": "#/$defs/translated_string" } + } + } + } + } + } + } + } + +} diff --git a/schemas/manifest.v2.schema.json b/schemas/manifest.v2.schema.json index bb6f6b02..698affa9 100644 --- a/schemas/manifest.v2.schema.json +++ b/schemas/manifest.v2.schema.json @@ -7,14 +7,21 @@ "type": "object", "$defs": { "translated_string": { - "type": "object", - "required": ["en"], - "additionalProperties": false, - "patternProperties": { - "^[a-z]{2}$": { + "anyOf": [ + { + "type": "object", + "required": ["en"], + "additionalProperties": false, + "patternProperties": { + "^[a-z]{2}$": { + "type": "string" + } + } + }, + { "type": "string" } - } + ] }, "byte_size": { "type": "string", @@ -26,11 +33,11 @@ }, "path_absolute": { "type": "string", - "pattern": "^/.*$" + "pattern": "^(__[A-Z_]*__)?/.*$" }, "name_and_permission": { "type": "string", - "pattern": "^([a-z_][a-z0-9_-]{0,30})(:[rwx-]{3})?$" + "pattern": "^(([a-z_][a-z0-9_-]{0,30})|([_A-Z]*))(:[rwx-]{1,3})?$" }, "sha256sum": { "type": "string", @@ -142,6 +149,14 @@ } } }, + "antifeatures": { + "type": "object", + "required": [], + "additionalProperties": false, + "patternProperties": { + "^[a-zA-Z0-9_-]*$": {"$ref": "#/$defs/translated_string"} + } + }, "install": { "type": "object", "required": [], @@ -184,6 +199,20 @@ } } ] + }, + "pattern": { + "type": "object", + "required": [], + "additionalProperties": false, + "properties": { + "regexp": { + "type": "string", + "format": "regex" + }, + "error": { + "type": "string" + } + } } } } @@ -286,9 +315,10 @@ }, "additional_urls": { "type": "array", - "items": { - "type": "string" - } + "items": {"$ref": "#/$defs/path_absolute"} + }, + "label": { + "type": "string" } } } diff --git a/schemas/tests.v1.schema.json b/schemas/tests.v1.schema.json index e2aca8b6..cacc6399 100644 --- a/schemas/tests.v1.schema.json +++ b/schemas/tests.v1.schema.json @@ -23,7 +23,8 @@ "^[a-z][a-z0-9_]*$": { "anyOf": [ {"type": "string"}, - {"type": "number"} + {"type": "number"}, + {"type": "boolean"} ] } } diff --git a/tools/autoupdate_app_sources/autoupdate_app_sources.py b/tools/autoupdate_app_sources/autoupdate_app_sources.py index 3e4b4d7f..48ff84f4 100644 --- a/tools/autoupdate_app_sources/autoupdate_app_sources.py +++ b/tools/autoupdate_app_sources/autoupdate_app_sources.py @@ -385,7 +385,7 @@ class AppAutoUpdater: if is_main: def repl(m): - return m.group(1) + new_version + m.group(3) + return m.group(1) + new_version + "~ynh1" content = re.sub( r"(\s*version\s*=\s*[\"\'])([\d\.]+)(\~ynh\d+[\"\'])", repl, content diff --git a/tools/catalog_linter.py b/tools/catalog_linter.py old mode 100644 new mode 100755 index 32659899..6925cf81 --- a/tools/catalog_linter.py +++ b/tools/catalog_linter.py @@ -1,37 +1,103 @@ -import toml +#!/usr/bin/env python3 + +import json import sys +from functools import cache +from pathlib import Path +from typing import Any, Dict, Generator, List, Tuple -errors = [] +import jsonschema +import toml -catalog = toml.load(open('apps.toml')) +APPS_ROOT = Path(__file__).parent.parent -for app, infos in catalog.items(): + +@cache +def get_catalog() -> Dict[str, Dict[str, Any]]: + catalog_path = APPS_ROOT / "apps.toml" + return toml.load(catalog_path) + + +@cache +def get_categories() -> Dict[str, Any]: + categories_path = APPS_ROOT / "categories.toml" + return toml.load(categories_path) + + +@cache +def get_antifeatures() -> Dict[str, Any]: + antifeatures_path = APPS_ROOT / "antifeatures.toml" + return toml.load(antifeatures_path) + + +def validate_schema() -> Generator[str, None, None]: + with open(APPS_ROOT / "schemas" / "apps.toml.schema.json", encoding="utf-8") as file: + apps_catalog_schema = json.load(file) + validator = jsonschema.Draft202012Validator(apps_catalog_schema) + for error in validator.iter_errors(get_catalog()): + yield f"at .{'.'.join(error.path)}: {error.message}" + + +def check_app(app: str, infos: Dict[str, Any]) -> Generator[Tuple[str, bool], None, None]: if "state" not in infos: - errors.append(f"{app}: missing state info") + yield "state is missing", True + return -catalog = {app: infos for app, infos in catalog.items() if infos.get('state') == "working"} -categories = toml.load(open('categories.toml')).keys() + if infos["state"] != "working": + return + + repo_name = infos.get("url", "").split("/")[-1] + if repo_name != f"{app}_ynh": + yield f"repo name should be {app}_ynh, not in {repo_name}", True + + antifeatures = infos.get("antifeatures", []) + for antifeature in antifeatures: + if antifeature not in get_antifeatures(): + yield f"unknown antifeature {antifeature}", True + + category = infos.get("category") + if not category: + yield "category is missing", True + else: + if category not in get_categories(): + yield f"unknown category {category}", True + + subtags = infos.get("subtags", []) + for subtag in subtags: + if subtag not in get_categories().get(category, {}).get("subtags", []): + yield f"unknown subtag {category} / {subtag}", False -def check_apps(): - - for app, infos in catalog.items(): - - repo_name = infos.get("url", "").split("/")[-1] - if repo_name != app + "_ynh": - yield f"{app}: repo name should be {app}_ynh, not in {repo_name}" - - category = infos.get("category") - if not category: - yield f"{app}: missing category" - if category not in categories: - yield f"{app}: category {category} is not defined in categories.toml" +def check_all_apps() -> Generator[Tuple[str, List[Tuple[str, bool]]], None, None]: + for app, info in get_catalog().items(): + errors = list(check_app(app, info)) + if errors: + yield app, errors -errors = errors + list(check_apps()) +def main() -> None: + has_errors = False -for error in errors: - print(error) + schema_errors = list(validate_schema()) + if schema_errors: + has_errors = True + print("Error while validating catalog against schema:") + for error in schema_errors: + print(f" - {error}") + if schema_errors: + print() -if errors: - sys.exit(1) + for app, errors in check_all_apps(): + print(f"{app}:") + for error, is_fatal in errors: + if is_fatal: + has_errors = True + level = "error" if is_fatal else "warning" + print(f" - {level}: {error}") + + if has_errors: + sys.exit(1) + + +if __name__ == "__main__": + main()