Files
navop/script/release_pr.py
T
胡飞 416b81400e ci: 发布 PR 只跑发布元数据校验,跳过全平台测试
只改 CHANGELOG.md / main/Cargo.toml / Cargo.lock 的发布 PR 此前仍要跑
macOS / Linux / Windows 三平台全量测试(Windows job 约 25 分钟),而改动里
没有任何代码,这些测试没有意义。

- ci.yml 新增 classify job:PR 场景下用 script/release_pr.py 判定是否只包含
  发布改动,是则跳过 test 与 windows-rdp-probe,改为就地校验发布元数据
- script/release_pr.py:分类规则为「改动文件全部属于发布白名单」且
  「Cargo.toml / Cargo.lock 的差异只有 version = "..." 行」;命中后校验
  main/Cargo.toml 与 Cargo.lock 中 main 包版本一致,且该版本的双语
  changelog 条目完整(更新内容/修复与优化 + What's New/Fixes and
  Improvements,含 CNB 镜像行);无法算出 diff 时回退到全量测试
- ci-gate 仍是唯一必需状态检查,分类或校验失败都会拦住合并
- .github/RELEASE.md 记录该快速通道
- windows_rdp_host 契约测试钉住 classify/needs/if 结构与脚本白名单

验证:
- python3 -m unittest discover -s script/tests -p "test_release_pr.py" — 16 passed
- cargo test -p windows_rdp_host --test contract — 40 passed
- ruby -ryaml 解析 ci.yml,核对 classify/test/windows-rdp-probe/ci-gate 的
  needs 与 if 表达式
- rustfmt --edition 2024 --check crates/windows_rdp_host/tests/contract.rs
2026-09-19 12:55:35 +08:00

235 lines
7.8 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
ALLOWED_FILES = ("CHANGELOG.md", "main/Cargo.toml", "Cargo.lock")
VERSION_PATHS = ("main/Cargo.toml", "Cargo.lock")
CHANGELOG_PATH = "CHANGELOG.md"
MANIFEST_PATH = "main/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_main_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 != "main":
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 "main" 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 pair; return the version."""
version = manifest_version(read(root / MANIFEST_PATH))
locked = lock_main_version(read(root / LOCK_PATH))
if version != locked:
raise ReleasePrError(
f"{MANIFEST_PATH} is {version} but {LOCK_PATH} pins main at {locked}"
)
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())