From 416b81400e5ff05a107c5d0db8d9ab346be908eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sat, 19 Sep 2026 12:55:35 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20=E5=8F=91=E5=B8=83=20PR=20=E5=8F=AA?= =?UTF-8?q?=E8=B7=91=E5=8F=91=E5=B8=83=E5=85=83=E6=95=B0=E6=8D=AE=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=8C=E8=B7=B3=E8=BF=87=E5=85=A8=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只改 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 --- .github/RELEASE.md | 11 + .github/workflows/ci.yml | 33 ++- crates/windows_rdp_host/tests/contract.rs | 50 ++++- script/release_pr.py | 234 +++++++++++++++++++++ script/tests/test_release_pr.py | 241 ++++++++++++++++++++++ 5 files changed, 563 insertions(+), 6 deletions(-) create mode 100644 script/release_pr.py create mode 100644 script/tests/test_release_pr.py diff --git a/.github/RELEASE.md b/.github/RELEASE.md index d76d251ac..aae171b4c 100644 --- a/.github/RELEASE.md +++ b/.github/RELEASE.md @@ -34,6 +34,17 @@ The normal release sequence is: The build workflow checks out the requested tag, while the workflow itself runs from `main`. This keeps Cargo input caches and sccache data reusable across tags and repair runs. +## Release-only pull requests + +A pull request that only carries the `CHANGELOG.md` entry and the version bump does not need the platform matrix. `.github/workflows/ci.yml` classifies every pull request in the `classify` job through `script/release_pr.py check`: + +- The pull request qualifies only when every changed file is `CHANGELOG.md`, `main/Cargo.toml`, or `Cargo.lock`, and the `Cargo.toml` / `Cargo.lock` diff contains nothing but `version = "..."` lines. Any other file, or a dependency change in `main/Cargo.toml`, keeps the full matrix. +- When it qualifies, the `test` and `windows-rdp-probe` jobs are skipped and the release metadata is validated instead: `main/Cargo.toml` and the `main` package in `Cargo.lock` must agree, and the bilingual entry for that version must exist, be complete (`更新内容` / `修复与优化` plus `What's New` / `Fixes and Improvements`) and carry the CNB mirror line. +- `CI gate` stays the only required status check, and it still fails the pull request when the classification itself fails, so an invalid release entry cannot reach `main`. +- If the diff cannot be computed (for example a fork pull request whose head commit was not fetched), the classifier falls back to the full matrix. + +A release pull request keeps the fast path after `main` is merged back into `dev`, because the classifier only looks at the diff against the base branch. + ## Branch model - `dev` is the beta development branch. Changes are pushed and validated here before a release. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ef9d97f5..57e6700ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,9 +54,30 @@ jobs: esac echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + # 发布 PR 只改 CHANGELOG + 版本号,跑全平台测试没有意义:这一层只做分类, + # 并在判定为发布改动时就地校验 changelog 与版本号;其余 PR 照旧跑全量测试。 + classify: + name: Classify changes + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + outputs: + release_only: ${{ steps.check.outputs.release_only }} + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Classify and validate release-only changes + id: check + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python3 script/release_pr.py check + test: name: Test (${{ matrix.platform }}, ${{ matrix.target }}) - needs: prepare + needs: [prepare, classify] + if: ${{ needs.classify.outputs.release_only != 'true' }} strategy: fail-fast: false matrix: @@ -144,11 +165,13 @@ jobs: windows-rdp-probe: name: Windows RDP probe (${{ matrix.target }}) + needs: [classify] if: >- ${{ - inputs.platform == '' || - inputs.platform == 'all' || - inputs.platform == 'windows' + (inputs.platform == '' || + inputs.platform == 'all' || + inputs.platform == 'windows') && + needs.classify.outputs.release_only != 'true' }} runs-on: windows-2022 env: @@ -186,7 +209,7 @@ jobs: ci-gate: name: CI gate - needs: [prepare, test, windows-rdp-probe] + needs: [prepare, classify, test, windows-rdp-probe] if: always() runs-on: ubuntu-latest steps: diff --git a/crates/windows_rdp_host/tests/contract.rs b/crates/windows_rdp_host/tests/contract.rs index 8c00443b0..9ae9e30c1 100644 --- a/crates/windows_rdp_host/tests/contract.rs +++ b/crates/windows_rdp_host/tests/contract.rs @@ -2802,7 +2802,7 @@ fn build_is_windows_hosted_msvc_only_and_ci_runs_host_tests() { ); assert_contains_all( ".github/workflows/ci.yml", - &["needs: [prepare, test, windows-rdp-probe]"], + &["needs: [prepare, classify, test, windows-rdp-probe]"], ); assert_contains_all( ".github/workflows/release.yml", @@ -2825,6 +2825,54 @@ fn build_is_windows_hosted_msvc_only_and_ci_runs_host_tests() { ); } +// A release pull request only carries the CHANGELOG entry and the version bump, +// so it must skip the platform matrix and be validated by script/release_pr.py +// instead. `ci-gate` stays the single required check and still fails the merge +// when the release metadata is invalid. +#[test] +fn release_only_pull_requests_skip_the_platform_matrix() { + let ci = ".github/workflows/ci.yml"; + assert_tokens_in_scope( + ci, + " classify:", + " test:", + &[ + "if: ${{ github.event_name == 'pull_request' }}", + "release_only: ${{ steps.check.outputs.release_only }}", + "- uses: actions/checkout@v7", + "fetch-depth: 0", + "BASE_SHA: ${{ github.event.pull_request.base.sha }}", + "python3 script/release_pr.py check", + ], + ); + assert_contains_all( + ci, + &[ + "needs: [prepare, classify]", + "if: ${{ needs.classify.outputs.release_only != 'true' }}", + "needs: [classify]", + ], + ); + // The classifier must keep the release diff allow-list and validate the + // bilingual entry plus the version pair itself. + assert_contains_all( + "script/release_pr.py", + &[ + "ALLOWED_FILES = (\"CHANGELOG.md\", \"main/Cargo.toml\", \"Cargo.lock\")", + "def classify(", + "def verify_release_metadata(", + "def validate_changelog_entry(", + ], + ); + assert_contains_all( + "script/tests/test_release_pr.py", + &[ + "class CheckCommandTests(unittest.TestCase)", + "test_code_changes_require_the_full_matrix", + ], + ); +} + #[test] fn active_x_policy_sections_run_explicitly_in_frozen_order() { let policy_source = &format!("{HOST_CRATE}/native/connection_policy.cpp"); diff --git a/script/release_pr.py b/script/release_pr.py new file mode 100644 index 000000000..9ea05af7d --- /dev/null +++ b/script/release_pr.py @@ -0,0 +1,234 @@ +#!/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= HEAD_SHA= GITHUB_OUTPUT= \ + 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 ''}") + 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()) diff --git a/script/tests/test_release_pr.py b/script/tests/test_release_pr.py new file mode 100644 index 000000000..769a30949 --- /dev/null +++ b/script/tests/test_release_pr.py @@ -0,0 +1,241 @@ +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. + + +""" + +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()