358 lines
13 KiB
Python
Executable File
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()
|