Merge pull request #237 from feigeCode/ci/release-only-fast-path

ci: 发布 PR 只跑发布元数据校验,跳过全平台测试
This commit is contained in:
xiaofei
2026-09-19 15:42:37 +08:00
committed by GitHub
5 changed files with 563 additions and 6 deletions
+11
View File
@@ -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.
+28 -5
View File
@@ -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:
+49 -1
View File
@@ -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");
+234
View File
@@ -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=<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())
+241
View File
@@ -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.
<!-- 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()