Files
navop/script/release_pr.py
T
胡飞 46a5978443 refactor(product-split): main 目录归位 crates/navop_app,包名统一 navop_app
产品装配移出后 main 已只剩库,却仍占着仓库根目录、与 apps/* 并列,看起来像一个
产品。本次把它落回 crate 层:

- git mv main crates/navop_app;package name 由 main 改为 navop_app(与 lib 名
  一致),删掉冗余的 [lib] 块;根 members 同步。
- 三个 app 改为直接相对路径依赖 navop_app = { path = "../../crates/navop_app" },
  不进 workspace.dependencies(Cargo 不允许再关继承来的 default-features)。
- 26 处逃出 crate 根的 include_str! 各加一层 ../(resources/*、core/build.rs、
  apps/* 与 crates/*_view 的 Cargo.toml、app_kit 源码);build.rs 的 icon/manifest
  路径同步。
- env_file.rs 与 i18n_locale_keys_contract 的「工作区根」改为上两级;
  cross-crate 源码断言(core/window_close、db_view、one_ui、windows_rdp_host)指向
  新路径。
- locales/main.yml 保持原名:i18n 命名空间与全仓 t!() key 绑定,改名是另一件事。
- 发布链路同步:release_pr.py(MANIFEST_PATH、lock_app_version、报错文案)、
  beta-release.sh 版本包名集合、release-tag.sh / ci.yml / release.yml /
  .github/RELEASE.md / AGENTS.md / CLAUDE.md 等文档的路径与 -p navop_app。
- 顺带修两处未推送提交遗留的 CI 红线:app_kit::install 的
  expected_binary_name cfg 过宽(macOS dead_code)、测试里多余的
  is_supported_app_bundle import;release PR 测试夹具补齐四个 manifest;
  windows_rdp_host 的 ALLOWED_FILES 断言对齐 release_pr.py 现状。

验证:cargo test -p navop_app(595 lib + 4 集成契约)、-p app_kit(149)、
one-ui/windows_rdp_host 契约、三个 app cargo build、cargo check --all-targets
全通过;script/tests 32 项、release-packaging 与 scripts/ai 的 node 测试全绿。
2026-11-01 00:52:04 +08:00

252 lines
8.7 KiB
Python
Raw 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
"""Classify and verify `dev` -> `main` release pull requests.
A release pull request carries only the bilingual `CHANGELOG.md` entry and the
version bump, so it does not need the full platform test matrix. `check`
decides whether a pull request qualifies for that fast path; when it does, the
release metadata is validated here instead of by a Rust build. `ci-gate` still
blocks the merge whenever the entry is missing or malformed.
Usage (CI):
BASE_SHA=<sha> HEAD_SHA=<sha> GITHUB_OUTPUT=<file> \
python3 script/release_pr.py check
"""
from __future__ import annotations
import argparse
import importlib.util
import os
import re
import subprocess
import sys
from pathlib import Path
# 统一版本约定:一个 tag 同发全量与两个独立产品,版本 bump 覆盖
# 应用外壳 crate(navop_app,历史名 main)+ 三个 app crate 的 manifest 与 Cargo.lock。
APP_MANIFEST_PATHS = (
"crates/navop_app/Cargo.toml",
"apps/navop/Cargo.toml",
"apps/navop-shell/Cargo.toml",
"apps/navop-data/Cargo.toml",
)
ALLOWED_FILES = ("CHANGELOG.md", "Cargo.lock", *APP_MANIFEST_PATHS)
VERSION_PATHS = ("Cargo.lock", *APP_MANIFEST_PATHS)
CHANGELOG_PATH = "CHANGELOG.md"
MANIFEST_PATH = "crates/navop_app/Cargo.toml"
LOCK_PATH = "Cargo.lock"
VERSION_LINE_RE = re.compile(r'^[+-][ \t]*version[ \t]*=[ \t]*"')
PACKAGE_SECTION_RE = re.compile(r"^\[package\]$")
SECTION_RE = re.compile(r"^\[")
NAME_RE = re.compile(r'^name[ \t]*=[ \t]*"([^"]+)"')
VERSION_RE = re.compile(r'^version[ \t]*=[ \t]*"([^"]+)"')
class ReleasePrError(Exception):
"""Raised when a release pull request cannot be classified or is invalid."""
def run_git(arguments: list[str], cwd: Path) -> str:
completed = subprocess.run(
["git", *arguments],
cwd=cwd,
capture_output=True,
text=True,
check=False,
)
if completed.returncode != 0:
raise ReleasePrError(
f"git {' '.join(arguments)} failed: {completed.stderr.strip()}"
)
return completed.stdout
def changed_files(base: str, head: str, cwd: Path) -> list[str]:
output = run_git(["diff", "--name-only", base, head], cwd)
return [line.strip() for line in output.splitlines() if line.strip()]
def version_paths_diff(base: str, head: str, cwd: Path) -> str:
return run_git(["diff", base, head, "--", *VERSION_PATHS], cwd)
def only_version_lines_changed(diff: str) -> bool:
"""True when every added/removed line is a `version = "..."` assignment."""
for line in diff.splitlines():
if not line.startswith(("+", "-")):
continue
if line.startswith(("+++", "---")):
continue
if VERSION_LINE_RE.match(line) is None:
return False
return True
def classify(files: list[str], diff: str) -> tuple[bool, str]:
"""Return whether the pull request is a release-only change and why."""
foreign = [path for path in files if path not in ALLOWED_FILES]
if foreign:
return False, f"code or configuration changed: {', '.join(sorted(foreign))}"
if not files:
return False, "no file changed"
if not only_version_lines_changed(diff):
return False, "Cargo.toml / Cargo.lock changed beyond the version bump"
return True, "only CHANGELOG.md and the version bump changed"
def manifest_version(manifest: str) -> str:
inside_package = False
for line in manifest.splitlines():
if PACKAGE_SECTION_RE.match(line):
inside_package = True
continue
if SECTION_RE.match(line):
inside_package = False
continue
if not inside_package:
continue
match = VERSION_RE.match(line)
if match:
return match.group(1)
raise ReleasePrError(f"{MANIFEST_PATH} has no [package] version")
def lock_app_version(lock: str) -> str:
current_name: str | None = None
for line in lock.splitlines():
if line == "[[package]]":
current_name = None
continue
name_match = NAME_RE.match(line)
if name_match:
current_name = name_match.group(1)
continue
if current_name != "navop_app":
continue
version_match = VERSION_RE.match(line)
if version_match:
return version_match.group(1)
raise ReleasePrError(f'{LOCK_PATH} has no version for the "navop_app" package')
def load_changelog_module():
script_path = Path(__file__).resolve().parent / "changelog.py"
spec = importlib.util.spec_from_file_location("navop_changelog", script_path)
if spec is None or spec.loader is None:
raise ReleasePrError(f"unable to load {script_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def validate_changelog_entry(changelog: str, version: str) -> None:
module = load_changelog_module()
tag = f"v{version}"
try:
notes = module.extract_release_notes(changelog, tag)
module.validate_release_notes(notes, require_cnb_line=True)
except module.ChangelogError as error:
raise ReleasePrError(str(error)) from error
def read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError as error:
raise ReleasePrError(f"file not found: {path}") from error
def verify_release_metadata(root: Path, changelog: str) -> str:
"""Validate the release entry and the version set; return the version."""
version = manifest_version(read(root / MANIFEST_PATH))
locked = lock_app_version(read(root / LOCK_PATH))
if version != locked:
raise ReleasePrError(
f"{MANIFEST_PATH} is {version} but {LOCK_PATH} pins navop_app at {locked}"
)
# 三个 app crate 与 navop_app 必须同版本:一个 tag 同时发布三个产品,
# 任何一处落后都会让对应产品的自报版本与 feed 版本比较出错。
for app_manifest in APP_MANIFEST_PATHS[1:]:
app_version = manifest_version(read(root / app_manifest))
if app_version != version:
raise ReleasePrError(
f"{app_manifest} is {app_version} but {MANIFEST_PATH} is {version}; "
"unified product releases must bump all four manifests together"
)
validate_changelog_entry(changelog, version)
return version
def write_github_output(path: Path, values: dict[str, str]) -> None:
with path.open("a", encoding="utf-8") as handle:
for key, value in values.items():
handle.write(f"{key}={value}\n")
def command_check(arguments: argparse.Namespace) -> None:
base = arguments.base or os.environ.get("BASE_SHA", "")
head = arguments.head or os.environ.get("HEAD_SHA", "")
if not base or not head:
raise ReleasePrError("BASE_SHA and HEAD_SHA are required")
root = arguments.root.resolve()
try:
files = changed_files(base, head, root)
diff = version_paths_diff(base, head, root)
except ReleasePrError as error:
# 无法算出差异时(例如只 fetch 了合并引用的外部 PR)宁可跑全量测试,
# 也不能把发布快速通道当成默认结论。
print(f"::warning::cannot diff {base}..{head}: {error}")
print("release-only: false (diff unavailable, running the full matrix)")
return
release_only, reason = classify(files, diff)
print(f"changed files: {', '.join(files) if files else '<none>'}")
print(f"release-only: {release_only} ({reason})")
version = ""
if release_only:
version = verify_release_metadata(root, read(root / CHANGELOG_PATH))
print(f"release metadata validated for v{version}")
output_path = arguments.github_output or os.environ.get("GITHUB_OUTPUT", "")
if release_only and output_path:
write_github_output(
Path(output_path),
{"release_only": "true", "version": version},
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
check_parser = subparsers.add_parser(
"check", help="classify a pull request and validate release-only contents"
)
check_parser.add_argument("--base", default="")
check_parser.add_argument("--head", default="")
check_parser.add_argument("--root", type=Path, default=Path.cwd())
check_parser.add_argument("--github-output", default="")
check_parser.set_defaults(handler=command_check)
return parser
def main() -> int:
parser = build_parser()
arguments = parser.parse_args()
try:
arguments.handler(arguments)
except ReleasePrError as error:
print(f"::error::{error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())