Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions scripts/check_release_readiness.py
Original file line number Diff line number Diff line change
@@ -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())
176 changes: 176 additions & 0 deletions tests/test_release_readiness_gate.py
Original file line number Diff line number Diff line change
@@ -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/<owner>/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"
)
4 changes: 3 additions & 1 deletion tests/test_version_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading