This commit is contained in:
Executable
+357
@@ -0,0 +1,357 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user