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

1096 lines
40 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())