Files
navop/script/tests/test_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

242 lines
7.4 KiB
Python

from __future__ import annotations
import argparse
import importlib.util
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "release_pr.py"
SPEC = importlib.util.spec_from_file_location("navop_release_pr", SCRIPT_PATH)
assert SPEC is not None and SPEC.loader is not None
release_pr = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release_pr)
MANIFEST = """\
[package]
name = "main"
version = "0.0.9"
publish.workspace = true
"""
LOCK = """\
# This file is automatically @generated by Cargo.
[[package]]
name = "main"
version = "0.0.9"
dependencies = ["one-assets"]
[[package]]
name = "one-assets"
version = "3.4.5"
"""
CHANGELOG = """\
# Changelog
Navop user-facing release notes.
<!-- NAVOP_RELEASES -->
"""
ENTRY = """\
## [v0.1.0] - 2026-01-01
#### 修复与优化
- 修复了某个问题。
国内下载:如果 GitHub 下载较慢,可从 [CNB 镜像](https://cnb.cool/navop-dev/navop/-/releases/tag/v0.1.0) 下载桌面端安装包
---
#### Fixes and Improvements
- Fixed something.
"""
def git(cwd: Path, *args: str) -> None:
completed = subprocess.run(
["git", *args], cwd=cwd, capture_output=True, text=True, check=False
)
assert completed.returncode == 0, completed.stderr
def write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
class ClassifyTests(unittest.TestCase):
def test_release_only_changes_qualify_for_the_fast_path(self) -> None:
release_only, reason = release_pr.classify(
["CHANGELOG.md", "main/Cargo.toml", "Cargo.lock"],
'--- a/main/Cargo.toml\n+++ b/main/Cargo.toml\n-version = "0.0.9"\n'
'+version = "0.1.0"\n',
)
self.assertTrue(release_only, reason)
def test_code_changes_require_the_full_matrix(self) -> None:
release_only, reason = release_pr.classify(
["CHANGELOG.md", "crates/terminal/src/wsl_distributions.rs"],
"",
)
self.assertFalse(release_only)
self.assertIn("crates/terminal/src/wsl_distributions.rs", reason)
def test_dependency_changes_require_the_full_matrix(self) -> None:
release_only, reason = release_pr.classify(
["main/Cargo.toml"],
'--- a/main/Cargo.toml\n+++ b/main/Cargo.toml\n'
'-version = "0.0.9"\n+version = "0.1.0"\n'
'+"one-assets = { workspace = true }"\n',
)
self.assertFalse(release_only)
self.assertIn("beyond the version bump", reason)
def test_empty_changes_are_not_a_release_pull_request(self) -> None:
release_only, reason = release_pr.classify([], "")
self.assertFalse(release_only)
self.assertIn("no file changed", reason)
def test_diff_headers_do_not_count_as_changes(self) -> None:
self.assertTrue(
release_pr.only_version_lines_changed(
"diff --git a/Cargo.lock b/Cargo.lock\n"
"--- a/Cargo.lock\n"
"+++ b/Cargo.lock\n"
"-version = \"0.0.9\"\n"
"+version = \"0.1.0\"\n"
)
)
class VersionTests(unittest.TestCase):
def test_manifest_version_reads_the_package_section(self) -> None:
self.assertEqual(release_pr.manifest_version(MANIFEST), "0.0.9")
def test_lock_version_reads_the_main_package(self) -> None:
self.assertEqual(release_pr.lock_main_version(LOCK), "0.0.9")
def test_missing_manifest_version_is_reported(self) -> None:
with self.assertRaises(release_pr.ReleasePrError):
release_pr.manifest_version('[workspace]\nmembers = ["main"]\n')
def test_missing_lock_package_is_reported(self) -> None:
with self.assertRaises(release_pr.ReleasePrError):
release_pr.lock_main_version('[[package]]\nname = "other"\nversion = "1.0.0"\n')
class ChangelogTests(unittest.TestCase):
def test_entry_for_the_bumped_version_validates(self) -> None:
release_pr.validate_changelog_entry(f"{CHANGELOG}\n{ENTRY}", "0.1.0")
def test_missing_entry_is_reported(self) -> None:
with self.assertRaises(release_pr.ReleasePrError):
release_pr.validate_changelog_entry(CHANGELOG, "0.1.0")
class CheckCommandTests(unittest.TestCase):
def setUp(self) -> None:
self._temporary = tempfile.TemporaryDirectory()
self.addCleanup(self._temporary.cleanup)
self.root = Path(self._temporary.name)
git(self.root, "init", "-b", "main")
git(self.root, "config", "user.email", "release@example.com")
git(self.root, "config", "user.name", "Release Test")
write(self.root / "main/Cargo.toml", MANIFEST)
write(self.root / "Cargo.lock", LOCK)
write(self.root / "CHANGELOG.md", CHANGELOG)
git(self.root, "add", ".")
git(self.root, "commit", "-m", "base")
def _release_commit(self) -> None:
write(
self.root / "main/Cargo.toml",
MANIFEST.replace('version = "0.0.9"', 'version = "0.1.0"'),
)
write(
self.root / "Cargo.lock",
LOCK.replace('version = "0.0.9"', 'version = "0.1.0"'),
)
write(self.root / "CHANGELOG.md", f"{CHANGELOG}\n{ENTRY}")
git(self.root, "commit", "-am", "release")
def _check(self, base: str = "HEAD~1") -> None:
release_pr.command_check(
argparse.Namespace(
base=base, head="HEAD", root=self.root, github_output=""
)
)
def test_release_commit_is_classified_and_validated(self) -> None:
self._release_commit()
self._check()
def test_release_commit_publishes_the_release_output(self) -> None:
self._release_commit()
output = self.root / "github-output.txt"
release_pr.command_check(
argparse.Namespace(
base="HEAD~1",
head="HEAD",
root=self.root,
github_output=str(output),
)
)
self.assertEqual(
output.read_text(encoding="utf-8"),
"release_only=true\nversion=0.1.0\n",
)
def test_unavailable_diff_falls_back_to_the_full_matrix(self) -> None:
output = self.root / "github-output.txt"
release_pr.command_check(
argparse.Namespace(
base="does-not-exist",
head="HEAD",
root=self.root,
github_output=str(output),
)
)
self.assertFalse(output.exists(), "fallback must not skip the matrix")
def test_version_mismatch_fails_the_release_check(self) -> None:
write(
self.root / "main/Cargo.toml",
MANIFEST.replace('version = "0.0.9"', 'version = "0.1.0"'),
)
write(self.root / "CHANGELOG.md", f"{CHANGELOG}\n{ENTRY}")
git(self.root, "commit", "-am", "release with a stale lock")
with self.assertRaises(release_pr.ReleasePrError) as error:
self._check()
self.assertIn("Cargo.lock pins main at", str(error.exception))
def test_code_change_is_not_a_release_pull_request(self) -> None:
self._release_commit()
write(self.root / "crates/terminal/src/lib.rs", "pub fn placeholder() {}\n")
git(self.root, "add", ".")
git(self.root, "commit", "-m", "feature")
self._check(base="HEAD~2")
if __name__ == "__main__":
unittest.main()