Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

358 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
DEFAULT_SHARED_PATHS = []
def parse_args():
parser = argparse.ArgumentParser(
description="Plan an incremental MacTools plugin release."
)
parser.add_argument("--mode", choices=["auto", "all", "selected"], default="auto")
parser.add_argument("--plugins", default="", help="Comma-separated plugin IDs for selected mode.")
parser.add_argument("--plugin", action="append", default=[], help="Plugin ID or directory name. Repeatable.")
parser.add_argument("--source-dir", default="Plugins")
parser.add_argument("--previous-catalog", default="docs/plugins/catalog.json")
parser.add_argument("--output", required=True)
parser.add_argument(
"--shared-path",
action="append",
default=[],
help=(
"Repository path that forces existing plugins to rebuild when changed. "
"Repeatable; defaults to none."
),
)
return parser.parse_args()
def load_json(path):
try:
return json.loads(Path(path).read_text(encoding="utf-8"))
except FileNotFoundError:
return None
def git(args, check=True, capture=True):
kwargs = {
"check": check,
"text": True,
}
if capture:
kwargs["stdout"] = subprocess.PIPE
kwargs["stderr"] = subprocess.PIPE
return subprocess.run(["git", *args], **kwargs)
def git_ref_exists(ref):
return git(["rev-parse", "--verify", "--quiet", ref], check=False).returncode == 0
def changed_paths(ref, paths):
existing_paths = [path for path in paths if path]
if not existing_paths:
return []
result = git(["diff", "--name-only", f"{ref}..HEAD", "--", *existing_paths])
return [line for line in result.stdout.splitlines() if line.strip()]
def package_relevant_plugin_paths(paths):
result = []
for path in paths:
parts = Path(path).parts
if "Tests" in parts:
continue
result.append(path)
return result
def version_parts(version):
values = []
for component in version.split("."):
match = re.match(r"(\d+)", component)
values.append(int(match.group(1)) if match else 0)
return values
def compare_versions(lhs, rhs):
left = version_parts(lhs)
right = version_parts(rhs)
count = max(len(left), len(right))
left.extend([0] * (count - len(left)))
right.extend([0] * (count - len(right)))
if left < right:
return -1
if left > right:
return 1
return 0
def plugin_release_tag(entry):
candidates = [
((entry.get("package") or {}).get("url") or ""),
entry.get("releaseNotesURL") or "",
]
for value in candidates:
match = re.search(r"/releases/(?:download|tag)/([^/]+)", value)
if match:
return match.group(1)
return None
def read_plugins(source_dir):
root = Path(source_dir)
plugins = {}
for manifest_path in sorted(root.glob("*/plugin.json")):
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
plugin_id = manifest["id"]
plugins[plugin_id] = {
"id": plugin_id,
"directoryName": manifest_path.parent.name,
"path": manifest_path.parent.as_posix(),
"manifestPath": manifest_path.as_posix(),
"version": manifest["version"],
"pluginKitVersion": manifest["pluginKitVersion"],
"displayName": manifest.get("displayName", plugin_id),
}
return plugins
def current_plugin_kit_version(plugins):
versions = sorted({plugin["pluginKitVersion"] for plugin in plugins.values()})
if len(versions) != 1:
raise SystemExit(
"Plugin manifests must use one pluginKitVersion for a release: "
+ ", ".join(str(version) for version in versions)
)
return versions[0]
def normalize_selected(raw_values, plugins):
values = []
for raw in raw_values:
values.extend(part.strip() for part in raw.split(",") if part.strip())
by_directory = {plugin["directoryName"]: plugin_id for plugin_id, plugin in plugins.items()}
selected = []
unknown = []
for value in values:
plugin_id = value if value in plugins else by_directory.get(value)
if plugin_id is None:
unknown.append(value)
elif plugin_id not in selected:
selected.append(plugin_id)
return selected, unknown
def plan_release(args):
plugins = read_plugins(args.source_dir)
plugin_kit_version = current_plugin_kit_version(plugins)
previous_catalog = load_json(args.previous_catalog)
previous_catalog_plugin_kit_version = (previous_catalog or {}).get("pluginKitVersion")
previous_entries = {
entry["id"]: entry
for entry in (previous_catalog or {}).get("plugins", [])
}
shared_paths = args.shared_path or DEFAULT_SHARED_PATHS
selected_inputs, unknown_inputs = normalize_selected(
[args.plugins, *args.plugin],
plugins,
)
if unknown_inputs:
raise SystemExit("Unknown plugin selection: " + ", ".join(unknown_inputs))
removed_plugin_ids = []
if args.mode != "selected":
removed_plugin_ids = sorted(set(previous_entries) - set(plugins))
selected = []
reasons = {}
errors = []
full_release = False
if (
previous_catalog_plugin_kit_version is not None
and previous_catalog_plugin_kit_version != plugin_kit_version
):
# A catalog must never mix packages built against different PluginKit
# ABIs. The first release of a new ABI therefore replaces the entire
# catalog, even when the caller selected the default auto mode.
full_release = True
def select(plugin_id, reason):
if plugin_id not in selected:
selected.append(plugin_id)
reasons.setdefault(plugin_id, []).append(reason)
def previous_entry_plugin_kit_version(entry):
if "pluginKitVersion" in entry:
return entry["pluginKitVersion"]
if previous_catalog_plugin_kit_version is not None:
return previous_catalog_plugin_kit_version
raise SystemExit(
f"Previous catalog entry is missing pluginKitVersion: {entry.get('id', '(unknown)')}"
)
def handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp):
previous_version = previous_entry_plugin_kit_version(previous_entry)
if previous_version == plugin["pluginKitVersion"]:
return False
reason = f"pluginKitVersion {previous_version} -> {plugin['pluginKitVersion']}"
if version_cmp <= 0:
errors.append(
f"{plugin_id}: {reason}, but version is still {plugin['version']}. "
f"Bump {plugin['manifestPath']} version before publishing a PluginKit update."
)
else:
select(plugin_id, reason)
return True
if (
args.mode == "selected"
and previous_catalog_plugin_kit_version is not None
and previous_catalog_plugin_kit_version != plugin_kit_version
and set(selected_inputs) != set(plugins)
):
errors.append(
"Current pluginKitVersion differs from the previous catalog "
f"({previous_catalog_plugin_kit_version} -> {plugin_kit_version}). "
"Use --mode all so the catalog cannot mix incompatible plugin packages."
)
if args.mode == "all":
full_release = True
for plugin_id, plugin in sorted(plugins.items()):
previous_entry = previous_entries.get(plugin_id)
if previous_entry is not None:
previous_version = previous_entry["version"]
current_version = plugin["version"]
version_cmp = compare_versions(current_version, previous_version)
if version_cmp < 0:
errors.append(
f"{plugin_id}: version cannot go backwards "
f"({previous_version} -> {current_version})"
)
continue
handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp)
select(plugin_id, "all mode")
elif args.mode == "selected":
if not selected_inputs:
raise SystemExit("--mode selected requires --plugins or --plugin")
for plugin_id in selected_inputs:
select(plugin_id, "selected mode")
for plugin_id in selected_inputs:
plugin = plugins[plugin_id]
previous_entry = previous_entries.get(plugin_id)
if previous_entry is None:
continue
previous_version = previous_entry["version"]
current_version = plugin["version"]
version_cmp = compare_versions(current_version, previous_version)
if version_cmp < 0:
errors.append(
f"{plugin_id}: version cannot go backwards "
f"({previous_version} -> {current_version})"
)
continue
handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp)
elif not previous_catalog:
full_release = True
for plugin_id in sorted(plugins):
select(plugin_id, "no previous catalog")
else:
for plugin_id, plugin in sorted(plugins.items()):
previous_entry = previous_entries.get(plugin_id)
if previous_entry is None:
select(plugin_id, "new plugin")
continue
previous_version = previous_entry["version"]
current_version = plugin["version"]
version_cmp = compare_versions(current_version, previous_version)
if version_cmp < 0:
errors.append(
f"{plugin_id}: version cannot go backwards "
f"({previous_version} -> {current_version})"
)
continue
if handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp):
continue
if version_cmp > 0:
select(plugin_id, f"version {previous_version} -> {current_version}")
continue
tag = plugin_release_tag(previous_entry)
if not tag:
errors.append(f"{plugin_id}: previous release tag could not be inferred from catalog")
continue
if not git_ref_exists(tag):
errors.append(f"{plugin_id}: previous release tag is not available locally: {tag}")
continue
plugin_changes = package_relevant_plugin_paths(changed_paths(tag, [plugin["path"]]))
shared_changes = changed_paths(tag, shared_paths)
if plugin_changes or shared_changes:
if version_cmp <= 0:
change_samples = (plugin_changes + shared_changes)[:5]
errors.append(
f"{plugin_id}: package-relevant files changed since {tag}, "
f"but version is still {current_version}. "
f"Bump {plugin['manifestPath']} version. Changed paths: "
+ ", ".join(change_samples)
)
else:
select(plugin_id, f"package-relevant changes since {tag}")
if errors:
print("\n".join(errors), file=sys.stderr)
raise SystemExit(1)
selected = sorted(selected)
plan = {
"mode": args.mode,
"releaseRequired": bool(selected or removed_plugin_ids),
"fullRelease": full_release,
"pluginKitVersion": plugin_kit_version,
"selectedPluginIDs": selected,
"removedPluginIDs": removed_plugin_ids,
"reasons": {plugin_id: reasons.get(plugin_id, []) for plugin_id in selected},
"plugins": [
{
"id": plugin_id,
"displayName": plugins[plugin_id]["displayName"],
"version": plugins[plugin_id]["version"],
"path": plugins[plugin_id]["path"],
}
for plugin_id in selected
],
}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(plan, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return plan
def main():
args = parse_args()
plan = plan_release(args)
if plan["selectedPluginIDs"]:
print("Selected plugins: " + ", ".join(plan["selectedPluginIDs"]))
elif plan["removedPluginIDs"]:
print("No plugin packages selected; removed plugins: " + ", ".join(plan["removedPluginIDs"]))
else:
print("No plugin release changes detected.")
if __name__ == "__main__":
main()