1096 lines
40 KiB
Python
Executable File
1096 lines
40 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import termios
|
||
import tty
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
|
||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||
APP_VERSION_CONFIG = ROOT_DIR / "Configs" / "AppVersion.xcconfig"
|
||
CHANGELOG_PATH = ROOT_DIR / "CHANGELOG.md"
|
||
CHANGELOG_FRAGMENT_DIR = ROOT_DIR / "changes" / "unreleased"
|
||
PLUGIN_SOURCE_DIR = ROOT_DIR / "Plugins"
|
||
LEGACY_PLUGIN_CATALOG = ROOT_DIR / "docs/plugins/catalog.json"
|
||
PLUGIN_PLAN_PATH = ROOT_DIR / "build/release/plugin-plan.json"
|
||
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
||
APP_TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$")
|
||
PLUGIN_TAG_RE = re.compile(r"^plugins-(\d+\.\d+\.\d+)$")
|
||
|
||
|
||
class ReleaseError(RuntimeError):
|
||
pass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PluginInfo:
|
||
id: str
|
||
directory_name: str
|
||
display_name: str
|
||
path: Path
|
||
manifest_path: Path
|
||
version: str
|
||
plugin_kit_version: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PluginAnalysis:
|
||
mode: str
|
||
selected_ids: list[str]
|
||
removed_ids: list[str]
|
||
needs_bump: list[PluginInfo]
|
||
already_bumped: list[PluginInfo]
|
||
new_plugins: list[PluginInfo]
|
||
reasons: dict[str, list[str]]
|
||
|
||
@property
|
||
def release_required(self) -> bool:
|
||
return bool(
|
||
self.selected_ids
|
||
or self.removed_ids
|
||
or self.needs_bump
|
||
or self.already_bumped
|
||
or self.new_plugins
|
||
)
|
||
|
||
|
||
def info(message: str) -> None:
|
||
print(f"[release] {message}", flush=True)
|
||
|
||
|
||
def fail(message: str) -> None:
|
||
raise ReleaseError(message)
|
||
|
||
|
||
def run(
|
||
args: list[str],
|
||
*,
|
||
cwd: Path = ROOT_DIR,
|
||
capture: bool = False,
|
||
dry_run: bool = False,
|
||
mutates: bool = False,
|
||
) -> subprocess.CompletedProcess[str]:
|
||
if dry_run and mutates:
|
||
print("+ " + " ".join(shell_quote(part) for part in args))
|
||
return subprocess.CompletedProcess(args, 0, "" if capture else None, "")
|
||
|
||
sys.stdout.flush()
|
||
sys.stderr.flush()
|
||
|
||
kwargs: dict[str, object] = {
|
||
"cwd": cwd,
|
||
"text": True,
|
||
"check": False,
|
||
}
|
||
if capture:
|
||
kwargs["stdout"] = subprocess.PIPE
|
||
kwargs["stderr"] = subprocess.PIPE
|
||
|
||
result = subprocess.run(args, **kwargs)
|
||
if result.returncode != 0:
|
||
if capture and result.stderr:
|
||
sys.stderr.write(result.stderr)
|
||
command = " ".join(shell_quote(part) for part in args)
|
||
fail(f"命令失败:{command}")
|
||
return result
|
||
|
||
|
||
def shell_quote(value: str) -> str:
|
||
if re.fullmatch(r"[A-Za-z0-9_./:=@%+,-]+", value):
|
||
return value
|
||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||
|
||
|
||
def git(args: list[str], *, capture: bool = True) -> str:
|
||
result = run(["git", *args], capture=capture)
|
||
return (result.stdout or "").strip()
|
||
|
||
|
||
def require_command(command: str) -> None:
|
||
if shutil.which(command) is None:
|
||
fail(f"缺少必要命令:{command}")
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description="Interactive release helper for MacTools app and plugin releases."
|
||
)
|
||
parser.add_argument("--type", choices=["app", "plugin"], help="发布类型。")
|
||
parser.add_argument("--version", help="目标版本,不含 v 或 plugins- 前缀。")
|
||
parser.add_argument("--level", choices=["patch", "minor", "major"], help="版本递增级别。")
|
||
parser.add_argument(
|
||
"--plugin-mode",
|
||
choices=["auto", "selected", "all"],
|
||
default=None,
|
||
help="插件发布模式,默认 auto。",
|
||
)
|
||
parser.add_argument(
|
||
"--plugin",
|
||
action="append",
|
||
default=[],
|
||
help="插件 ID 或目录名;可重复,也可用逗号分隔。仅用于 --plugin-mode selected。",
|
||
)
|
||
parser.add_argument("--remote", default="origin", help="Git remote,默认 origin。")
|
||
parser.add_argument("--branch", default="main", help="发布分支,默认 main。")
|
||
parser.add_argument("--yes", action="store_true", help="跳过最终确认。")
|
||
parser.add_argument("--skip-check", action="store_true", help="跳过本地轻量检查。")
|
||
parser.add_argument("--dry-run", action="store_true", help="只展示将执行的写操作。")
|
||
return parser.parse_args()
|
||
|
||
|
||
def read_interactive_line(prompt: str) -> str:
|
||
sys.stdout.write(prompt)
|
||
sys.stdout.flush()
|
||
|
||
fd = sys.stdin.fileno()
|
||
previous_settings = termios.tcgetattr(fd)
|
||
value: list[str] = []
|
||
|
||
try:
|
||
tty.setcbreak(fd)
|
||
while True:
|
||
char = sys.stdin.read(1)
|
||
if char == "":
|
||
sys.stdout.write("\n")
|
||
fail("已取消发布。")
|
||
if char == "\x1b":
|
||
sys.stdout.write("\n")
|
||
fail("已取消发布。")
|
||
if char in {"\r", "\n"}:
|
||
sys.stdout.write("\n")
|
||
return "".join(value)
|
||
if char in {"\x03", "\x04"}:
|
||
sys.stdout.write("\n")
|
||
fail("已取消发布。")
|
||
if char in {"\x7f", "\b"}:
|
||
if value:
|
||
value.pop()
|
||
sys.stdout.write("\b \b")
|
||
sys.stdout.flush()
|
||
continue
|
||
if char == "\x15":
|
||
while value:
|
||
value.pop()
|
||
sys.stdout.write("\b \b")
|
||
sys.stdout.flush()
|
||
continue
|
||
if char.isprintable():
|
||
value.append(char)
|
||
sys.stdout.write(char)
|
||
sys.stdout.flush()
|
||
finally:
|
||
termios.tcsetattr(fd, termios.TCSADRAIN, previous_settings)
|
||
|
||
|
||
def prompt_choice(title: str, choices: list[tuple[str, str]]) -> str:
|
||
if not sys.stdin.isatty():
|
||
fail(f"非交互环境需要通过参数指定:{title}")
|
||
|
||
print(title)
|
||
for index, (_, label) in enumerate(choices, start=1):
|
||
print(f" {index}. {label}")
|
||
|
||
while True:
|
||
raw = read_interactive_line("> ").strip()
|
||
if not raw:
|
||
return choices[0][0]
|
||
if raw.isdigit():
|
||
index = int(raw)
|
||
if 1 <= index <= len(choices):
|
||
return choices[index - 1][0]
|
||
for value, label in choices:
|
||
if raw == value or raw == label:
|
||
return value
|
||
print("请输入列表中的编号或值,或按 Esc 退出。")
|
||
|
||
|
||
def prompt_text(title: str) -> str:
|
||
if not sys.stdin.isatty():
|
||
fail(f"非交互环境需要通过参数指定:{title}")
|
||
while True:
|
||
raw = read_interactive_line(f"{title}\n> ").strip()
|
||
if raw:
|
||
return raw
|
||
print("输入不能为空,或按 Esc 退出。")
|
||
|
||
|
||
def confirm(message: str, assume_yes: bool) -> None:
|
||
if assume_yes:
|
||
return
|
||
if not sys.stdin.isatty():
|
||
fail("非交互环境需要传入 --yes。")
|
||
while True:
|
||
raw = read_interactive_line(f"{message} [y/N] ").strip().lower()
|
||
if raw in {"y", "yes"}:
|
||
return
|
||
if raw in {"n", "no"}:
|
||
fail("已取消发布。")
|
||
print("请输入 y 确认,n 取消,或按 Esc 退出。")
|
||
|
||
|
||
def ensure_clean_worktree() -> None:
|
||
status = git(["status", "--porcelain"])
|
||
if status:
|
||
fail(
|
||
"发布前 Git 工作区必须干净。请先提交或暂存当前改动。\n"
|
||
+ status
|
||
)
|
||
|
||
|
||
def warn_dirty_worktree_for_dry_run() -> None:
|
||
status = git(["status", "--porcelain"])
|
||
if status:
|
||
info("Dry-run: 当前工作区有未提交改动,正式发布前仍需要清理。")
|
||
|
||
|
||
def ensure_release_branch(branch: str) -> None:
|
||
current_branch = git(["branch", "--show-current"])
|
||
if current_branch != branch:
|
||
fail(f"当前分支是 {current_branch or '(detached)'},请切到 {branch} 后再发布。")
|
||
|
||
|
||
def fetch_release_refs(remote: str, dry_run: bool) -> None:
|
||
info(f"Fetching release refs from {remote}")
|
||
run(["git", "fetch", "--prune", "--tags", remote], dry_run=dry_run, mutates=True)
|
||
|
||
|
||
def sync_branch_after_confirm(remote: str, branch: str, dry_run: bool) -> None:
|
||
info(f"Rebasing local commits onto {remote}/{branch}")
|
||
command = ["git", "pull", "--rebase", remote, branch]
|
||
if dry_run:
|
||
run(command, dry_run=dry_run, mutates=True)
|
||
else:
|
||
result = subprocess.run(command, cwd=ROOT_DIR, text=True, check=False)
|
||
if result.returncode != 0:
|
||
fail(
|
||
"同步远端失败。若 rebase 发生冲突,请解决冲突后执行 "
|
||
"`git rebase --continue`,或执行 `git rebase --abort` 回到发布前状态后重新运行 make release。"
|
||
)
|
||
if dry_run:
|
||
warn_dirty_worktree_for_dry_run()
|
||
else:
|
||
ensure_clean_worktree()
|
||
|
||
|
||
def semver_tuple(version: str) -> tuple[int, int, int]:
|
||
match = SEMVER_RE.fullmatch(version)
|
||
if not match:
|
||
fail(f"版本号必须是 x.y.z 格式:{version}")
|
||
return tuple(int(part) for part in match.groups())
|
||
|
||
|
||
def format_semver(value: tuple[int, int, int]) -> str:
|
||
return ".".join(str(part) for part in value)
|
||
|
||
|
||
def bump_version(version: str, level: str) -> str:
|
||
major, minor, patch = semver_tuple(version)
|
||
if level == "major":
|
||
return f"{major + 1}.0.0"
|
||
if level == "minor":
|
||
return f"{major}.{minor + 1}.0"
|
||
if level == "patch":
|
||
return f"{major}.{minor}.{patch + 1}"
|
||
fail(f"未知版本级别:{level}")
|
||
|
||
|
||
def normalize_requested_version(version: str | None) -> str | None:
|
||
if version is None:
|
||
return None
|
||
normalized = version.strip()
|
||
if normalized.startswith("plugins-"):
|
||
normalized = normalized.removeprefix("plugins-")
|
||
elif normalized.startswith("v"):
|
||
normalized = normalized.removeprefix("v")
|
||
semver_tuple(normalized)
|
||
return normalized
|
||
|
||
|
||
def infer_bump_level(base_version: str, target_version: str) -> str:
|
||
base = semver_tuple(base_version)
|
||
target = semver_tuple(target_version)
|
||
if target <= base:
|
||
fail(f"目标版本必须高于当前基准版本:{base_version} -> {target_version}")
|
||
if target[0] > base[0]:
|
||
return "major"
|
||
if target[1] > base[1]:
|
||
return "minor"
|
||
return "patch"
|
||
|
||
|
||
def max_version(values: list[str], fallback: str) -> str:
|
||
if not values:
|
||
return fallback
|
||
return format_semver(max(semver_tuple(value) for value in values))
|
||
|
||
|
||
def compare_versions(lhs: str, rhs: str) -> int:
|
||
left = semver_tuple(lhs)
|
||
right = semver_tuple(rhs)
|
||
if left < right:
|
||
return -1
|
||
if left > right:
|
||
return 1
|
||
return 0
|
||
|
||
|
||
def latest_tag_version(pattern: str, tag_re: re.Pattern[str]) -> str | None:
|
||
raw = git(["tag", "--list", pattern])
|
||
versions = []
|
||
for tag in raw.splitlines():
|
||
match = tag_re.fullmatch(tag.strip())
|
||
if match:
|
||
versions.append(match.group(1))
|
||
if not versions:
|
||
return None
|
||
return max_version(versions, "0.0.0")
|
||
|
||
|
||
def read_app_versions() -> tuple[str, int]:
|
||
content = APP_VERSION_CONFIG.read_text(encoding="utf-8")
|
||
marketing = re.search(r"(?m)^\s*MARKETING_VERSION\s*=\s*([^\s#]+)", content)
|
||
build = re.search(r"(?m)^\s*CURRENT_PROJECT_VERSION\s*=\s*([0-9]+)", content)
|
||
if not marketing or not build:
|
||
fail("无法从 Configs/AppVersion.xcconfig 读取 MARKETING_VERSION 或 CURRENT_PROJECT_VERSION。")
|
||
return marketing.group(1), int(build.group(1))
|
||
|
||
|
||
def write_app_versions(version: str, build_number: int, dry_run: bool) -> None:
|
||
if dry_run:
|
||
print(f"+ update Configs/AppVersion.xcconfig MARKETING_VERSION={version} CURRENT_PROJECT_VERSION={build_number}")
|
||
return
|
||
|
||
content = APP_VERSION_CONFIG.read_text(encoding="utf-8")
|
||
content, marketing_count = re.subn(
|
||
r"(?m)^(\s*MARKETING_VERSION\s*=\s*)[^\s#]+",
|
||
rf"\g<1>{version}",
|
||
content,
|
||
count=1,
|
||
)
|
||
content, build_count = re.subn(
|
||
r"(?m)^(\s*CURRENT_PROJECT_VERSION\s*=\s*)[0-9]+",
|
||
rf"\g<1>{build_number}",
|
||
content,
|
||
count=1,
|
||
)
|
||
if marketing_count != 1 or build_count != 1:
|
||
fail("更新 Configs/AppVersion.xcconfig 版本号失败。")
|
||
APP_VERSION_CONFIG.write_text(content, encoding="utf-8")
|
||
|
||
|
||
def read_plugins() -> dict[str, PluginInfo]:
|
||
plugins: dict[str, PluginInfo] = {}
|
||
for manifest_path in sorted(PLUGIN_SOURCE_DIR.glob("*/plugin.json")):
|
||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
plugin_id = manifest["id"]
|
||
plugins[plugin_id] = PluginInfo(
|
||
id=plugin_id,
|
||
directory_name=manifest_path.parent.name,
|
||
display_name=manifest.get("displayName", plugin_id),
|
||
path=manifest_path.parent,
|
||
manifest_path=manifest_path,
|
||
version=manifest["version"],
|
||
plugin_kit_version=manifest["pluginKitVersion"],
|
||
)
|
||
return plugins
|
||
|
||
|
||
def plugin_catalog_path(plugin_kit_version: int) -> Path:
|
||
if plugin_kit_version == 2:
|
||
return LEGACY_PLUGIN_CATALOG
|
||
return ROOT_DIR / "docs" / "plugins" / f"v{plugin_kit_version}" / "catalog.json"
|
||
|
||
|
||
def previous_plugin_catalog_path() -> Path:
|
||
plugin_kit_version = current_plugin_kit_version(read_plugins())
|
||
preferred_path = plugin_catalog_path(plugin_kit_version)
|
||
if preferred_path.exists() or preferred_path == LEGACY_PLUGIN_CATALOG:
|
||
return preferred_path
|
||
|
||
previous_versioned_catalogs = []
|
||
for candidate in (ROOT_DIR / "docs" / "plugins").glob("v*/catalog.json"):
|
||
match = re.fullmatch(r"v(\d+)", candidate.parent.name)
|
||
if match and int(match.group(1)) < plugin_kit_version and candidate.exists():
|
||
previous_versioned_catalogs.append((int(match.group(1)), candidate))
|
||
if previous_versioned_catalogs:
|
||
return max(previous_versioned_catalogs, key=lambda item: item[0])[1]
|
||
|
||
# The first v3 release compares against the immutable v2 catalog. Once a
|
||
# versioned catalog exists, subsequent releases stay within the newest
|
||
# available ABI line.
|
||
return LEGACY_PLUGIN_CATALOG
|
||
|
||
|
||
def read_previous_catalog() -> dict:
|
||
catalog_path = previous_plugin_catalog_path()
|
||
if not catalog_path.exists():
|
||
return {}
|
||
return json.loads(catalog_path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def load_previous_catalog() -> dict[str, dict]:
|
||
catalog = read_previous_catalog()
|
||
return {entry["id"]: entry for entry in catalog.get("plugins", [])}
|
||
|
||
|
||
def current_plugin_kit_version(plugins: dict[str, PluginInfo]) -> int:
|
||
versions = sorted({plugin.plugin_kit_version for plugin in plugins.values()})
|
||
if len(versions) != 1:
|
||
fail(
|
||
"插件 manifest 必须使用同一个 pluginKitVersion:"
|
||
+ ", ".join(str(version) for version in versions)
|
||
)
|
||
return versions[0]
|
||
|
||
|
||
def plugin_release_tag(entry: dict) -> str | None:
|
||
package = entry.get("package") or {}
|
||
candidates = [package.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 git_ref_exists(ref: str) -> bool:
|
||
result = subprocess.run(
|
||
["git", "rev-parse", "--verify", "--quiet", ref],
|
||
cwd=ROOT_DIR,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
text=True,
|
||
)
|
||
return result.returncode == 0
|
||
|
||
|
||
def changed_paths_since(ref: str, path: Path) -> list[str]:
|
||
raw = git(["diff", "--name-only", f"{ref}..HEAD", "--", path.relative_to(ROOT_DIR).as_posix()])
|
||
result = []
|
||
for item in raw.splitlines():
|
||
parts = Path(item).parts
|
||
if "Tests" in parts:
|
||
continue
|
||
result.append(item)
|
||
return result
|
||
|
||
|
||
def normalize_plugin_selection(raw_values: list[str], plugins: dict[str, PluginInfo]) -> list[str]:
|
||
values: list[str] = []
|
||
for raw in raw_values:
|
||
values.extend(part.strip() for part in raw.split(",") if part.strip())
|
||
|
||
by_directory = {plugin.directory_name: plugin_id for plugin_id, plugin in plugins.items()}
|
||
selected: list[str] = []
|
||
unknown: list[str] = []
|
||
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)
|
||
|
||
if unknown:
|
||
fail("未知插件:" + ", ".join(unknown))
|
||
return selected
|
||
|
||
|
||
def analyze_plugins(mode: str, selection: list[str]) -> PluginAnalysis:
|
||
plugins = read_plugins()
|
||
plugin_kit_version = current_plugin_kit_version(plugins)
|
||
previous_catalog = read_previous_catalog()
|
||
previous_catalog_plugin_kit_version = previous_catalog.get("pluginKitVersion")
|
||
previous_entries = load_previous_catalog()
|
||
reasons: dict[str, list[str]] = {}
|
||
selected_ids: list[str] = []
|
||
needs_bump: list[PluginInfo] = []
|
||
already_bumped: list[PluginInfo] = []
|
||
new_plugins: list[PluginInfo] = []
|
||
removed_ids: list[str] = []
|
||
errors: list[str] = []
|
||
|
||
def select(plugin: PluginInfo, reason: str) -> None:
|
||
if plugin.id not in selected_ids:
|
||
selected_ids.append(plugin.id)
|
||
reasons.setdefault(plugin.id, []).append(reason)
|
||
|
||
def previous_entry_plugin_kit_version(entry: dict) -> int:
|
||
if "pluginKitVersion" in entry:
|
||
return entry["pluginKitVersion"]
|
||
if previous_catalog_plugin_kit_version is not None:
|
||
return previous_catalog_plugin_kit_version
|
||
fail(f"生产 catalog 条目缺少 pluginKitVersion:{entry.get('id', '(unknown)')}")
|
||
|
||
def handle_plugin_kit_change(plugin: PluginInfo, previous_entry: dict, version_cmp: int) -> bool:
|
||
previous_version = previous_entry_plugin_kit_version(previous_entry)
|
||
if previous_version == plugin.plugin_kit_version:
|
||
return False
|
||
|
||
reason = f"pluginKitVersion {previous_version} -> {plugin.plugin_kit_version}"
|
||
if version_cmp > 0:
|
||
already_bumped.append(plugin)
|
||
else:
|
||
needs_bump.append(plugin)
|
||
select(plugin, reason)
|
||
return True
|
||
|
||
if mode == "selected":
|
||
selected_ids = normalize_plugin_selection(selection, plugins)
|
||
if (
|
||
previous_catalog_plugin_kit_version is not None
|
||
and previous_catalog_plugin_kit_version != plugin_kit_version
|
||
and set(selected_ids) != set(plugins)
|
||
):
|
||
errors.append(
|
||
"当前 PluginKit 版本和生产 catalog 不一致 "
|
||
f"({previous_catalog_plugin_kit_version} -> {plugin_kit_version})。"
|
||
"请使用 plugin-mode all 全量重建插件,避免 catalog 混合不兼容插件包。"
|
||
)
|
||
for plugin_id in selected_ids:
|
||
reasons.setdefault(plugin_id, []).append("selected mode")
|
||
elif mode == "all":
|
||
for plugin_id, plugin in sorted(plugins.items()):
|
||
previous_entry = previous_entries.get(plugin_id)
|
||
if previous_entry is None:
|
||
new_plugins.append(plugin)
|
||
else:
|
||
previous_version = previous_entry["version"]
|
||
if semver_tuple(plugin.version) < semver_tuple(previous_version):
|
||
errors.append(
|
||
f"{plugin_id}: version cannot go backwards "
|
||
f"({previous_version} -> {plugin.version})"
|
||
)
|
||
continue
|
||
handle_plugin_kit_change(
|
||
plugin,
|
||
previous_entry,
|
||
compare_versions(plugin.version, previous_version),
|
||
)
|
||
if (
|
||
semver_tuple(plugin.version) > semver_tuple(previous_version)
|
||
and previous_entry_plugin_kit_version(previous_entry) == plugin.plugin_kit_version
|
||
):
|
||
already_bumped.append(plugin)
|
||
select(plugin, "all mode")
|
||
elif not previous_entries:
|
||
for plugin_id, plugin in sorted(plugins.items()):
|
||
new_plugins.append(plugin)
|
||
select(plugin, "no previous catalog")
|
||
else:
|
||
removed_ids = sorted(set(previous_entries) - set(plugins))
|
||
for plugin_id, plugin in sorted(plugins.items()):
|
||
previous_entry = previous_entries.get(plugin_id)
|
||
if previous_entry is None:
|
||
new_plugins.append(plugin)
|
||
select(plugin, "new plugin")
|
||
continue
|
||
|
||
previous_version = previous_entry["version"]
|
||
current_version = plugin.version
|
||
if semver_tuple(current_version) < semver_tuple(previous_version):
|
||
errors.append(
|
||
f"{plugin_id}: version cannot go backwards "
|
||
f"({previous_version} -> {current_version})"
|
||
)
|
||
continue
|
||
if handle_plugin_kit_change(
|
||
plugin,
|
||
previous_entry,
|
||
compare_versions(current_version, previous_version),
|
||
):
|
||
continue
|
||
if semver_tuple(current_version) > semver_tuple(previous_version):
|
||
already_bumped.append(plugin)
|
||
select(plugin, 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
|
||
|
||
changed = changed_paths_since(tag, plugin.path)
|
||
if changed:
|
||
needs_bump.append(plugin)
|
||
select(plugin, f"package-relevant changes since {tag}")
|
||
|
||
if mode == "selected":
|
||
for plugin_id in selected_ids:
|
||
plugin = plugins[plugin_id]
|
||
previous_entry = previous_entries.get(plugin_id)
|
||
if previous_entry is None:
|
||
new_plugins.append(plugin)
|
||
continue
|
||
|
||
previous_version = previous_entry["version"]
|
||
if semver_tuple(plugin.version) < semver_tuple(previous_version):
|
||
errors.append(
|
||
f"{plugin_id}: version cannot go backwards "
|
||
f"({previous_version} -> {plugin.version})"
|
||
)
|
||
continue
|
||
if handle_plugin_kit_change(
|
||
plugin,
|
||
previous_entry,
|
||
compare_versions(plugin.version, previous_version),
|
||
):
|
||
continue
|
||
if semver_tuple(plugin.version) > semver_tuple(previous_version):
|
||
already_bumped.append(plugin)
|
||
continue
|
||
|
||
tag = plugin_release_tag(previous_entry)
|
||
changed = changed_paths_since(tag, plugin.path) if tag and git_ref_exists(tag) else []
|
||
if changed:
|
||
needs_bump.append(plugin)
|
||
|
||
if errors:
|
||
fail("\n".join(errors))
|
||
|
||
deduped_selected = sorted(dict.fromkeys(selected_ids))
|
||
deduped_needs_bump = list({plugin.id: plugin for plugin in needs_bump}.values())
|
||
deduped_already_bumped = list({plugin.id: plugin for plugin in already_bumped}.values())
|
||
deduped_new_plugins = list({plugin.id: plugin for plugin in new_plugins}.values())
|
||
|
||
return PluginAnalysis(
|
||
mode=mode,
|
||
selected_ids=deduped_selected,
|
||
removed_ids=removed_ids,
|
||
needs_bump=deduped_needs_bump,
|
||
already_bumped=deduped_already_bumped,
|
||
new_plugins=deduped_new_plugins,
|
||
reasons={plugin_id: reasons.get(plugin_id, []) for plugin_id in deduped_selected},
|
||
)
|
||
|
||
|
||
def write_plugin_versions(plugins: list[PluginInfo], level: str, dry_run: bool) -> dict[str, str]:
|
||
updated: dict[str, str] = {}
|
||
for plugin in plugins:
|
||
next_version = bump_version(plugin.version, level)
|
||
updated[plugin.id] = next_version
|
||
if dry_run:
|
||
print(f"+ update {plugin.manifest_path.relative_to(ROOT_DIR)} version={next_version}")
|
||
continue
|
||
|
||
manifest = json.loads(plugin.manifest_path.read_text(encoding="utf-8"))
|
||
manifest["version"] = next_version
|
||
plugin.manifest_path.write_text(
|
||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
return updated
|
||
|
||
|
||
def refresh_plugins(plugins: list[PluginInfo]) -> list[PluginInfo]:
|
||
latest = read_plugins()
|
||
return [latest[plugin.id] for plugin in plugins]
|
||
|
||
|
||
def plugins_requiring_version_bump(mode: str, analysis: PluginAnalysis) -> list[PluginInfo]:
|
||
if mode == "auto":
|
||
return analysis.needs_bump
|
||
|
||
plugins = read_plugins()
|
||
previous_entries = load_previous_catalog()
|
||
result: list[PluginInfo] = []
|
||
for plugin_id in analysis.selected_ids:
|
||
plugin = plugins[plugin_id]
|
||
previous_entry = previous_entries.get(plugin_id)
|
||
if previous_entry is None:
|
||
continue
|
||
if semver_tuple(plugin.version) == semver_tuple(previous_entry["version"]):
|
||
result.append(plugin)
|
||
return result
|
||
|
||
|
||
def check_tag_available(tag: str, remote: str) -> None:
|
||
if git_ref_exists(f"refs/tags/{tag}"):
|
||
fail(f"本地 tag 已存在:{tag}")
|
||
result = subprocess.run(
|
||
["git", "ls-remote", "--exit-code", "--tags", remote, f"refs/tags/{tag}"],
|
||
cwd=ROOT_DIR,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
text=True,
|
||
)
|
||
if result.returncode == 0:
|
||
fail(f"远端 tag 已存在:{tag}")
|
||
if result.returncode not in {0, 2}:
|
||
fail(f"无法检查远端 tag:{tag}")
|
||
|
||
|
||
def run_app_check(skip_check: bool, dry_run: bool) -> None:
|
||
if skip_check:
|
||
info("Skipping app check")
|
||
return
|
||
if os.environ.get("CI") == "true":
|
||
info("Running CI app check")
|
||
run(["make", "generate"], dry_run=dry_run, mutates=True)
|
||
run(
|
||
[
|
||
"xcodebuild",
|
||
"-project",
|
||
"MacTools.xcodeproj",
|
||
"-scheme",
|
||
"MacTools",
|
||
"-configuration",
|
||
"Debug",
|
||
"-destination",
|
||
"platform=macOS",
|
||
"-derivedDataPath",
|
||
"build/DerivedData",
|
||
"CODE_SIGNING_ALLOWED=NO",
|
||
"CODE_SIGNING_REQUIRED=NO",
|
||
"CODE_SIGN_IDENTITY=",
|
||
"test",
|
||
"-quiet",
|
||
],
|
||
dry_run=dry_run,
|
||
mutates=True,
|
||
)
|
||
else:
|
||
info("Running app build check")
|
||
run(["make", "build"], dry_run=dry_run, mutates=True)
|
||
|
||
|
||
def changelog_fragment_paths(release: str) -> list[Path]:
|
||
if not CHANGELOG_FRAGMENT_DIR.exists():
|
||
return []
|
||
paths: list[Path] = []
|
||
release_pattern = re.compile(rf"(?m)^\s*release\s*:\s*['\"]?{re.escape(release)}['\"]?\s*$")
|
||
for path in sorted(CHANGELOG_FRAGMENT_DIR.glob("*.md")):
|
||
if not path.is_file():
|
||
continue
|
||
content = path.read_text(encoding="utf-8")
|
||
if release_pattern.search(content):
|
||
paths.append(path)
|
||
return paths
|
||
|
||
|
||
def changelog_status_label(release: str) -> str:
|
||
count = len(changelog_fragment_paths(release))
|
||
if count == 1:
|
||
return f"1 pending {release} fragment"
|
||
return f"{count} pending {release} fragments"
|
||
|
||
|
||
def validate_changelog(release: str, require_pending: bool) -> None:
|
||
command = ["python3", "scripts/changelog.py", "validate", "--release", release]
|
||
if require_pending:
|
||
command.append("--require-pending")
|
||
run(command)
|
||
|
||
|
||
def prepare_changelog(release: str, tag: str, dry_run: bool) -> list[Path]:
|
||
paths = [CHANGELOG_PATH, *changelog_fragment_paths(release)]
|
||
info(f"Preparing CHANGELOG.md for {tag}")
|
||
command = ["python3", "scripts/changelog.py", "prepare", "--release", release, "--tag", tag]
|
||
if dry_run:
|
||
command.append("--dry-run")
|
||
run(command, dry_run=False)
|
||
return paths
|
||
|
||
|
||
def run_plugin_generate_check(skip_check: bool, dry_run: bool) -> None:
|
||
if skip_check:
|
||
info("Skipping plugin check")
|
||
return
|
||
info("Running plugin project generation check")
|
||
run(["make", "generate"], dry_run=dry_run, mutates=True)
|
||
|
||
|
||
def run_plugin_plan_check(mode: str, selection: list[str], skip_check: bool, dry_run: bool) -> None:
|
||
if skip_check:
|
||
return
|
||
info("Running plugin release plan check")
|
||
plan_args = [
|
||
"scripts/plugins/plan-plugin-release.py",
|
||
"--mode",
|
||
mode,
|
||
"--source-dir",
|
||
"Plugins",
|
||
"--previous-catalog",
|
||
previous_plugin_catalog_path().relative_to(ROOT_DIR).as_posix(),
|
||
"--output",
|
||
PLUGIN_PLAN_PATH.relative_to(ROOT_DIR).as_posix(),
|
||
]
|
||
if mode == "selected" and selection:
|
||
plan_args.extend(["--plugins", ",".join(selection)])
|
||
run(plan_args, dry_run=dry_run, mutates=True)
|
||
|
||
|
||
def commit_if_needed(paths: list[Path], message: str, dry_run: bool) -> bool:
|
||
relative_paths = [path.relative_to(ROOT_DIR).as_posix() for path in paths]
|
||
if not relative_paths:
|
||
return False
|
||
if dry_run:
|
||
print("+ git add " + " ".join(shell_quote(path) for path in relative_paths))
|
||
print("+ git commit -m " + shell_quote(message))
|
||
return True
|
||
run(["git", "add", "--all", "--", *relative_paths], dry_run=dry_run, mutates=True)
|
||
staged = git(["diff", "--cached", "--name-only"])
|
||
if not staged:
|
||
info("No version file changes to commit")
|
||
return False
|
||
run(["git", "commit", "-m", message], dry_run=dry_run, mutates=True)
|
||
return True
|
||
|
||
|
||
def push_branch_and_tag(remote: str, branch: str, tag: str, dry_run: bool) -> None:
|
||
run(["git", "push", remote, f"HEAD:{branch}"], dry_run=dry_run, mutates=True)
|
||
run(["git", "tag", "-a", tag, "-m", f"Release {tag}"], dry_run=dry_run, mutates=True)
|
||
run(["git", "push", remote, tag], dry_run=dry_run, mutates=True)
|
||
|
||
|
||
def choose_release_type(value: str | None) -> str:
|
||
if value:
|
||
return value
|
||
return prompt_choice(
|
||
"选择发布类型(回车默认 app):",
|
||
[
|
||
("app", "app - 发布 MacTools DMG"),
|
||
("plugin", "plugin - 发布插件批次"),
|
||
],
|
||
)
|
||
|
||
|
||
def choose_plugin_mode(value: str | None, explicit_plugins: list[str]) -> tuple[str, list[str]]:
|
||
if explicit_plugins:
|
||
return "selected", explicit_plugins
|
||
|
||
if value:
|
||
mode = value
|
||
elif sys.stdin.isatty():
|
||
mode = prompt_choice(
|
||
"选择插件发布模式(回车默认 auto):",
|
||
[
|
||
("auto", "auto - 自动发布变化插件"),
|
||
("selected", "selected - 指定插件"),
|
||
("all", "all - 全量重建插件包"),
|
||
],
|
||
)
|
||
else:
|
||
mode = "auto"
|
||
|
||
if mode != "selected":
|
||
return mode, []
|
||
|
||
raw_selection = prompt_text("输入插件 ID 或目录名,多个用逗号分隔:")
|
||
return mode, [raw_selection]
|
||
|
||
|
||
def choose_level(value: str | None, base_version: str, title: str) -> str:
|
||
if value:
|
||
return value
|
||
return prompt_choice(
|
||
title,
|
||
[
|
||
("patch", f"patch -> {bump_version(base_version, 'patch')}"),
|
||
("minor", f"minor -> {bump_version(base_version, 'minor')}"),
|
||
("major", f"major -> {bump_version(base_version, 'major')}"),
|
||
],
|
||
)
|
||
|
||
|
||
def summarize_plugin_analysis(analysis: PluginAnalysis) -> None:
|
||
if analysis.needs_bump:
|
||
info(
|
||
"需要自动 bump 的插件:"
|
||
+ ", ".join(f"{plugin.id} {plugin.version}" for plugin in analysis.needs_bump)
|
||
)
|
||
if analysis.already_bumped:
|
||
info(
|
||
"已手动 bump 的插件:"
|
||
+ ", ".join(f"{plugin.id} {plugin.version}" for plugin in analysis.already_bumped)
|
||
)
|
||
if analysis.new_plugins:
|
||
info("新插件:" + ", ".join(plugin.id for plugin in analysis.new_plugins))
|
||
if analysis.removed_ids:
|
||
info("将从 catalog 移除:" + ", ".join(analysis.removed_ids))
|
||
|
||
|
||
def app_check_label(skip_check: bool) -> str:
|
||
if skip_check:
|
||
return "skip"
|
||
if os.environ.get("CI") == "true":
|
||
return "make generate + xcodebuild test"
|
||
return "make build"
|
||
|
||
|
||
def release_app(args: argparse.Namespace) -> None:
|
||
validate_changelog("app", require_pending=True)
|
||
current_version, current_build = read_app_versions()
|
||
latest_tag = latest_tag_version("v*", APP_TAG_RE)
|
||
base_version = max_version(
|
||
[value for value in [current_version, latest_tag] if value],
|
||
current_version,
|
||
)
|
||
requested_version = normalize_requested_version(args.version)
|
||
if requested_version:
|
||
level = args.level or infer_bump_level(base_version, requested_version)
|
||
next_version = requested_version
|
||
else:
|
||
level = choose_level(args.level, base_version, "选择 app 版本递增级别(回车默认 patch):")
|
||
next_version = bump_version(base_version, level)
|
||
next_build = current_build + 1
|
||
tag = f"v{next_version}"
|
||
check_tag_available(tag, args.remote)
|
||
|
||
print()
|
||
print("App 发布计划")
|
||
print(f" 当前 AppVersion.xcconfig: {current_version} ({current_build})")
|
||
print(f" 最新 app tag: {latest_tag or '(none)'}")
|
||
print(f" 下一版本: {next_version} ({next_build})")
|
||
print(f" tag: {tag}")
|
||
print(f" check: {app_check_label(args.skip_check)}")
|
||
print(f" changelog: {changelog_status_label('app')}")
|
||
confirm("确认执行 bump、check、commit、tag 和 push?", args.yes)
|
||
|
||
sync_branch_after_confirm(args.remote, args.branch, args.dry_run)
|
||
validate_changelog("app", require_pending=True)
|
||
current_version_after_sync, current_build_after_sync = read_app_versions()
|
||
latest_tag_after_sync = latest_tag_version("v*", APP_TAG_RE)
|
||
base_version_after_sync = max_version(
|
||
[value for value in [current_version_after_sync, latest_tag_after_sync] if value],
|
||
current_version_after_sync,
|
||
)
|
||
if requested_version and semver_tuple(next_version) <= semver_tuple(base_version_after_sync):
|
||
fail(f"同步远端后目标版本不再高于基准版本:{base_version_after_sync} -> {next_version}")
|
||
if not requested_version:
|
||
expected_version_after_sync = bump_version(base_version_after_sync, level)
|
||
if expected_version_after_sync != next_version:
|
||
fail(
|
||
"同步远端后计算出的下一版本发生变化:"
|
||
f"{next_version} -> {expected_version_after_sync}。请重新运行 make release。"
|
||
)
|
||
next_build = current_build_after_sync + 1
|
||
check_tag_available(tag, args.remote)
|
||
run_app_check(args.skip_check, args.dry_run)
|
||
write_app_versions(next_version, next_build, args.dry_run)
|
||
changelog_paths = prepare_changelog("app", tag, args.dry_run)
|
||
commit_if_needed([APP_VERSION_CONFIG, *changelog_paths], f"chore: release {tag}", args.dry_run)
|
||
push_branch_and_tag(args.remote, args.branch, tag, args.dry_run)
|
||
info(f"App release tag pushed: {tag}")
|
||
|
||
|
||
def release_plugin(args: argparse.Namespace) -> None:
|
||
mode, raw_selection = choose_plugin_mode(args.plugin_mode, args.plugin)
|
||
plugins = read_plugins()
|
||
previous_catalog = read_previous_catalog()
|
||
current_plugin_kit = current_plugin_kit_version(plugins)
|
||
previous_plugin_kit = previous_catalog.get("pluginKitVersion")
|
||
if mode == "auto" and previous_plugin_kit is not None and previous_plugin_kit != current_plugin_kit:
|
||
mode = "all"
|
||
info(
|
||
f"检测到 PluginKit ABI 升级({previous_plugin_kit} -> {current_plugin_kit}),"
|
||
"插件发布模式自动切换为 all。"
|
||
)
|
||
selection = normalize_plugin_selection(raw_selection, plugins) if raw_selection else []
|
||
analysis = analyze_plugins(mode, selection)
|
||
if not analysis.release_required:
|
||
fail("未检测到需要发布的插件变化。若要强制发布,请使用 ARGS=\"--type plugin --plugin-mode all\"。")
|
||
validate_changelog("plugin", require_pending=True)
|
||
|
||
latest_batch = latest_tag_version("plugins-*", PLUGIN_TAG_RE)
|
||
base_version = latest_batch or "0.0.0"
|
||
requested_version = normalize_requested_version(args.version)
|
||
if requested_version:
|
||
level = args.level or infer_bump_level(base_version, requested_version)
|
||
next_batch = requested_version
|
||
else:
|
||
level = choose_level(args.level, base_version, "选择插件批次版本递增级别(回车默认 patch):")
|
||
next_batch = bump_version(base_version, level)
|
||
tag = f"plugins-{next_batch}"
|
||
check_tag_available(tag, args.remote)
|
||
|
||
print()
|
||
print("Plugin 发布计划")
|
||
print(f" mode: {mode}")
|
||
print(f" 最新插件批次 tag: {'plugins-' + latest_batch if latest_batch else '(none)'}")
|
||
print(f" 下一批次 tag: {tag}")
|
||
print(f" 将发布插件: {', '.join(analysis.selected_ids) if analysis.selected_ids else '(catalog-only)'}")
|
||
print(f" check: {'skip' if args.skip_check else 'make generate + plan-plugin-release'}")
|
||
print(f" changelog: {changelog_status_label('plugin')}")
|
||
summarize_plugin_analysis(analysis)
|
||
plugins_to_bump = plugins_requiring_version_bump(mode, analysis)
|
||
if plugins_to_bump:
|
||
print(
|
||
" 将自动 bump: "
|
||
+ ", ".join(f"{plugin.id} {plugin.version} -> {bump_version(plugin.version, level)}" for plugin in plugins_to_bump)
|
||
)
|
||
confirm("确认执行 bump、check、commit、tag 和 push?", args.yes)
|
||
|
||
sync_branch_after_confirm(args.remote, args.branch, args.dry_run)
|
||
validate_changelog("plugin", require_pending=True)
|
||
latest_batch_after_sync = latest_tag_version("plugins-*", PLUGIN_TAG_RE)
|
||
if requested_version and semver_tuple(next_batch) <= semver_tuple(latest_batch_after_sync or "0.0.0"):
|
||
fail(f"同步远端后目标插件批次不再高于基准版本:{latest_batch_after_sync} -> {next_batch}")
|
||
if not requested_version:
|
||
expected_batch_after_sync = bump_version(latest_batch_after_sync or "0.0.0", level)
|
||
if expected_batch_after_sync != next_batch:
|
||
fail(
|
||
"同步远端后计算出的下一插件批次发生变化:"
|
||
f"{next_batch} -> {expected_batch_after_sync}。请重新运行 make release。"
|
||
)
|
||
analysis = analyze_plugins(mode, selection)
|
||
plugins_to_bump = plugins_requiring_version_bump(mode, analysis)
|
||
check_tag_available(tag, args.remote)
|
||
updated_versions = write_plugin_versions(plugins_to_bump, level, args.dry_run)
|
||
if updated_versions:
|
||
info(
|
||
"已更新插件版本:"
|
||
+ ", ".join(f"{plugin_id} -> {version}" for plugin_id, version in updated_versions.items())
|
||
)
|
||
|
||
if updated_versions:
|
||
analysis = analyze_plugins(mode, selection)
|
||
plugins_to_bump = refresh_plugins(plugins_to_bump)
|
||
|
||
run_plugin_generate_check(args.skip_check, args.dry_run)
|
||
run_plugin_plan_check(mode, selection, args.skip_check, args.dry_run)
|
||
changelog_paths = prepare_changelog("plugin", tag, args.dry_run)
|
||
changed_manifests = [plugin.manifest_path for plugin in plugins_to_bump]
|
||
made_commit = commit_if_needed([*changed_manifests, *changelog_paths], f"chore: release {tag}", args.dry_run)
|
||
if not made_commit and not args.dry_run:
|
||
head = git(["rev-parse", "--short", "HEAD"])
|
||
info(f"No version bump commit needed; tagging current HEAD {head}.")
|
||
push_branch_and_tag(args.remote, args.branch, tag, args.dry_run)
|
||
info(f"Plugin release tag pushed: {tag}")
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
try:
|
||
require_command("git")
|
||
require_command("make")
|
||
release_type = choose_release_type(args.type)
|
||
ensure_release_branch(args.branch)
|
||
if args.dry_run:
|
||
warn_dirty_worktree_for_dry_run()
|
||
else:
|
||
ensure_clean_worktree()
|
||
fetch_release_refs(args.remote, args.dry_run)
|
||
if release_type == "app":
|
||
release_app(args)
|
||
else:
|
||
release_plugin(args)
|
||
except ReleaseError as error:
|
||
print(f"[release] error: {error}", file=sys.stderr)
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|