项目文件夹

文件
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

1096 行
40 KiB
Python
可执行文件

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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())