mirror of
https://github.com/feigeCode/navop.git
synced 2026-11-06 16:07:51 +00:00
产品装配移出后 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 测试全绿。
252 lines
8.7 KiB
Python
252 lines
8.7 KiB
Python
#!/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())
|