From 0c1aba3b14ad48f47de0598a046032baca5d08ac Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Tue, 22 Sep 2026 22:23:23 +0800 Subject: [PATCH] =?UTF-8?q?ci(release):=20=E7=BB=99=20release-please=20?= =?UTF-8?q?=E5=8A=A0=20exclude-paths=EF=BC=8C=E8=AE=A9=E7=BA=AF=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=94=B9=E5=8A=A8=E4=B8=8D=E5=86=8D=20mint=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测起因:#136 是**两个 `docs(release):` 提交、零代码改动**,合并 25 秒后 RP 就开出了 #137 `chore(main): release 2.2.6`,CHANGELOG 段是 3 条 `### Documentation`(它把 PR 的两条 真实提交**和 #136 的 merge commit 各算了一条**)。按自动路径的规矩,这一条版本要人再补 5 类 手工同步位 + 手工补跑镜像(tag 不级联那一条),而用户拿到的东西一个字节没变。这税不划算。 配置:`packages["."].exclude-paths = ["docs", "tests"]`。放在**包内那一层**而不是顶层同名键 —— RP 的 `CommitExclude` 构造器吃 `Record`,包内一定生效;顶层是否 被合并进包配置我没在源码里读到确证,写在那儿就是"看起来配了、其实没生效"那个老形状。 三个坑是读 `src/util/commit-exclude.ts` 确认的(匹配式 `file.startsWith(entry + "/")`, **不是 glob**),都落成了闸 `test_exclude_paths_entries_are_all_actionable`: 1. `docs/**` 这种写法永不命中,只能用裸目录名(RP 自己的测试用的就是 `['pkg3','pkg1']`); 2. 仓库根上的单个文件排不掉(那个 `+"/"` 让 `README.md`/`CHANGELOG.md`/ `release-please-config.json` 都做不成条目)—— 所以"只改发版配置"的提交仍会 mint 版本, 今天 #137 就是这么留下的,本条提交也是; 3. 条目写成 `.` 会把**所有**提交排掉,RP 从此不报错也不发版。 另加两条边界:排掉的目录里不许有 RP 要写的版本位,也不许有随包分发的代码(`app/`)。 顺带修两处已经数不对的话:§5 写"仓库内跟踪的 9 处",`_site_versions()` 今天是 **11** 行 (含 `scripts/installer/version.json` 那个装配中间物与本文 §1 那句散文);§1 里"第 10 处" 的序号也漂了,改成"今天这张表 11 行,它是最后收进来的一行"。 验证: - 变异复验 5 种写法(`docs/**` / `.` / 不存在的目录 / 清空列表 / 覆盖 `app/`)**全部 rc=1**, 还原配置后 `test_version_consistency.py` + `test_release_readiness_gate.py` 13 passed; `ruff check` + `format --check` 通过。 - 5 个发版链路测试文件合计 **31 passed**(版本位 9 + 发布条件闸 4 + DCO 豁免 4 + 缓存预算 2 + 性能门禁 12)。 - `check_release_readiness.py --root .` 仍判"发版条件满足"(11 处 = 2.2.5,8 条判据 PASS)。 - 合完之后要看一眼 #137:按上面第 2 条,它不会自动关,内容会从"3 条 Documentation"变成 带这条 config 改动 —— 关不关是 Release 侧动作,交回 owner。 Signed-off-by: ReSerendipity --- docs/release-governance.md | 26 ++++++++-- release-please-config.json | 4 +- tests/test_version_consistency.py | 79 +++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/docs/release-governance.md b/docs/release-governance.md index 9ed7934..ea9d146 100644 --- a/docs/release-governance.md +++ b/docs/release-governance.md @@ -28,7 +28,8 @@ > `scripts/installer/setup.nsi` 的 `OutFile`/`APP_VERSION`/`VIProductVersion` > (NSIS 注释符是 `;`,用不了 `generic` 要求的 `# x-release-please-version` 行内标记)、 > `deploy/kubernetes/deployment.yaml` 的镜像 tag(要跟 ghcr 上真存在的标签走)。 - > **第 10 处是散文**:本文开头那句"已发布最新 = v" —— RP 不碰它,而它真会漂 + > **还有一处是散文**(今天这张版本位表 11 行,它是最后收进来的一行):本文开头那句 + > "已发布最新 = v" —— RP 不碰它,而它真会漂 > (v2.2.5 发出去之后这里还停在 v2.2.4),所以已被 `test_all_version_sites_agree` > 当版本位钉住,补齐时顺手改一行。 > `version.json` 里 RP 只抬 `$.version`,**`changelog` 与 `release_date` 也是手工位** @@ -42,7 +43,7 @@ 1. 自动:合入 release PR(RP 在 main push 后自动开/刷新)并等它自己打 tag 发 Release。 **已于 2026-09-22 走通一次**:v2.2.5 的 tag `9125a3e`、Release 与 4 个资产 (`SHA256SUMS`、`SHA256SUMS.scripts`、wheel、sdist)都是 RP 自己产出/挂上的。 - 走这条路必须知道四件事: + 走这条路必须知道五件事: - **它只抬 5+1 处自动位**,剩下 5 类手工位(`config.yaml`、`Cargo.lock`、 `setup.nsi` 三处、k8s 镜像 tag、`version.json` 的 `changelog`/`release_date`) 要你在 release 分支上**另加一条提交**补齐;而**任何一次 main push 都会让 RP 重写那条分支**, @@ -65,6 +66,21 @@ 把版本位一致性一起判红(2026-09-22 的 v2.2.5 就卡在这一点上,见 §2 第 5 步)。 GPG 签名那一格现在不痛(`GPG_PRIVATE_KEY` 未配,作业进 skip 分支只出 notice), 但 owner 配上密钥后,RP 发的每个版本都要把 `gpg-signed-release.yml` 一起手工补跑。 + - **"什么算一次发版"默认宽到不实用**:实测 #136(两个 `docs(release):` 提交、零代码改动) + 合并 25 秒后 RP 就开出 **#137 `chore(main): release 2.2.6`**,CHANGELOG 段是 3 条 + `### Documentation`(它把 PR 的两个提交**和那条 merge commit 各算一条**)。 + 一条纯文档改动要人补 5 类手工位 + 补跑镜像,这税不划算,所以 + `release-please-config.json` 加了 `packages["."].exclude-paths = ["docs", "tests"]`。 + 这个字段有三个坑,都在 `src/util/commit-exclude.ts` 里(匹配式是 + `file.startsWith(entry + "/")`,**不是 glob**): + ① 写 `docs/**` 永不命中,只能用裸目录名 `docs`;RP 自己的测试用的也是 `['pkg3','pkg1']`; + ② **仓库根上的单个文件排不掉**(那个 `+"/"` 让 `README.md`、`CHANGELOG.md`、 + `release-please-config.json` 都做不成条目)—— 所以"只改发版配置"这一类提交仍然会 + mint 一个版本,今天这条 #137 就是这么留下的; + ③ 条目写成 `.` 会把**所有**提交排掉,RP 从此一声不响再也不发版(本仓踩过同形状的 + "永远绿却什么都不做",见下面 v2.2.2 那段旧账)。 + 闸是 `test_exclude_paths_entries_are_all_actionable`:glob / `.` / 不存在的目录 / + 清空列表 / 覆盖 `app/` 这 5 种写法都实测过会红。 2. 手工:`git tag -a` + `gh release create`(v2.2.2 / v2.2.3 / v2.2.4 走的就是这条)。 手工发版之后 RP 会在下一条 release PR 里把版本号再抬一格(它按 manifest 算), 所以手工发完要把 `.release-please-manifest.json` 一起抬到刚发的版本,否则两边在版本号上互踩。 @@ -123,8 +139,10 @@ ## 5. 发布前检查清单 -- [ ] 版本位全部同步(`pyproject.toml` + `config.yaml` + `CHANGELOG.md`;仓库内跟踪的 9 处已由 - `tests/test_version_consistency.py::test_all_version_sites_agree` 机器核对,这格是**复看**用) +- [ ] 版本位全部同步(`pyproject.toml` + `config.yaml` + `CHANGELOG.md`;仓库内跟踪的 11 处 + 由 `tests/test_version_consistency.py::test_all_version_sites_agree` 机器核对 —— + 数目以 `_site_versions()` 实际返回为准,含 `scripts/installer/version.json` 那个装配中间物 + 和本文 §1 开头那句"已发布最新",这格是**复看**用) - [ ] `version.json` 的 `changelog`/`release_date` 已改成本次版本(RP 只抬 `$.version`; `test_bundled_changelog_describes_its_own_version` 会拦"版本号新、说明旧") - [ ] CHANGELOG `[Unreleased]` 已改版本 + 日期 diff --git a/release-please-config.json b/release-please-config.json index b65d4c1..4c38d5e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -12,6 +12,8 @@ { "type": "toml", "path": "desktop/src-tauri/Cargo.toml", "jsonpath": "$.package.version" } ], "packages": { - ".": {} + ".": { + "exclude-paths": ["docs", "tests"] + } } } diff --git a/tests/test_version_consistency.py b/tests/test_version_consistency.py index ff0bacd..c201cdc 100644 --- a/tests/test_version_consistency.py +++ b/tests/test_version_consistency.py @@ -284,6 +284,85 @@ def test_extra_files_entries_are_all_actionable() -> None: assert not pending, "extra-files 有命中不了的条目:\n " + "\n ".join(pending) +def _exclude_paths_entries() -> list[str]: + """`packages["."].exclude-paths` —— 故意读包内那一层,不读顶层同名键。 + + RP 的 `CommitExclude` 构造器吃的是 `Record`, + 包内那层一定是生效的;顶层那层是否被合并进包配置我没在 RP 源码里读到确证, + 写在那里就会变成"看起来配了、其实没生效"。 + """ + cfg = json.loads((PROJECT_ROOT / "release-please-config.json").read_text(encoding="utf-8")) + pkg = (cfg.get("packages") or {}).get(".") or {} + return [str(p) for p in (pkg.get("exclude-paths") or [])] + + +#: RP 匹配排除目录的方式(`src/util/commit-exclude.ts`,2026-09-22 读源码确认): +#: `file.indexOf(`${path}/`) === 0` —— 前缀比对,**不是 glob**。 +_EXCLUDE_GLOB_CHARS = re.compile(r"[*?\[\]]") + + +def test_exclude_paths_entries_are_all_actionable() -> None: + """每一条 exclude-paths 都必须"今天真的会排掉提交",否则它就是一行装饰。 + + 起因是实测:#136(两个 `docs(release):` 提交、零代码改动)合并后 25 秒, + RP 就开出了 `chore(main): release 2.2.6`(CHANGELOG 段是 3 条 `### Documentation`)—— + 纯文档改动会被当成一次可发布的版本,而每条这种版本都要人补 5 类手工同步位。 + 于是加了 `exclude-paths: ["docs", "tests"]`。这条闸钉住它别写错,因为这里最容易写错: + + 1. **不是 glob**。写 `docs/**` 或 `docs/**/*.md` 一点都不会生效 —— + 匹配式是 `file.startsWith(entry + "/")`,`docs/**/` 永远不是任何文件路径的前缀。 + RP 自己的测试用的也是裸目录名(`test/util/commit-exclude.ts` 里是 `['pkg3','pkg1']`)。 + 2. **排不掉仓库根上的单个文件**:同样的 `+"/"` 规则让 `README.md`、`CHANGELOG.md`、 + `release-please-config.json` 这类根文件做不成条目(所以下面要求条目必须是真实目录)。 + 3. **"." 会把所有提交都排掉** → RP 永远不开 release PR,而且**不会报错**, + 正是本仓踩过的"永远绿却什么都不做"那个形状(见 §1 的 v2.2.2 旧账)。 + 4. 排掉的目录里不能有任何 RP 要写的版本位或随包分发的代码:那才是真会漏东西的地方。 + """ + entries = _exclude_paths_entries() + assert entries, ( + "exclude-paths 空了。今天的行为已经实测过:纯文档合并会在 25 秒内开出一条 " + "release PR(#136 → #137 / 2.2.6),所以要么把它加回来,要么连同 " + "docs/release-governance.md §1 第 5 条一起改口径 —— 别只删配置。" + ) + + dead: list[str] = [] + for entry in entries: + if entry in ("", ".", "/"): + dead.append(f"{entry!r}:会把仓库根当目录,等于排掉所有提交(RP 不报错,只是再也不发版)") + continue + if _EXCLUDE_GLOB_CHARS.search(entry) or entry.endswith("/") or entry.startswith("/"): + dead.append( + f"{entry!r}:带 glob/斜杠。RP 的匹配是 `file.startsWith(entry + '/')`," + "`docs/**` 这种写法永远命中不了 —— 要用裸目录名 `docs`" + ) + continue + target = PROJECT_ROOT / entry + if not target.is_dir(): + dead.append(f"{entry}:不是仓库里的目录(根级单文件排不掉,见 `isRelevant` 的 `+'/')") + continue + files = [p for p in target.rglob("*") if p.is_file() and ".git" not in p.parts] + if not files: + dead.append(f"{entry}:目录是空的,没有任何提交会只落在这里 → 排了等于没排") + + covered = [ + f"{rel}(RP 自动位)" + for rel in sorted(_RP_MANAGED | {"CHANGELOG.md"}) + if any(e and rel.startswith(f"{e}/") for e in entries) + ] + assert not covered, f"exclude-paths 覆盖到了 RP 要写的版本位:{covered} —— 这些位一旦落在被排的目录里,发版时没人抬" + + shipped = [ + str(p.relative_to(PROJECT_ROOT)).replace("\\", "/") + for p in (PROJECT_ROOT / "app").rglob("*.py") + if any(e and str(p.relative_to(PROJECT_ROOT)).replace("\\", "/").startswith(f"{e}/") for e in entries) + ] + assert not shipped, ( + "exclude-paths 覆盖到了随包分发的代码(pyproject 的 packages.find where=['app']):" + f"{shipped[:5]} —— 排掉这种目录会让真改动不发版" + ) + assert not dead, "exclude-paths 有排不掉任何提交的条目:\n " + "\n ".join(dead) + + #: 当前解释器没有 TOML 解析器时的哨兵(区别于"有解析器但走不到 key")。 _NO_PARSER = "__no-toml-parser__"