From 92ff3f5853aff5bdca690c0d8a9734ba9a72f525 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Tue, 22 Sep 2026 20:45:41 +0800 Subject: [PATCH] =?UTF-8?q?ci(release):=20=E5=8A=A0"=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6"=E9=97=B8=E2=80=94=E2=80=94release=20PR=20?= =?UTF-8?q?=E6=8B=BF=E4=B8=8D=E5=88=B0=20CI=EF=BC=8C=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=89=8D=E5=BF=85=E9=A1=BB=E6=9C=89=E6=9C=BA=E5=99=A8=E5=88=A4?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机制账(这轮量到的):release-please 自己开的 PR **一条 CI 检查都没有**。GitHub 固定行为: GITHUB_TOKEN 产生的提交不再级联触发 workflow。实测 #120 与 #132 的 `gh pr checks` 都是空数组。 于是"手工同步的 5 类版本位会红在 release PR 上、补一个 commit 即可"这个假设不成立 —— 那些闸只会红在合并之后的 main 上,而那时 tag 与 Release 已经发出去了。 - 新增 scripts/check_release_readiness.py:检一棵工作树的发版条件。**判据不在这里重写** —— 它 importlib 载入 tests/test_version_consistency.py 把 8 条 test_ 逐条跑一遍, 再补两条 CI 看不到的(manifest 与目标版本一致、CHANGELOG 里有 ## [新版本] 段)。 这样"CI 里红的"与"发版前拦的"永远同源,不会各漂一半。 - release-please.yml:permissions 补 statuses: write;新增"发布条件检查"步骤,在 RP 步骤之后跑, 把 release PR 的 head 取成 worktree 跑脚本,结论回写成 commit status `release-gate` (成功/失败 + 指回本次 run),并把全文贴进 job 摘要。 **刻意不让这个作业失败**:结论已经打在 PR 上,要真拦合并就在分支保护里把 release-gate 加成必需检查(一键,owner 的决定);让作业红只会把"有一条待发 release PR"染到每次 main push 的历史上。 - 顺手修一处我自己写的随机性:test_installer_artifact_names_track_the_version_site 用 next(iter(set(sites.values()))) 取基准版本 —— 版本位矛盾时那是**随机挑一个**当真值, setup.nsi 可能恰好撞上被挑中的那个而判过(这次用假工作树真撞出来一次)。改成基准取 pyproject。 同时 tests/test_release_readiness_gate.py 里那条断言现在能稳定点到 OutFile。 验收: - 脚本对当前 main(2.2.4 全对齐)→ rc=0 "发版条件满足" - 脚本对 **PR #132 的真 head**(RP groom 出来的 2.2.5,手工位还停在 2.2.4)→ rc=1, 点出 4 处版本位落后 + changelog 过期 + OutFile 未跟 —— 这就是"没这道闸会发生什么"的现场证据 - tests/test_release_readiness_gate.py 4 条 + test_version_consistency 8 条 + test_benchmark_gate 12 条 = 24 passed;ruff check / format 通过;release-please.yml 过 yaml.safe_load Signed-off-by: ReSerendipity --- .github/workflows/release-please.yml | 50 ++++++++ scripts/check_release_readiness.py | 123 +++++++++++++++++++ tests/test_release_readiness_gate.py | 176 +++++++++++++++++++++++++++ tests/test_version_consistency.py | 4 +- 4 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 scripts/check_release_readiness.py create mode 100644 tests/test_release_readiness_gate.py diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0b47ad5..b3634f3 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -9,6 +9,12 @@ on: permissions: contents: write pull-requests: write + # release-gate 那一步要把检查结论写成 commit status 打在 release PR 的 head 上。 + # 为什么非得用 status 而不是普通 workflow 检查:release-please 自己开的 PR **拿不到任何 CI** —— + # GitHub 固定行为,由 GITHUB_TOKEN 产生的提交不再级联触发 workflow(实测 #120 / #132 的 + # `gh pr checks` 都是空)。没有这个 status,"这条 release PR 合下去会让 main 立刻变红" + # 这件事就只能靠人在合并前自己跑一遍脚本记着。 + statuses: write jobs: release-please: @@ -92,6 +98,50 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" + - name: 发布条件检查(把结论写成 release PR head 上的 commit status) + # 判据不在这里重写:调 scripts/check_release_readiness.py,它复用 + # tests/test_version_consistency.py 的那 8 条 —— CI 里红的与发版前拦的必须同源。 + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" \ + --jq '.[] | select(.head.ref == "release-please--branches--main") | .number' | head -1) + if [ -z "${pr:-}" ]; then + echo "没有待合的 release PR,跳过发布条件检查。" + exit 0 + fi + sha=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/$pr" --jq .head.sha) + wt="$RUNNER_TEMP/release-pr-$pr" + rm -rf "$wt" + git config --global --add safe.directory "$wt" || true + git fetch -q origin "refs/pull/$pr/head" + git worktree add -q --detach "$wt" FETCH_HEAD + if out=$(python3 scripts/check_release_readiness.py --root "$wt" 2>&1); then + st=success + desc="发版条件满足:PR #${pr} 的 head 上全部版本位一致、判据全过" + else + st=failure + desc="有手工同步位未补齐:合下去会让 main 立刻变红(详见本作业日志)" + fi + echo "$out" + { + echo "## 发布条件检查(release PR #${pr},head ${sha:0:8})" + echo '```' + echo "$out" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + git worktree remove --force "$wt" || true + gh api -X POST "repos/${GITHUB_REPOSITORY}/statuses/$sha" \ + -f state="$st" \ + -f context="release-gate" \ + -f description="$desc" \ + -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >/dev/null + echo "已在 ${sha:0:8} 上回写 release-gate=${st}" + # 这里刻意不 exit 1:结论已经以 commit status 的形式打在 release PR 上;要真拦合并, + # 就把分支保护里的必需检查加上 `release-gate`(一键,属于 owner 的决定)。 + # 让本作业失败只会把"有一条 release PR 待发"这件事的红染到每次 main push 的历史上。 + # After release is created, build and upload artifacts build-release: name: Build Release Artifacts diff --git a/scripts/check_release_readiness.py b/scripts/check_release_readiness.py new file mode 100644 index 0000000..e32acc5 --- /dev/null +++ b/scripts/check_release_readiness.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""发版前条件检查:这条 release PR 现在合下去,main 会不会立刻变红? + +为什么要有它(2026-09-22 实测的机制):**release-please 自己开的 PR 拿不到任何 CI 检查** —— +GitHub 固定行为,由 `GITHUB_TOKEN` 产生的提交不再级联触发 workflow。于是"手工同步的 5 类版本位 +会在 release PR 上红"这个假设是错的:那些闸只会红在**合并之后的 main** 上,而那时 tag 与 Release +已经发出去了。这个脚本把同一套判据提前到合并之前跑,结果以 commit status 的形式回写到 +release 分支的 head 上(`release-please.yml` 的 release-gate 步骤负责调它并回写)。 + +判据本身**不在这里重写**:脚本用 importlib 载入 `tests/test_version_consistency.py`, +把它里面的 `test_*` 逐个跑一遍。这样"CI 里那条"与"发版前这条"永远同源,不会各漂一半。 + +用法: + python scripts/check_release_readiness.py # 检当前仓库根 + python scripts/check_release_readiness.py --root <目录> # 检别处检出(CI 用它检 PR head) + +退出码:0 = 可以合;1 = 不能合(会红在 main 上),并逐条打印差在哪。 +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +import traceback +from pathlib import Path + +# 与 release-please 的 extra-files 无关、必须由人补的版本位(说明见 docs/release-governance.md §1) +HAND_SYNCED = ( + "desktop/src-tauri/Cargo.lock", + "config.yaml", + "deploy/kubernetes/deployment.yaml", + "scripts/installer/setup.nsi", + "version.json 的 changelog / release_date", +) + + +def _load_checker(root: Path): + """把 tests/test_version_consistency.py 当模块载入(它只 import 标准库,无需装依赖)。""" + src = root / "tests" / "test_version_consistency.py" + if not src.is_file(): + raise SystemExit(f"找不到判据来源:{src}") + spec = importlib.util.spec_from_file_location("_release_readiness_checker", src) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--root", default=str(Path(__file__).resolve().parent.parent), type=Path) + args = ap.parse_args() + root: Path = args.root.resolve() + + mod = _load_checker(root) + problems: list[str] = [] + + try: + sites = mod._site_versions() + except Exception as exc: # 读取器本身坏了 = 判据不可信,同样不能放行 + print(f"::error::读版本位失败:{type(exc).__name__}: {exc}") + return 1 + + print("版本位:") + for key in sorted(sites): + print(f" {sites[key]:9} {key}") + try: + target = sites["pyproject.toml"] + except KeyError: + print("::error::读不到 pyproject.toml 的版本,无法确定目标版本") + return 1 + + lag = {k: v for k, v in sites.items() if v != target} + if lag: + print(f"\n落后于 {target} 的版本位(这些必须人补,release-please 不会碰它们):") + for k, v in sorted(lag.items()): + print(f" {v:9} {k}") + problems.append(f"{len(lag)} 处版本位与 {target} 不一致") + + must_cover = [h for h in HAND_SYNCED if any(h.split()[0] in k for k in sites)] + print( + f"\n人工同步位清单({len(HAND_SYNCED)} 类,其中本仓读取器覆盖 {len(must_cover)} 类):" + "、".join(HAND_SYNCED) + ) + + names = [n for n in dir(mod) if n.startswith("test_")] + print(f"\n复用 tests/test_version_consistency.py 的 {len(names)} 条判据:") + for name in sorted(names): + fn = getattr(mod, name) + try: + fn() + print(f" PASS {name}") + except AssertionError as exc: + first = str(exc).strip().splitlines()[0] if str(exc).strip() else "(无消息)" + print(f" FAIL {name} —— {first}") + problems.append(f"{name}: {first}") + except Exception: # 崩了比断言失败更糟:判据没跑成 + print(f" ERROR {name}\n{traceback.format_exc()}") + problems.append(f"{name} 抛异常,判据未能执行") + + manifest = root / ".release-please-manifest.json" + if manifest.is_file(): + got = json.loads(manifest.read_text(encoding="utf-8")).get(".") + if str(got) != target: + problems.append(f".release-please-manifest.json={got} 与目标版本 {target} 不一致") + + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8", errors="ignore") + if f"## [{target}]" not in changelog: + problems.append(f"CHANGELOG.md 里没有 ## [{target}] 段") + + print() + if problems: + print("::error::发版条件不满足,合下去会让 main 立刻变红:") + for p in problems: + print(" - " + p) + return 1 + print(f"发版条件满足:全部版本位 = {target},判据全过。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_release_readiness_gate.py b/tests/test_release_readiness_gate.py new file mode 100644 index 0000000..57e448f --- /dev/null +++ b/tests/test_release_readiness_gate.py @@ -0,0 +1,176 @@ +"""发布条件闸的两半:脚本能判"这条 release PR 现在能不能合",工作流真的在跑它。 + +背景(都有账):release-please 自己开的 PR **拿不到任何 CI**(GitHub 固定行为: +`GITHUB_TOKEN` 产生的提交不再级联触发 workflow;实测 #120 与 #132 的 `gh pr checks` 都是空数组), +而它有 5 类手工同步的版本位 RP 不会碰。于是"红在 release PR 上是预期行为"这个假设是错的 —— +不补齐就会红在**已经发版之后**的 main 上。这道闸把判据提前,并回写成 commit status。 +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +_ROOT = Path(__file__).resolve().parent.parent +_SCRIPT = _ROOT / "scripts" / "check_release_readiness.py" +_WF = _ROOT / ".github" / "workflows" / "release-please.yml" + +# 判据会读到的所有版本承载文件;少搬一个,fixture 就会以"取不到版本号"而不是"判出漂移"失败, +# 那等于白测 —— 所以这个清单与 _CARRIERS 的断言不能省。 +_CARRIERS = ( + "pyproject.toml", + "version.json", + "config.yaml", + "CHANGELOG.md", + ".release-please-manifest.json", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", + "desktop/src-tauri/Cargo.lock", + "deploy/kubernetes/deployment.yaml", + "scripts/installer/setup.nsi", + "tests/test_version_consistency.py", + # 判据里有两条要读它(extra-files 的类型白名单与标注一致性),漏搬就会以 FileNotFoundError + # 崩在"判据未能执行"上 —— 那正是脚本设计上要区分开的那一档(崩 != 漂移,但同样不能放行)。 + "release-please-config.json", +) + + +def _run(root: Path) -> subprocess.CompletedProcess[str]: + # 子进程的 stdout 在 Windows 上默认走 GBK:只给父进程设 encoding="utf-8" 会把中文输出 + # 解成一堆 \ufffd,断言消息就没法读(#126 在门禁测试上栽过同一个坑)。 + return subprocess.run( + [sys.executable, str(_SCRIPT), "--root", str(root)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env={**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}, + check=False, + ) + + +def _current_version() -> str: + """从 pyproject 现读,别把版本号硬编码进测试(否则每次发版都要来改这里)。""" + import re + + text = (_ROOT / "pyproject.toml").read_text(encoding="utf-8") + m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.M) + assert m, "读不到 pyproject 的 version" + return m.group(1) + + +CUR = _current_version() # fixture 搬过来时树里就是它 +NEW = "9.9.9" + + +@pytest.fixture() +def tree(tmp_path: Path) -> Path: + """把版本承载文件按原样搬进 tmp_path,造一棵"可以改一处看它响不响"的假工作树。""" + for rel in _CARRIERS: + src = _ROOT / rel + assert src.is_file(), f"仓库里少了 {rel},这个 fixture 的假设要更新" + dst = tmp_path / rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dst) + return tmp_path + + +def _set(root: Path, rel: str, new: str = NEW) -> None: + p = root / rel + p.write_text(p.read_text(encoding="utf-8").replace(f'"{CUR}"', f'"{new}"'), encoding="utf-8") + + +def _bump_rp_managed_sites(root: Path) -> None: + """只抬 release-please 会自动改的那几处 —— 正是一条没补齐手工位的 release PR 的形状。""" + for rel in ( + "pyproject.toml", + "version.json", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", + ".release-please-manifest.json", + ): + _set(root, rel) + (root / "CHANGELOG.md").write_text(f"## [{NEW}] - 2026-09-22\n\n* 测试用\n", encoding="utf-8") + + +def _complete_hand_sites(root: Path) -> None: + (root / "config.yaml").write_text(f'version: "{NEW}"\n', encoding="utf-8") + # 包名必须写对:判据是按 `name = "tts-multimodel-desktop"` 定位锁里自身版本的 + (root / "desktop" / "src-tauri" / "Cargo.lock").write_text( + f'[[package]]\nname = "tts-multimodel-desktop"\nversion = "{NEW}"\n', encoding="utf-8" + ) + # 镜像名要照判据的正则来(它认 `ghcr.io//tts-multimodel:x.y.z`),否则测的是"读取器失效"而不是"版本漂移" + (root / "deploy" / "kubernetes" / "deployment.yaml").write_text( + f" image: ghcr.io/reserendipity/tts-multimodel:{NEW}\n", encoding="utf-8" + ) + (root / "scripts" / "installer" / "setup.nsi").write_text( + f'OutFile "TTSMultiModel-Setup-v{NEW}.exe"\n!define APP_VERSION "{NEW}"\nVIProductVersion "{NEW}.0"\n', + encoding="utf-8", + ) + (root / "version.json").write_text( + json.dumps( + { + "version": NEW, + "release_date": "2026-09-22", + "minimum_shell_version": "2.2.2", + "changelog": f"{NEW}:测试", + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + +def test_script_passes_on_a_consistent_tree(tree: Path) -> None: + res = _run(tree) + assert res.returncode == 0, res.stdout + res.stderr + assert "发版条件满足" in res.stdout + + +def test_script_fails_when_only_the_rp_managed_sites_are_bumped(tree: Path) -> None: + _bump_rp_managed_sites(tree) + res = _run(tree) + out = res.stdout + res.stderr + assert res.returncode == 1, f"未补齐手工位的 release PR 被放行了:{out[-400:]}" + for who in ("config.yaml", "Cargo.lock", "deployment.yaml", "OutFile"): + assert who in out, f"没点出 {who} 在拖:{out[-500:]}" + + +def test_script_still_fails_on_a_stale_changelog_section(tree: Path) -> None: + """手工位全补齐、只有 CHANGELOG 段名与目标版本不符 —— 也要拦住(发版说明是给人看的)。""" + _bump_rp_managed_sites(tree) + _complete_hand_sites(tree) + (tree / "CHANGELOG.md").write_text(f"## [{CUR}] - 2026-09-22\n\n* 旧段\n", encoding="utf-8") + res = _run(tree) + out = res.stdout + res.stderr + assert res.returncode == 1, f"CHANGELOG 段名过期却被放行:{out[-400:]}" + assert f"## [{NEW}]" in out + + +def test_workflow_runs_the_gate_and_can_write_the_status() -> None: + doc = yaml.safe_load(_WF.read_text(encoding="utf-8")) + job = doc["jobs"]["release-please"] + # permissions 在这个文件里写在顶层(对整个工作流生效),不是按 job 覆写;任一层给了都算够。 + layers = [(doc.get("permissions") or {}), (job.get("permissions") or {})] + assert any(p.get("statuses") == "write" for p in layers), ( + "没有任何一层给 permissions.statuses: write —— 回写 commit status 会被 API 拒,这道闸等于没装" + ) + steps = job["steps"] + gate = next((s for s in steps if "发布条件检查" in str(s.get("name") or "")), None) + assert gate, "找不到发布条件检查步骤(release PR 拿不到 CI,这是合并前唯一的机器判据)" + body = str(gate["run"]) + assert "check_release_readiness.py" in body, "这一步没真调脚本,只是摆样子" + assert "statuses/" in body and 'context="release-gate"' in body + rp = next(i for i, s in enumerate(steps) if "release-please-action" in str(s.get("uses", ""))) + assert any("发布条件检查" in str(s.get("name") or "") for s in steps[rp + 1 :]), ( + "闸必须在 RP 步骤之后跑 —— 它检的是刚被 groom 过的那个 head" + ) diff --git a/tests/test_version_consistency.py b/tests/test_version_consistency.py index b87e106..e5a2902 100644 --- a/tests/test_version_consistency.py +++ b/tests/test_version_consistency.py @@ -184,7 +184,9 @@ def test_installer_artifact_names_track_the_version_site() -> None: """OutFile / VIProductVersion 与 APP_VERSION 必须同源,否则装出来的包自称一个版本、 文件名叫另一个版本(`version.json` 的 min_shell_version 比对就失去意义)。""" sites = _site_versions() - ver = next(iter(set(sites.values()))) + # 基准取 pyproject,别用 next(iter(set(sites.values()))):版本位互相矛盾时那是在**随机挑一个** + # 当真值,setup.nsi 可能恰好撞上被挑中的那个而判过(2026-09-22 用假工作树撞通过一次)。 + ver = sites["pyproject.toml"] text = (PROJECT_ROOT / "scripts" / "installer" / "setup.nsi").read_text(encoding="utf-8", errors="ignore") assert f"TTSMultiModel-Setup-v{ver}.exe" in text, f"OutFile 还没跟到 v{ver}" assert f'VIProductVersion "{ver}.0"' in text, f"VIProductVersion 还没跟到 {ver}.0"