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
54 changes: 35 additions & 19 deletions docs/release-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,42 +8,56 @@

## 1. 版本号规范

- 遵循 SemVer `MAJOR.MINOR.PATCH`。**已发布最新 = v2.2.2(2026-09-22)**。
- 遵循 SemVer `MAJOR.MINOR.PATCH`。**已发布最新 = v2.2.3(2026-09-22)**。
版本号出现在 11 处(`version.json`/`pyproject.toml`/`config.yaml`/`desktop/*`/`scripts/installer/setup.nsi`),
但只有 tag + GitHub Release 同时存在才算发出去;核对:`gh release view v<版本>`。
> 其中 `desktop/package-lock.json` 在 `.gitignore` 里(不是仓库内的版本位);仓库内跟踪的 9 处
> 由 `tests/test_version_consistency.py::test_all_version_sites_agree` 逐条比对,漂一处就红
> (v2.2.2 发版前 `deploy/kubernetes/deployment.yaml` 的镜像 tag 就停在 2.2.1,红得下来)。
>
> **哪几处 release-please 会自动改、哪几处必须人改**(它只支持 `json|toml|yaml|xml|pom|generic`,
> 没有 regex):`pyproject.toml` 与 `release-please-config.json` 的 `extra-files`
> (`version.json`/`config.yaml`/`desktop/package.json`/`tauri.conf.json`/`Cargo.toml`)是自动的;
> **必须人补**的是 `desktop/src-tauri/Cargo.lock`(归 cargo 生成)、
> 没有 regex):自动的是 `pyproject.toml`(python release-type 自带)与
> `release-please-config.json` 的 `extra-files` 里那 5 条 —— `version.json`、
> `desktop/package.json`、`desktop/src-tauri/tauri.conf.json`、`desktop/src-tauri/Cargo.toml`
> 的 jsonpath 站点,实测在 PR #120 上每条只动 1 行。
> **`config.yaml` 曾经挂过 `yaml` 类型,已摘除**:release-please 的 YAML 写入器不是"改那一个字段",
> 而是解析后整份重排 —— 同一条 PR 上它把 232 行配置改成了 **160 增 / 160 删**,
> 注释行从 **96 行变成 0 行**(`model_source_mode` 的选型说明、SSL 怎么打开、
> `vram_safety_margin_gb` 的算式),并把 `"127.0.0.1"` 这类引号去掉。它现在归手工同步。
> 另三处也**必须人补**:`desktop/src-tauri/Cargo.lock`(归 cargo 生成)、
> `scripts/installer/setup.nsi` 的 `OutFile`/`APP_VERSION`/`VIProductVersion`
> (NSIS 注释符是 `;`,用不了 `generic` 要求的 `# x-release-please-version` 标记)、
> (NSIS 注释符是 `;`,用不了 `generic` 要求的 `# x-release-please-version` 行内标记)、
> `deploy/kubernetes/deployment.yaml` 的镜像 tag(要跟 ghcr 上真存在的标签走)。
> 所以下一条 release PR 上,红在这三处是**预期行为**,补一个 commit 即可 ——
> 失败信息里会逐条标注哪处是『RP 自动』、哪处是『手工同步』。
> `version.json` 里 RP 只抬 `$.version`,**`changelog` 与 `release_date` 也是手工位**
> (壳的 `updater.rs` 会把 changelog 显示给用户,只抬版本号会发出"自称 2.2.4、说明写着 2.2.3"的包)。
> 所以下一条 release PR 上,红在这几处是**预期行为**,补一个 commit 即可 ——
> 失败信息里会逐条标注哪处是『RP 自动』、哪处是『手工同步』,
> 并由 `test_extra_files_entries_are_all_actionable` 钉住"每条 extra-files 今天确实能命中"。
- 版本位:`pyproject.toml` + `config.yaml`(release-please 驱动前端缓存参数需人工补齐,见本地 AGENTS.md #9(AGENTS.md 为本地维护、不随仓库分发))+ `CHANGELOG.md`。
- 发布**目前**由人工 `git tag -a` + `gh release create` 完成:**`release-please.yml` 是结构性空转**,
不会替你发版 —— 它设了 `skip-github-pull-request: true` 而仓库从未产生过 release PR,
于是每次 main push 都输出 `found 0 possible releases` 后成功;它还传了 5 个 v4 不认的入参
(`package-name`/`changelog-path`/`draft`/`label`/`prerelease` 全被忽略),且没有
`release-please-config.json` / `.release-please-manifest.json`。
挂在它下面的 `build-release`(sdist/wheel + SHA256SUMS)因 `release_created != true` 同样从不执行。
(v2.2.2 曾在工作流全绿的情况下既没 tag 迁移也没 Release,原因即此;修法另见待落的 RP 修复 PR。)
- **发版有两条路,别同时走**:
1. 自动:合入 release PR(RP 在 main push 后自动开/刷新,如 #120)并等它自己打 tag 发 Release;
2. 手工:`git tag -a` + `gh release create`(v2.2.2/v2.2.3 走的就是这条)。
手工发版之后 RP 会在下一条 release PR 里把版本号再抬一格(它按 manifest 算),
所以手工发完要把 `.release-please-manifest.json` 一起抬到刚发的版本,否则两边在版本号上互踩。
历史上 `release-please.yml` 曾是**结构性空转**(`skip-github-pull-request: true` + 缺
config/manifest + 5 个 v4 不认的入参 → 每次 main push 输出 `found 0 possible releases` 后绿,
v2.2.2 因此三次绿 run 都没 Release);已修,现在它真的会开 PR。挂在它下面的
`build-release`(sdist/wheel + SHA256SUMS)同样只在 `release_created == true` 时执行。

## 2. 发布流程

1. 确认本批内容已进 CHANGELOG(`[Unreleased]` 收敛进 `[<新版本>]` 并改日期);
`tests/test_version_consistency.py` 必须绿(它就是"§5 版本位全部同步"那格闸)
2. 同步 `config.yaml` 顶层 `version` 与 `deploy/kubernetes/deployment.yaml` 的镜像 tag
(都无人自动改;后者红在版本位一致性测试里)
3. 手工发版(RP 目前是空转,见 §1):
(都不在 extra-files 里:前者因为 RP 的 YAML 写入器会整份重排并洗掉注释,见 §1;
后者要跟 ghcr 上真存在的标签走。两处都红在版本位一致性测试里)
3. 手工发版(v2.2.2/v2.2.3 走的就是这条;自动那条见 §1 末):
`git tag -a v<版本> -m "..." <SHA>` → `git push origin v<版本>` →
`python -m build` + `twine check dist/*` + `SHA256SUMS.txt` →
`gh release create v<版本> --notes-file ... <资产>`;核对 `gh release view v<版本>`
4. CI 盯到终态;容器镜像**发布走 `docker-publish.yml`**,上线时按 digest 钉,禁止 `:latest`
4. 手工发完把 `.release-please-manifest.json` 的 `"."` 抬到刚发的版本并推 main,
否则 RP 会在下一条 release PR 里把版本号再抬一格(两边互踩)
5. CI 盯到终态;容器镜像**发布走 `docker-publish.yml`**,上线时按 digest 钉,禁止 `:latest`

> 便携分卷(core/torch/model,~26 GB)与桌面增量包**不在**第 3 步的默认资产里:
> 需要 `scripts/release_gate.ps1 -ModelDir ... -RuntimeDir ... -TorchWheelDir ...` 真构建 + 单独点头
Expand All @@ -65,11 +79,13 @@

- [ ] 版本位全部同步(`pyproject.toml` + `config.yaml` + `CHANGELOG.md`;仓库内跟踪的 9 处已由
`tests/test_version_consistency.py::test_all_version_sites_agree` 机器核对,这格是**复看**用)
- [ ] `version.json` 的 `changelog`/`release_date` 已改成本次版本(RP 只抬 `$.version`;
`test_bundled_changelog_describes_its_own_version` 会拦"版本号新、说明旧")
- [ ] CHANGELOG `[Unreleased]` 已改版本 + 日期
- [ ] 全量 pytest 通过(门禁实测:非 GPU 回归 0 failed)
- [ ] `ruff` 全绿;mypy 遵守 `.ci/mypy_baseline.txt` 棘轮
- [ ] `python scripts/check_spec_refs.py` 退出码 0
- [ ] 镜像 digest 钉版 + Trivy 关键/高危扫描绿
- [ ] 完整性自检 16/16 通过
- [ ] tag 已推送,且 `gh release view v<版本>` 能看到 Release(**推 tag 不会触发自动发版**:
`release-please.yml` 目前结构性空转,见 §1;Release 需手工建 + 手工挂资产)
- [ ] tag 已推送,且 `gh release view v<版本>` 能看到 Release(**推 tag 本身不会建 Release**:
走手工路径时要自己建 + 挂资产;走 RP 路径时由它合 PR 后打 tag 并建 Release,见 §1)
3 changes: 1 addition & 2 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
{ "type": "json", "path": "version.json", "jsonpath": "$.version" },
{ "type": "json", "path": "desktop/package.json", "jsonpath": "$.version" },
{ "type": "json", "path": "desktop/src-tauri/tauri.conf.json", "jsonpath": "$.version" },
{ "type": "toml", "path": "desktop/src-tauri/Cargo.toml", "jsonpath": "$.package.version" },
{ "type": "yaml", "path": "config.yaml", "jsonpath": "$.version" }
{ "type": "toml", "path": "desktop/src-tauri/Cargo.toml", "jsonpath": "$.package.version" }
],
"packages": {
".": {}
Expand Down
121 changes: 115 additions & 6 deletions tests/test_version_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,16 @@ def test_no_hardcoded_old_version():

#: release-please 通过 `release-please-config.json` 的 extra-files 会自动抬的版本位。
#: 键与本文件 `_site_versions()` 的键一致;改 config 时同步改这里,否则失败信息会指错方向。
#:
#: `config.yaml` **不在**这里,而且不是遗漏:release-please 的 `yaml` 写入器会整份重排
#: 文档,第一次真跑(PR #120,2026-09-22)就把 232 行配置改成了 160 增/160 删,
#: 注释行从 96 行变成 0 行(`model_source_mode` 的选型说明、SSL 开关怎么打开、
#: `vram_safety_margin_gb` 的算式),并把 `"127.0.0.1"` 的引号去掉。
#: 同一条 PR 上 json/toml 那 4 条各只动 1 行 —— 破坏性是 `yaml` 类型特有的。
#: 见 `test_extra_files_entries_are_all_actionable` 里的类型白名单。
_RP_MANAGED = {
"pyproject.toml",
"version.json",
"config.yaml",
"desktop/package.json",
"desktop/src-tauri/tauri.conf.json",
"desktop/src-tauri/Cargo.toml",
Expand Down Expand Up @@ -167,7 +173,8 @@ def test_all_version_sites_agree() -> None:
f"{k} = {v}{' ← RP 自动' if k in _RP_MANAGED else ' ← 手工同步'}" for k, v in sorted(sites.items())
)
+ "\n release-please 只会改上面标『RP 自动』的那些(它只支持 json/toml/yaml/xml/pom/generic,"
"没有 regex);标『手工同步』的必须在 release PR 上补一个 commit —— "
"没有 regex,且 yaml 写入器会整份重排、把注释洗掉,所以 config.yaml 也不在里面);"
"标『手工同步』的必须在 release PR 上补一个 commit —— "
"Cargo.lock 归 cargo 生成、setup.nsi 的注释符是 `;` 用不了 generic 的 `# x-release-please-version` 标记、"
"k8s 镜像 tag 要跟 ghcr 上真存在的标签走、安装器那份是 gitignore 的装配中间物。"
)
Expand All @@ -191,15 +198,117 @@ def test_installer_artifact_names_track_the_version_site() -> None:
assert _ver_tuple(minimum) <= _ver_tuple(ver), f"minimum_shell_version={minimum} 高于本次版本 {ver}"


def _extra_files_entries() -> list[dict]:
cfg = json.loads((PROJECT_ROOT / "release-please-config.json").read_text(encoding="utf-8"))
return list(cfg.get("extra-files") or [])


def test_rp_managed_annotation_matches_the_actual_config() -> None:
"""`_RP_MANAGED` 只是给失败信息指路用的,它自己不能漂:必须与
release-please-config.json 的 extra-files + pyproject(python release-type 自带)一致。"""
import re as _re

cfg = (PROJECT_ROOT / "release-please-config.json").read_text(encoding="utf-8")
listed = set(_re.findall(r'"path":\s*"([^"]+)"', cfg))
listed = {str(e.get("path")) for e in _extra_files_entries()}
assert listed, "config 里一个 extra-files 都没有,那这条闸就没意义了"
assert listed | {"pyproject.toml"} == _RP_MANAGED, (
f"RP 自动位与测试里的标注不一致:config 有 {sorted(listed)},标注多/少的部分是"
f" {sorted((listed | {'pyproject.toml'}) ^ _RP_MANAGED)}"
)


def test_extra_files_entries_are_all_actionable() -> None:
"""每一条 extra-files 都必须"真的能命中",否则 release-please 会在无人察觉时少抬一处版本位。

三件事都在这里钉住:
1. 类型白名单只放 `json|toml|generic`。`yaml` 被排除是有账的:它在 PR #120(2.2.4)
上把 `config.yaml` 的 232 行改写成 160 增/160 删,注释行 96 → 0 —— 静默、且每次都发生。
2. `json|toml`:按 jsonpath 走进目标文件,取到的值必须**当前就是那个版本号**
(写错 key、文件搬家、字段改名都会在这里红,而不是在发版当天发现)。
3. `generic`:那一行必须带着 `x-release-please-version` 标记,它是行内匹配 ——
标记一旦丢(`routes/system/settings.py` 的 `_save_yaml_raw` 用 safe_dump 整体重写
config.yaml,注释必然消失),版本位就再也不动。
"""
entries = _extra_files_entries()
assert entries, "config 里没有 extra-files,本条闸没有对象"
ver = _site_versions()["pyproject.toml"]

unsupported = [e for e in entries if str(e.get("type")) not in {"json", "toml", "generic"}]
assert not unsupported, (
"extra-files 用了没在 PR 上验过的类型:"
+ str([(e.get("path"), e.get("type")) for e in unsupported])
+ " —— release-please 的 yaml 写入器会整份重排文档并删掉注释(实测见本文件 "
"_RP_MANAGED 上方的账),只允许 json/toml/generic。"
)

pending: list[str] = []
sites = _site_versions()
for entry in entries:
rel, type_, path = str(entry["path"]), str(entry["type"]), str(entry.get("jsonpath") or "")
target = PROJECT_ROOT / rel
if not target.exists():
pending.append(f"{rel}:文件不存在")
continue
if type_ == "generic":
body = target.read_text(encoding="utf-8", errors="ignore")
hit = [ln for ln in body.splitlines() if "x-release-please-version" in ln and _SEMVER.search(ln)]
if not hit:
pending.append(
f"{rel}:generic 要求同一行内既有 `x-release-please-version` 标记又有一个 x.y.z,两者都没找到"
)
continue
value = _jsonpath_value(target, path, type_)
if value == _NO_PARSER:
# Python 3.10 没有 tomllib(CI 矩阵里就有 3.10),这一位只能交给
# `test_all_version_sites_agree` 的读取器去核 —— 它对 Cargo.toml 是覆盖到的;
# 连站点读取器都没有(下表没这一行)就真的没人管了,那种情况要响。
if rel not in sites:
pending.append(
f"{rel}:本解释器读不了 {type_}(无 tomllib),且 {rel} 不在 _site_versions() 里 —— 没人核对这一位"
)
continue
if isinstance(value, str) and value.startswith("__unread__"):
pending.append(f"{rel}:jsonpath {path} 走不到({value})")
elif str(value) != ver:
pending.append(
f"{rel}:jsonpath {path} 现在是 {value!r},与 canonical {ver!r} 不等 —— 它不会被抬到本次版本"
)
if rel in sites and sites[rel] != ver:
pending.append(f"{rel}:本文件另一个读取器看到的是 {sites[rel]!r},与 canonical 不一致")
assert not pending, "extra-files 有命中不了的条目:\n " + "\n ".join(pending)


#: 当前解释器没有 TOML 解析器时的哨兵(区别于"有解析器但走不到 key")。
_NO_PARSER = "__no-toml-parser__"


def _jsonpath_value(target: Path, path: str, type_: str) -> object:
"""按 RP 的 jsonpath 取当前值;取不到返回 `__unread__:原因` 字符串(交给调用方汇总)。"""
keys = path.removeprefix("$").strip(".").split(".")
try:
if type_ == "json":
doc: object = json.loads(target.read_text(encoding="utf-8"))
else:
try:
import tomllib
except ImportError: # CI 矩阵有 3.10,那里没有 tomllib
return _NO_PARSER
doc = tomllib.loads(target.read_text(encoding="utf-8"))
for key in keys:
if not isinstance(doc, dict) or key not in doc:
return f"__unread__:缺 key {key!r}"
doc = doc[key]
return doc
except (json.JSONDecodeError, OSError, ValueError) as exc:
return f"__unread__:{type(exc).__name__}: {exc}"


def test_bundled_changelog_describes_its_own_version() -> None:
"""`version.json` 的 `$.version` 由 RP 自动抬,但 `changelog`/`release_date` 不会 ——
而 `desktop/src-tauri/src/updater.rs` 会读本地 `version.json` 的 changelog 展示给用户。
只抬版本号就会发出"自称 2.2.4、说明写着 2.2.3"的壳,所以这条必须红在 release PR 上。"""
data = json.loads((PROJECT_ROOT / "version.json").read_text(encoding="utf-8"))
ver = str(data["version"])
changelog = str(data.get("changelog", ""))
assert changelog.strip(), "version.json 没有 changelog 字段,壳里那栏是空的"
assert changelog.lstrip().startswith(ver), (
f"version.json 自称 {ver},changelog 却在讲另一个版本:{changelog.splitlines()[0][:40]!r}..."
" —— RP 只改 $.version,发版时把这段说明(和 release_date)一起补上。"
)
Loading