diff --git a/.github/workflows/update-catalog.py b/.github/workflows/update-catalog.py index ff05463..d5b7711 100644 --- a/.github/workflows/update-catalog.py +++ b/.github/workflows/update-catalog.py @@ -15,6 +15,11 @@ REQUIRED_FIELDS = ("id", "name", "version", "author", "plugin_api", "tags") OPTIONAL_STRING_FIELDS = ("license", "icon", "description") OPTIONAL_BOOL_FIELDS = ("deprecated",) +# Oldest plugin API any supported Noctalia accepts (kOldestSupportedPluginApiVersion in the +# shell's src/scripting/plugin_api.h). Release rows below it can never be installed, so the +# history walk stops there. +OLDEST_SUPPORTED_PLUGIN_API = 3 + def git_commit_time(path: Path, *extra_args: str) -> int | None: """Commit time in Unix seconds, or None when `path` has no matching commit. @@ -30,6 +35,51 @@ def git_commit_time(path: Path, *extra_args: str) -> int | None: return int(stdout) if stdout else None +def git_output(*args: str) -> str: + return subprocess.run( + ["git", *args], capture_output=True, text=True, check=True + ).stdout + + +def release_history(subdir: str, tip_api: int) -> list[dict]: + """Older revisions of a plugin, one per API level below the tip's, newest first. + + A Noctalia below the tip's `plugin_api` has nothing to install unless the catalog names + a revision it can run. Walking `/plugin.toml` newest-first and keeping only the + revisions that lower the API level yields a strictly decreasing sequence, so each row is + the newest revision at or below its own level -- exactly what the host resolves against. + """ + releases = [] + lowest_api = tip_api + revisions = git_output( + "log", "--format=%H", "--", f"{subdir}/plugin.toml" + ).split() + + for revision in revisions: + if lowest_api <= OLDEST_SUPPORTED_PLUGIN_API: + break + try: + manifest = tomllib.loads( + git_output("show", f"{revision}:{subdir}/plugin.toml") + ) + except (subprocess.CalledProcessError, tomllib.TOMLDecodeError): + continue # unreadable or pre-`plugin_api` history + + plugin_api = manifest.get("plugin_api") + version = manifest.get("version") + if not isinstance(plugin_api, int) or isinstance(plugin_api, bool): + continue + if not isinstance(version, str) or not version: + continue + if plugin_api >= lowest_api or plugin_api < OLDEST_SUPPORTED_PLUGIN_API: + continue + + releases.append({"plugin_api": plugin_api, "version": version, "rev": revision}) + lowest_api = plugin_api + + return releases + + def load_plugin_manifest(path: Path) -> dict: with path.open("rb") as handle: manifest = tomllib.load(handle) @@ -91,6 +141,7 @@ def discover_plugins() -> list[dict]: directory = manifest_path.parent.name manifest["_directory"] = directory manifest["_order"] = order.get(manifest["id"], len(order)) + manifest["releases"] = release_history(directory, manifest["plugin_api"]) plugins.append(manifest) plugins.sort(key=lambda plugin: (plugin["_order"], plugin["_directory"])) @@ -113,6 +164,8 @@ def render_catalog(plugins: list[dict]) -> str: "# Index of every plugin this source ships: the minimum needed to render, search,", "# and compat-check the list. The per-plugin plugin.toml stays authoritative; the", "# host re-reads it on enable. Keep one [[plugin]] row per plugin subdirectory.", + "# A [[plugin.release]] row names an older revision for a Noctalia below the tip's", + "# plugin_api, so an older release stays installable instead of the plugin vanishing.", "", ] @@ -147,6 +200,16 @@ def render_catalog(plugins: list[dict]) -> str: + "]", ] ) + for release in plugin["releases"]: + lines.extend( + [ + "", + "[[plugin.release]]", + f"plugin_api = {release['plugin_api']}", + f"version = {toml_string(release['version'])}", + f"rev = {toml_string(release['rev'])}", + ] + ) return "\n".join(lines) + "\n"