diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a56628..35af3a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,12 @@ jobs: run: python3 scripts/ci/tools_cache_isolation_gate.py --self-test - name: 集成测试缓存隔离门禁 run: python3 scripts/ci/tools_cache_isolation_gate.py + # 发版 bump 只改 Cargo.toml 会让锁文件悄悄漂移:日常 cargo 调用重写工作区、 + # git pull 被挡;一旦哪里用了 --locked 直接红。判据只比对本包版本行。 + - name: 判据自测:锁文件版本一致性 + run: python3 scripts/ci/lock_version_gate.py --self-test + - name: 锁文件版本一致性门禁 + run: python3 scripts/ci/lock_version_gate.py # 安装脚本的 latest tag 解析:正反对照 + 旧写法恒空对照(离线跑,不碰网络) - name: 安装脚本 latest tag 解析测试 run: bash scripts/ci/install_tag_parse_test.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b794eb6..45619f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,6 +137,7 @@ CI 的 `gates` job 里有两条判据,都可以本地先跑,不必等 CI: python3 scripts/ci/commit_author_gate.py --base origin/main # 提交作者角色身份 python3 scripts/ci/new_file_fmt_gate.py --base origin/main # 本次新增 *.rs 的格式 python3 scripts/ci/tools_cache_isolation_gate.py # 开稳定层的测试是否隔离了缓存落盘 +python3 scripts/ci/lock_version_gate.py # Cargo.lock 版本是否跟上一 ``` - **提交作者**:区间内每个 commit 的 author 名必须是 `codex-layer-<角色>`, @@ -165,6 +166,18 @@ python3 scripts/ci/tools_cache_isolation_gate.py # 开稳定层的 .env("CODEX_LAYER_TOOLS_CACHE_PATH", dir.path().join("tools-cache.json")) ``` +- **锁文件版本一致性**:`Cargo.lock` 里本包的 version 必须等于 `Cargo.toml`。 + 发版 bump **必须两处在同一笔 commit**:只改清单会让锁文件漂移,之后任何一次 + `cargo` 调用都会悄悄重写工作区、把 `git pull` 挡住,而任何用 `cargo build --locked` + 的地方直接报错。本地同步锁文件(离线,不访问网络): + + ```bash + cargo metadata --format-version 1 --offline > /dev/null + ``` + + 判据只比对本包自己的版本行,不校验依赖解析结果——后者是 `--locked` 的职责, + 写进来会把正常的依赖升级拦在门外。 + 两条判据都带 `--self-test`,先自证判据可用再判真实区间。若本地报「无法执行」 (取不到基线、找不到 rustfmt),那是判据自身不可用,会以非零退出, **不要**把它当成「通过」。 diff --git a/Cargo.lock b/Cargo.lock index dec4cbd..75520ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,7 +160,7 @@ dependencies = [ [[package]] name = "codex-layer" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "axum", diff --git a/scripts/ci/lock_version_gate.py b/scripts/ci/lock_version_gate.py new file mode 100755 index 0000000..2949ef6 --- /dev/null +++ b/scripts/ci/lock_version_gate.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""锁文件版本一致性门禁:`Cargo.lock` 里的包版本必须等于 `Cargo.toml` 的版本。 + +立项依据(本机真实踩到的两类代价): +1. 只改 `Cargo.toml` 而不同步 `Cargo.lock` 时,任何一次 `cargo` 调用都会**悄悄重写 + 锁文件**,工作区凭空多出未提交改动,`git pull` 被挡(本仓库已两次因此先 stash 再 + drop 才能继续); +2. 一旦有人(或 CI)用 `cargo build --locked`,会直接报 + 「cannot update the lock file because --locked was passed」——发版链路上就是红。 + +根因是发版 bump 只改一处。判据刻意做得极窄:只比对本包自己的版本行, +不校验依赖解析结果(那属于 `--locked` 的职责,且会把正常的依赖升级拦在门外)。 + +判据本体是纯函数 `decide`,`--self-test` 带正反对照;两个文件任一读不到都显式失败, +绝不把「读不到」当成「一致」。 +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys + +PACKAGE = "codex-layer" + + +class GateError(RuntimeError): + """判据自身不可用时抛出——绝不降级成「没有违规」。""" + + +def manifest_version(text: str) -> str: + """取 `[package]` 段里第一条 `version = "x.y.z"`。""" + m = re.search(r"^\[package\][^\[]*?^version\s*=\s*\"([^\"]+)\"", text, re.M | re.S) + if not m: + raise GateError("Cargo.toml 里找不到 [package] 段的 version 行") + return m.group(1) + + +def lock_version(text: str, package: str = PACKAGE) -> str: + """取锁文件里该包那条 `[[package]]` 的 version。""" + pattern = re.compile( + r"^\[\[package\]\]\nname = \"" + re.escape(package) + r"\"\nversion = \"([^\"]+)\"", + re.M, + ) + found = pattern.findall(text) + if len(found) != 1: + raise GateError(f"Cargo.lock 里本包条目数异常({len(found)} 条),判据无法定论") + return found[0] + + +def decide(manifest: str, locked: str) -> str | None: + """一致返回 None;不一致返回人读的差异。""" + return None if manifest == locked else f"Cargo.toml={manifest} 而 Cargo.lock={locked}" + + +def read(path: str) -> str: + try: + with open(path, encoding="utf-8") as handle: + return handle.read() + except OSError as exc: + raise GateError(f"读不到 {path}:{exc}") from exc + + +def self_test() -> int: + manifest = '[package]\nname = "codex-layer"\nversion = "0.3.3"\nedition = "2024"\n' + ok_lock = 'version = 4\n\n[[package]]\nname = "codex-layer"\nversion = "0.3.3"\n' + bad_lock = 'version = 4\n\n[[package]]\nname = "codex-layer"\nversion = "0.3.2"\n' + other_lock = 'version = 4\n\n[[package]]\nname = "axum"\nversion = "0.3.2"\n' + + if manifest_version(manifest) != "0.3.3": + print("自测失败:manifest 版本解析错误", file=sys.stderr) + return 1 + if lock_version(ok_lock) != "0.3.3": + print("自测失败:lock 版本解析错误", file=sys.stderr) + return 1 + if decide("0.3.3", "0.3.3") is not None: + print("自测失败:一致版本被判违规", file=sys.stderr) + return 1 + if decide("0.3.3", "0.3.2") is None: + print("自测失败:漂移版本未被抓出", file=sys.stderr) + return 1 + # 锁文件里根本没有本包条目:判据自身失效,必须显式报错而不是放行 + try: + lock_version(other_lock) + except GateError: + pass + else: + print("自测失败:锁文件缺本包条目却未报错", file=sys.stderr) + return 1 + print( + "锁文件版本门禁自测通过:一致放行、漂移判红、" + "manifest 与 lock 的解析各自正确、锁文件缺本包条目显式报错。" + ) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="断言 Cargo.lock 的包版本等于 Cargo.toml") + parser.add_argument("--self-test", action="store_true", help="跑夹具对照,不查真实仓库") + parser.add_argument("--repo", help="仓库目录(默认当前目录)") + args = parser.parse_args() + + if args.self_test: + return self_test() + + repo = args.repo or os.getcwd() + try: + manifest = manifest_version(read(os.path.join(repo, "Cargo.toml"))) + locked = lock_version(read(os.path.join(repo, "Cargo.lock"))) + except GateError as exc: + print(f"锁文件版本门禁无法执行:{exc}", file=sys.stderr) + return 2 + + reason = decide(manifest, locked) + if reason: + print(f"锁文件版本漂移:{reason}", file=sys.stderr) + print( + "\n修正办法(离线即可,不访问网络):\n" + " cargo metadata --format-version 1 --offline > /dev/null\n" + "然后把 Cargo.lock 与被改的 Cargo.toml 一起提交——发版 bump 必须两处在同一笔。", + file=sys.stderr, + ) + return 1 + print(f"锁文件版本与清单一致({manifest})。") + return 0 + + +if __name__ == "__main__": + sys.exit(main())