diff --git a/docs/release-governance.md b/docs/release-governance.md index 0856113..06525ca 100644 --- a/docs/release-governance.md +++ b/docs/release-governance.md @@ -8,7 +8,7 @@ ## 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 处 @@ -16,34 +16,48 @@ > (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 "..." ` → `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 ...` 真构建 + 单独点头 @@ -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 需手工建 + 手工挂资产) \ No newline at end of file +- [ ] tag 已推送,且 `gh release view v<版本>` 能看到 Release(**推 tag 本身不会建 Release**: + 走手工路径时要自己建 + 挂资产;走 RP 路径时由它合 PR 后打 tag 并建 Release,见 §1) \ No newline at end of file diff --git a/release-please-config.json b/release-please-config.json index 78ba566..b65d4c1 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -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": { ".": {} diff --git a/tests/test_version_consistency.py b/tests/test_version_consistency.py index bdc50be..b87e106 100644 --- a/tests/test_version_consistency.py +++ b/tests/test_version_consistency.py @@ -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", @@ -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 的装配中间物。" ) @@ -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)一起补上。" + )