Merged
fix(nvidia): 修正 RTX 6000 Ada 身份并试点独立变更片段 #1441
fix(nvidia): 修正 RTX 6000 Ada 身份并试点独立变更片段 #1441
Merged
liuxingsenAdd on 6 days ago
Total: 10 files changed+1021-3
@@ -28,3 +28,12 @@ repos:
28 args: [--maxkb, "1024"]28 args: [--maxkb, "1024"]
29 - id: check-case-conflict29 - id: check-case-conflict
30 - id: detect-private-key30 - id: detect-private-key
31+ 
32+ - repo: local
33+ hooks:
34+ - id: release-fragments
35+ name: 校验独立变更片段
36+ entry: python3 scripts/release_metadata.py validate
37+ language: system
38+ pass_filenames: false
39+ always_run: true
@@ -19,7 +19,7 @@ pre-commit run --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG
19 19 
20### 配置详情20### 配置详情
21 21 
22-本项目使用以下两组钩子:22+本项目使用以下远端钩子和本地检查:
23 23 
24**[openUBMC pre-commit-hooks](https://gitcode.com/openUBMC/pre-commit-hooks/blob/main/README.md)**:24**[openUBMC pre-commit-hooks](https://gitcode.com/openUBMC/pre-commit-hooks/blob/main/README.md)**:
25- **conventional-commit**: 检查提交消息是否符合 Conventional Commits 规范25- **conventional-commit**: 检查提交消息是否符合 Conventional Commits 规范
@@ -37,6 +37,9 @@ pre-commit run --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG
37- **check-case-conflict**: 检查文件名大小写冲突37- **check-case-conflict**: 检查文件名大小写冲突
38- **detect-private-key**: 检测私钥泄露38- **detect-private-key**: 检测私钥泄露
39 39 
40+**本地 release-fragments**:校验独立变更片段的格式;允许归档后或既有流程中没有片段。
41+正式版本校验还须按下方版本流程指定目标基线执行。
42+ 
40## clang-tidy 静态检查43## clang-tidy 静态检查
41 44 
42通过 `bingo test -ct` 在开发者测试阶段自动执行 clang-tidy 静态代码分析,检查代码规范问题。45通过 `bingo test -ct` 在开发者测试阶段自动执行 clang-tidy 静态代码分析,检查代码规范问题。
@@ -117,6 +120,16 @@ HeaderFilterRegex: '.*'
117 120 
118详见 [clang-tidy 官方文档](https://clang.llvm.org/extra/clang-tidy/)。121详见 [clang-tidy 官方文档](https://clang.llvm.org/extra/clang-tidy/)。
119 122 
123+## 版本号与变更说明
124+ 
125+并行 PR 使用 `changes/pending/<UUID>.json` 分别记录变更,开发期间不提前修改
126+`mds/service.json.version` 和根 `CHANGELOG`。本地提交检查会校验片段格式。
127+准备合入时,由维护者在已更新的目标基线上运行版本准备工具,将代码、版本和日志
128+整理到同一个最终提交;生成后重新验证再合入。
129+ 
130+具体命令、Conan 版本约束及社区队列尚未接入时的操作边界见
131+[组件版本与变更说明流程](docs/versioning.md)。仅通过片段格式检查不代表已完成正式定版。
132+ 
120## 许可证133## 许可证
121 134 
122Mulan PSL v2 许可证,详见 [LICENSE](LICENSE) 文件135Mulan PSL v2 许可证,详见 [LICENSE](LICENSE) 文件
@@ -0,0 +1,8 @@
1+{
2+ "schema": 1,
3+ "component": "component_drivers",
4+ "category": "fix",
5+ "version_action": "increment_patch",
6+ "summary": "修正 RTX 6000 Ada 的 PCIe 身份四元组并补充配置回归;增加独立变更片段及合入前版本、CHANGELOG 准备与校验工具。",
7+ "compatibility": "保留其他 GPU 配置与现有组件版本格式,不改变对外接口;正式发布仍需从定版后的固定提交构建。"
8+}
@@ -0,0 +1,69 @@
1+# 组件版本与变更说明
2+ 
3+`mds/service.json.version` 是组件发布版本,关联 `component_drivers/<version>@openubmc/stable`、发布标签和根 `CHANGELOG`,不能用 Meson 版本或 Conan revision 替代。
4+ 
5+开发、评审期间,每个 PR 只维护代码和独立变更片段;排到合入队首后,才根据最新目标基线生成版本和日志,与代码一起提交。禁止先合入未升版代码,再异步补版本。
6+ 
7+当前仅交付仓库侧工具、格式钩子和回归测试,尚未接入社区强制合入队列及发布流水线。维护者须串行定版;本地锁、摘要和钩子不能消除检查到合入之间的竞态,自动接入仍需受保护控制器及目标/源 SHA 条件校验。
8+ 
9+## 开发者:独立片段
10+ 
11+运行 `python3 -c 'import uuid; print(uuid.uuid4())'`,创建 `changes/pending/<小写标准UUID>.json`:
12+ 
13+```json
14+{
15+ "schema": 1,
16+ "component": "component_drivers",
17+ "category": "fix",
18+ "version_action": "increment_patch",
19+ "summary": "修正设备识别。",
20+ "compatibility": "保持现有接口兼容性。"
21+}
22+```
23+ 
24+修订同一 PR 时更新原片段,不重复升版;已归档 UUID 不得重用。字段须与示例一致,摘要、兼容性说明为非空单行。类别支持 `fix/feat/refactor/perf/docs/test/build/ci/chore`,不自动决定大版本或小版本。组件名须与 service.json 一致。
25+ 
26+`python3 scripts/release_metadata.py validate` 只检查格式,允许无片段;加 `--base origin/main` 则验证开发态:有片段、版本与日志未手改、历史归档未变、基线无遗留片段。service.json 的其他业务字段可正常修改。
27+ 
28+本阶段只支持三段非负整数、无多余前导零的版本及 `increment_patch`,例如 `1.2.999 → 1.2.1000`,不把第三段满 999 当作进位。`feat` 也不擅自越过消费者的 `<1.3.0` 约束;兼容性仍须评审。不升版的文档/测试变更、组件更名或不兼容变更走维护者确认的独立流程。
29+ 
30+## 维护者:队首定版
31+ 
32+先取得独占合入资格,把 PR 更新到最新目标基线并整理好业务提交,确保工作区干净。保存开发态及远端旧 SHA,便于重做和使用明确预期 SHA 的 `--force-with-lease` 更新 PR。
33+ 
34+```bash
35+git fetch origin main --tags
36+release_base=$(git rev-parse origin/main)
37+release_source=$(git rev-parse HEAD)
38+release_date=$(date +%F)
39+git update-ref "refs/release-backups/$release_source" "$release_source"
40+python3 scripts/release_metadata.py validate --base "$release_base"
41+python3 scripts/release_metadata.py prepare --base "$release_base" --date "$release_date"
42+python3 scripts/release_metadata.py prepare --base "$release_base" --date "$release_date" --write
43+python3 scripts/release_metadata.py check --base "$release_base"
44+git diff -- mds/service.json CHANGELOG changes/
45+```
46+ 
47+`prepare` 默认只预览;`--write` 才落盘,不暂存、提交、联网、打标签或发布。只替换顶层版本值,保留其他字节与历史日志,按 UUID 排序插入新条目,并把片段原字节移到 `changes/releases/<version>/`。`release.json` 记录基线 SHA、版本、日期、片段及源码摘要,不是签名,也不代表最终合入 SHA。
48+ 
49+审查后只暂存本次生成的文件和片段删除,整理到相应功能提交,保留作者、Change-Id、Signed-off-by 和实际 AI 元数据。重新执行 `check` 和项目测试,推送并等待新 SHA 门禁。合入前再次运行:
50+ 
51+```bash
52+git fetch origin main --tags
53+test "$(git rev-parse origin/main)" = "$release_base"
54+python3 scripts/release_metadata.py check --base origin/main
55+```
56+ 
57+任一步失败就停止合入。相同有效输入重复准备不会再次加版本;基线、源码、日期或归档变化,目标标签占用,HEAD/暂存区与工作树不一致,均须重新核对。生成后基线前进时,保留当前修订,从开发态备份在新基线上重放业务变更,再生成;不要整文件恢复 service.json 而丢掉依赖或选项修改。
58+ 
59+脚本只检查本地标签,不能替代 Conan 版本占用检查。已打标签或上传的版本不可回收、覆盖或挪动标签。发布端须从验证过的固定合入 SHA 构建,核对版本、日志、标签、Conan 引用一致,另行记录最终提交及 recipe/package revision;上传可见性和失败恢复由发布系统管理。
60+ 
61+## 验证
62+ 
63+```bash
64+python3 scripts/tests/test_release_metadata.py -v
65+python3 -m unittest discover -s scripts/tests -p 'test_*.py' -v
66+meson test -C <已配置的builddir> --suite release_metadata --print-errorlogs
67+```
68+ 
69+测试使用私有临时 Git 仓库,覆盖并行片段、连续定版、跨 999、幂等、历史保护和异常拒绝,不等同真实合入竞态、全仓构建或发布验收。受信任 CI 应使用审核过的工具,不能让待合入 PR 自带脚本自行证明安全。
@@ -76,9 +76,9 @@
76 },76 },
77 "bmc.dev.PCIeDevice.PCIeFunction": {77 "bmc.dev.PCIeDevice.PCIeFunction": {
78 "VendorId": "0x10de",78 "VendorId": "0x10de",
79- "DeviceId": "0x2230",79+ "DeviceId": "0x26b1",
80 "SubSystemVendorId": "0x10de",80 "SubSystemVendorId": "0x10de",
81- "SubSystemDeviceId": "0x1459",81+ "SubSystemDeviceId": "0x16a1",
82 "FunctionProtocol": "PCIe",82 "FunctionProtocol": "PCIe",
83 "FunctionType": "Physical"83 "FunctionType": "Physical"
84 },84 },
@@ -0,0 +1,471 @@
1+#!/usr/bin/env python3
2+# SPDX-License-Identifier: MulanPSL-2.0
3+# Copyright (c) 2026 Huawei Technologies Co., Ltd.
4+# openUBMC is licensed under Mulan PSL v2.
5+# You can use this software according to the terms and conditions of the Mulan PSL v2.
6+# You may obtain a copy of Mulan PSL v2 at:
7+# http://license.coscl.org.cn/MulanPSL2
8+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
9+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
10+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
11+# See the Mulan PSL v2 for more details.
12+"""校验独立变更片段,并在已确定的队首基线上生成可核对的发布元数据。
13+ 
14+仅使用 Python 标准库和本地 Git;不抓取远端、不自动提交、合入或发布。
15+调用方负责远端队列互斥,以及提交/发布前重新抓取和核对目标分支。
16+"""
17+ 
18+import argparse
19+from contextlib import contextmanager
20+from datetime import date
21+import difflib
22+import hashlib
23+import json
24+import os
25+from pathlib import Path, PurePosixPath
26+import re
27+import stat
28+import subprocess
29+import sys
30+import uuid
31+ 
32+ 
33+SERVICE = "mds/service.json"
34+CHANGELOG = "CHANGELOG"
35+PENDING = "changes/pending"
36+RELEASES = "changes/releases"
37+COMPONENT = "component_drivers"
38+CATEGORIES = ("fix", "feat", "refactor", "perf", "docs", "test", "build", "ci", "chore")
39+VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z")
40+ 
41+ 
42+class ReleaseError(Exception):
43+ """输入或仓库状态不满足定版前置条件。"""
44+ 
45+ 
46+def require(condition, message):
47+ if not condition:
48+ raise ReleaseError(message)
49+ 
50+ 
51+def strict_json(raw, label):
52+ def object_pairs(pairs):
53+ result = {}
54+ for key, value in pairs:
55+ require(key not in result, f"{label}: JSON 字段重复: {key}")
56+ result[key] = value
57+ return result
58+ 
59+ def invalid_constant(value):
60+ raise ReleaseError(f"{label}: 非法 JSON 数值 {value}")
61+ 
62+ try:
63+ result = json.loads(raw.decode("utf-8"), object_pairs_hook=object_pairs,
64+ parse_constant=invalid_constant)
65+ except (ValueError, UnicodeError) as exc:
66+ raise ReleaseError(f"{label}: 无效 JSON: {exc}") from exc
67+ require(isinstance(result, dict), f"{label}: 必须是 JSON 对象")
68+ return result
69+ 
70+ 
71+def json_bytes(value):
72+ return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
73+ 
74+ 
75+def version_value(raw):
76+ service = strict_json(raw, SERVICE)
77+ require(service.get("name") == COMPONENT, f"{SERVICE}: name 必须为 {COMPONENT},不能更改 Conan 组件身份")
78+ value = service.get("version")
79+ require(isinstance(value, str) and VERSION.fullmatch(value),
80+ f"{SERVICE}: 本阶段仅支持无前导零的 major.minor.patch 数字版本")
81+ return value
82+ 
83+ 
84+def replace_version(raw, version):
85+ """只替换顶层字符串值,保留其他 JSON 字节、格式及嵌套 version。"""
86+ version_value(raw)
87+ text = raw.decode("utf-8")
88+ decoder = json.JSONDecoder()
89+ position = text.index("{") + 1
90+ while True:
91+ position = re.compile(r"\s*").match(text, position).end()
92+ key, position = decoder.raw_decode(text, position)
93+ position = re.compile(r"\s*:\s*").match(text, position).end()
94+ start = position
95+ _, position = decoder.raw_decode(text, position)
96+ if key == "version":
97+ return (text[:start] + json.dumps(version) + text[position:]).encode("utf-8")
98+ position = re.compile(r"\s*,\s*").match(text, position).end()
99+ 
100+ 
101+def valid_date(value):
102+ try:
103+ require(date.fromisoformat(value).isoformat() == value, "日期须为 YYYY-MM-DD")
104+ except (ValueError, TypeError) as exc:
105+ raise ReleaseError("日期须为有效的 YYYY-MM-DD") from exc
106+ return value
107+ 
108+ 
109+def fragment_value(name, raw):
110+ try:
111+ ident = name[:-5]
112+ require(name == f"{uuid.UUID(ident)}.json", f"片段文件名必须是规范 UUID.json: {name}")
113+ except ValueError as exc:
114+ raise ReleaseError(f"片段文件名必须是规范 UUID.json: {name}") from exc
115+ require(len(raw) <= 65536, f"片段过大: {name}")
116+ value = strict_json(raw, name)
117+ fields = {"schema", "component", "category", "version_action", "summary", "compatibility"}
118+ require(set(value) == fields, f"{name}: 字段必须恰好为 {', '.join(sorted(fields))}")
119+ require(type(value["schema"]) is int and value["schema"] == 1, f"{name}: schema 必须为整数 1")
120+ require(value["component"] == COMPONENT, f"{name}: component 必须为 {COMPONENT}")
121+ require(value["category"] in CATEGORIES, f"{name}: 不支持的 category")
122+ require(value["version_action"] == "increment_patch",
123+ f"{name}: 本阶段只支持 increment_patch;minor/major/none 须走独立评审流程")
124+ for field in ("summary", "compatibility"):
125+ text = value[field]
126+ require(isinstance(text, str) and text and text == text.strip()
127+ and all(character.isprintable() for character in text),
128+ f"{name}: {field} 必须为无控制字符的非空单行文本")
129+ return value
130+ 
131+ 
132+class Repository:
133+ def __init__(self, root):
134+ self.root = Path(root).resolve()
135+ top = self.git("rev-parse", "--show-toplevel").decode().strip()
136+ require(Path(top).resolve() == self.root, "--repo 必须指向 Git 工作树根目录")
137+ 
138+ def git(self, *args, data=None, check=True):
139+ result = subprocess.run(["git", "--no-optional-locks", "--literal-pathspecs", "-C", str(self.root), *args],
140+ input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
141+ if check:
142+ require(result.returncode == 0,
143+ "Git 操作失败: " + result.stderr.decode("utf-8", errors="replace").strip())
144+ return result.stdout if check else result
145+ 
146+ def path(self, relative):
147+ parts = PurePosixPath(relative).parts
148+ require(parts and not PurePosixPath(relative).is_absolute() and ".." not in parts,
149+ f"拒绝越界路径: {relative}")
150+ current = self.root
151+ for part in parts:
152+ current = current / part
153+ require(not current.is_symlink(), f"发布元数据禁止使用符号链接: {relative}")
154+ return current
155+ 
156+ def read(self, relative):
157+ path = self.path(relative)
158+ require(path.is_file(), f"缺少普通文件: {relative}")
159+ require(stat.S_IMODE(path.stat().st_mode) & 0o111 == 0,
160+ f"发布元数据不得设置可执行位: {relative}")
161+ return path.read_bytes()
162+ 
163+ def directory(self, relative, recursive=False):
164+ path = self.path(relative)
165+ if not path.exists():
166+ return {}
167+ require(path.is_dir(), f"不是目录: {relative}")
168+ result = {}
169+ for child in sorted(path.iterdir()):
170+ name = child.relative_to(self.root).as_posix()
171+ self.path(name)
172+ if recursive and child.is_dir():
173+ result.update(self.directory(name, recursive=True))
174+ else:
175+ result[name] = self.read(name)
176+ return result
177+ 
178+ def pending(self, required=True):
179+ files = self.directory(PENDING)
180+ if required:
181+ require(files, "没有待定版片段;不能生成空版本")
182+ for path, raw in files.items():
183+ fragment_value(PurePosixPath(path).name, raw)
184+ return files
185+ 
186+ def resolve(self, ref):
187+ require(ref and not ref.startswith("-"), "无效基线引用")
188+ return self.git("rev-parse", "--verify", f"{ref}^{{commit}}").decode().strip()
189+ 
190+ def tree(self, sha):
191+ result = {}
192+ output = self.git("ls-tree", "-r", "-z", "--full-tree", sha, "--",
193+ SERVICE, CHANGELOG, PENDING, RELEASES)
194+ for entry in output.split(b"\0"):
195+ if entry:
196+ info, path = entry.split(b"\t", 1)
197+ mode, kind, oid = info.split()
198+ require(mode == b"100644" and kind == b"blob",
199+ "基线发布元数据必须为非可执行普通文件")
200+ result[path.decode("utf-8")] = self.git("cat-file", "blob", oid.decode())
201+ return result
202+ 
203+ def source_digest(self, base_version):
204+ """非忽略源码的路径、Git 文件模式和内容摘要;自动元数据另行逐字核对。"""
205+ entries = self.git("ls-files", "-z", "--cached", "--others", "--exclude-standard")
206+ digest = hashlib.sha256()
207+ for name in sorted(set(entries.split(b"\0")) - {b""}):
208+ relative = os.fsdecode(name)
209+ if relative == CHANGELOG or relative.startswith((PENDING + "/", RELEASES + "/")):
210+ continue
211+ path = self.root / relative
212+ # 不跟随目录链接;源码文件本身的链接以链接目标文本参与哈希。
213+ current = self.root
214+ for part in PurePosixPath(relative).parts[:-1]:
215+ current /= part
216+ require(not current.is_symlink(), f"源码父目录是符号链接: {relative}")
217+ metadata = path.lstat()
218+ if stat.S_ISLNK(metadata.st_mode):
219+ mode, raw = b"120000", os.fsencode(os.readlink(path))
220+ else:
221+ require(stat.S_ISREG(metadata.st_mode), f"暂不支持子模块或特殊源码文件: {relative}")
222+ mode = b"100755" if metadata.st_mode & 0o111 else b"100644"
223+ raw = path.read_bytes()
224+ if relative == SERVICE:
225+ raw = replace_version(raw, base_version)
226+ for value in (name, mode, hashlib.sha256(raw).digest()):
227+ digest.update(len(value).to_bytes(8, "big"))
228+ digest.update(value)
229+ return digest.hexdigest()
230+ 
231+ def check_staged_content(self):
232+ """允许未暂存/已暂存的生成结果,但不能把另一份内容误认为可提交结果。"""
233+ require(not self.git("ls-files", "--unmerged", "-z"), "暂存区存在未解决的合并冲突")
234+ changed = self.git("diff", "--cached", "--no-renames", "--name-only", "-z").split(b"\0")
235+ for name in filter(None, changed):
236+ relative = os.fsdecode(name)
237+ path = self.root / relative
238+ entries = self.git("ls-files", "--stage", "-z", "--", relative).split(b"\0")
239+ entries = list(filter(None, entries))
240+ if not entries:
241+ require(not path.exists() and not path.is_symlink(), f"暂存删除与工作树不一致: {relative}")
242+ continue
243+ require(len(entries) == 1, f"暂存区含未解决的路径: {relative}")
244+ mode, oid, _ = entries[0].split(b"\t", 1)[0].split()
245+ if path.is_symlink():
246+ actual_mode, raw = b"120000", os.fsencode(os.readlink(path))
247+ else:
248+ require(path.is_file(), f"暂存内容与工作树不一致: {relative}")
249+ actual_mode = b"100755" if path.stat().st_mode & 0o111 else b"100644"
250+ raw = path.read_bytes()
251+ require(mode == actual_mode and raw == self.git("cat-file", "blob", oid.decode()),
252+ f"暂存内容与工作树不一致: {relative}")
253+ 
254+ def check_committed_source(self, base_version):
255+ changed = self.git("diff", "--no-ext-diff", "--no-renames", "--name-only", "-z", "HEAD", "--")
256+ for name in filter(None, changed.split(b"\0")):
257+ relative = os.fsdecode(name)
258+ if relative == CHANGELOG or relative.startswith((PENDING + "/", RELEASES + "/")):
259+ continue
260+ if relative == SERVICE:
261+ committed = self.git("show", "HEAD:" + SERVICE)
262+ require(replace_version(committed, base_version) == replace_version(self.read(SERVICE), base_version),
263+ "service.json 业务字段与 HEAD 不一致;不能用工作树覆盖已提交源码")
264+ else:
265+ raise ReleaseError(f"源码与 HEAD 不一致;不能用工作树覆盖已提交源码: {relative}")
266+ 
267+ @contextmanager
268+ def write_lock(self):
269+ # 该锁只保护此工作树中的本工具进程,不能替代远端合并队列。
270+ lock = self.git("rev-parse", "--git-path", "release-metadata.lock").decode().strip()
271+ lock_path = Path(lock) if Path(lock).is_absolute() else self.root / lock
272+ try:
273+ descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
274+ except FileExistsError as exc:
275+ raise ReleaseError("已有本地定版进程或遗留锁;先核实其状态") from exc
atomgit-bot
atomgit-botatomgit-bot6 days ago

🔵 Low Priority

改动行(scripts/release_metadata.py:273-275)用 os.O_CREAT | os.O_EXCL 在 .git/release-metadata.lock 建锁,异常路径下才由 finally: lock_path.unlink() 释放。

触发条件:prepare --write 在持锁期间被 SIGKILL、断电、或容器被强制回收(finally 不执行),锁文件残留。

失败模式:此后每次 prepare --write 都在 except FileExistsError 处直接报“已有本地定版进程或遗留锁;先核实其状态”并 exit 1,即定版/发布流程被永久阻塞,直到有人手工删除 .git/release-metadata.lock。错误信息只提示“核实状态”,工具本身没有任何手段区分“另一个进程正在运行”与“僵尸锁”,也没有 PID/时间戳可判断,运维人员可能误删正在运行进程的锁。

建议:建议在锁文件内写入 PID 与创建时间;捕获 FileExistsError 时读取并校验 PID 是否存活,对僵尸锁允许接管(或提供显式 --force-unlock 参数),避免异常退出后发布流程被永久阻塞。

likedislike
Incorrect?
276+ try:
277+ os.close(descriptor)
278+ yield
279+ finally:
280+ lock_path.unlink()
281+ 
282+ 
283+class ReleasePlan:
284+ def __init__(self, repo, base_ref):
285+ self.repo = repo
286+ self.base_ref = base_ref
287+ self.base_sha = repo.resolve(base_ref)
288+ require(repo.git("merge-base", "--is-ancestor", self.base_sha, "HEAD", check=False).returncode == 0,
289+ "基线必须是当前 HEAD 的祖先;请先更新分支")
290+ self.base = repo.tree(self.base_sha)
291+ require(SERVICE in self.base and CHANGELOG in self.base, "基线缺少 service.json 或 CHANGELOG")
292+ require(not any(path.startswith(PENDING + "/") or path == PENDING for path in self.base),
293+ "基线含未定版片段,不能把其他未发布源码归入本 PR")
294+ self.base_version = version_value(self.base[SERVICE])
295+ major, minor, patch = self.base_version.split(".")
296+ self.version = f"{major}.{minor}.{int(patch) + 1}"
297+ self.prefix = f"{RELEASES}/{self.version}/"
298+ self.receipt_path = self.prefix + "release.json"
299+ self.old_archives = {path: raw for path, raw in self.base.items() if path.startswith(RELEASES + "/")}
300+ require(not any(path.startswith(self.prefix) for path in self.old_archives),
301+ "目标版本已有正式归档,不得重用")
302+ self.archives = repo.directory(RELEASES, recursive=True)
303+ for path, raw in self.old_archives.items():
304+ require(self.archives.get(path) == raw, f"历史归档被修改或删除: {path}")
305+ self.new_archives = {path: raw for path, raw in self.archives.items() if path not in self.old_archives}
306+ self.service = repo.read(SERVICE)
307+ self.changelog = repo.read(CHANGELOG)
308+ self.check_base_changelog()
309+ 
310+ def check_base_changelog(self):
311+ history = self.base[CHANGELOG]
312+ history.decode("utf-8")
313+ match = re.search(rb"^## \[([^\]\r\n]+)\] - \d{4}-\d{2}-\d{2}\r?$", history, re.MULTILINE)
314+ require(match and match.group(1).decode() == self.base_version,
315+ "基线 CHANGELOG 首个版本与 service.version 不一致")
316+ self.insertion = match.start()
317+ 
318+ def development(self):
319+ require(version_value(self.service) == self.base_version,
320+ "开发态 service.version 已被手工修改;请还原为目标基线版本")
321+ require(self.changelog == self.base[CHANGELOG], "开发态 CHANGELOG 已被修改;请改用独立片段")
322+ require(not self.new_archives, "开发态出现未经核对的归档,不得覆盖")
323+ fragments = self.repo.pending()
324+ self.reject_consumed_fragments(fragments)
325+ return fragments
326+ 
327+ def reject_consumed_fragments(self, fragments):
328+ consumed = {PurePosixPath(path).name for path in self.old_archives}
329+ for path in fragments:
330+ name = PurePosixPath(path).name
331+ require(name not in consumed, f"片段 UUID 已被历史版本消费,禁止重复归档: {name}")
332+ 
333+ def generated(self, fragments, release_date):
334+ self.reject_consumed_fragments(fragments)
335+ new_log = [f"## [{self.version}] - {release_date}\n"]
336+ hashes = []
337+ files = {}
338+ for path, raw in sorted(fragments.items()):
339+ name = PurePosixPath(path).name
340+ value = fragment_value(name, raw)
341+ new_log.extend([f"- {value['category']}: {value['summary']}\n",
342+ f" - 兼容性:{value['compatibility']}\n"])
343+ hashes.append({"id": name[:-5], "sha256": hashlib.sha256(raw).hexdigest()})
344+ files[self.prefix + name] = raw
345+ new_log.append("\n")
346+ history = self.base[CHANGELOG]
347+ files[CHANGELOG] = history[:self.insertion] + "".join(new_log).encode("utf-8") + history[self.insertion:]
348+ files[SERVICE] = replace_version(self.service, self.version)
349+ receipt = {"schema": 1, "component": COMPONENT, "base_sha": self.base_sha,
350+ "base_version": self.base_version, "version": self.version, "date": release_date,
351+ "source_sha256": self.repo.source_digest(self.base_version), "fragments": hashes}
352+ files[self.receipt_path] = json_bytes(receipt)
353+ return files
354+ 
355+ def check(self):
356+ self.reject_tags()
357+ self.repo.check_staged_content()
358+ self.repo.check_committed_source(self.base_version)
359+ require(not self.repo.pending(required=False), "已定版状态不得残留 pending 片段")
360+ require(self.receipt_path in self.new_archives, "缺少与当前基线对应的新 release.json;需重新定版")
361+ receipt = strict_json(self.new_archives[self.receipt_path], self.receipt_path)
362+ require(receipt.get("base_sha") == self.base_sha, "基线已前进或 receipt 基线不符;旧计划失效")
363+ release_date = valid_date(receipt.get("date"))
364+ require(version_value(self.service) == self.version, "定版版本与基线递增结果不一致")
365+ fragments = {path: raw for path, raw in self.new_archives.items()
366+ if path.startswith(self.prefix) and path != self.receipt_path}
367+ require(fragments, "定版归档不能没有片段")
368+ require(all(PurePosixPath(path).parent.as_posix() == self.prefix.rstrip("/") for path in fragments),
369+ "归档片段必须直接位于版本目录")
370+ files = self.generated(fragments, release_date)
371+ expected_archives = {path: raw for path, raw in files.items() if path.startswith(self.prefix)}
372+ require(self.new_archives == expected_archives,
373+ "归档、片段摘要或源码摘要与定版计划不符;需重新定版")
374+ require(self.changelog == files[CHANGELOG], "CHANGELOG 条目或历史与定版计划不一致")
375+ require(self.service == files[SERVICE], "service.json 与定版计划不一致")
376+ require(self.repo.resolve(self.base_ref) == self.base_sha, "检查期间基线引用已变化")
377+ return release_date
378+ 
379+ def reject_tags(self):
380+ tags = set(self.repo.git("tag", "--list").decode().splitlines())
381+ require(not tags.intersection((self.version, "v" + self.version)),
382+ "目标版本已有本地 tag,不得重用;请先核对远端发布状态")
383+ 
384+ def changes(self, release_date):
385+ fragments = self.development()
386+ self.reject_tags()
387+ files = self.generated(fragments, release_date)
388+ result = {SERVICE: (self.service, files.pop(SERVICE)),
389+ CHANGELOG: (self.changelog, files.pop(CHANGELOG))}
390+ result.update({path: (raw, None) for path, raw in fragments.items()})
391+ for path, raw in files.items():
392+ require(not self.repo.path(path).exists(), f"目标路径已存在,拒绝覆盖: {path}")
393+ result[path] = (None, raw)
394+ return result
395+ 
396+ 
397+def patch_bytes(changes):
398+ patch = bytearray()
399+ for path, (before, after) in sorted(changes.items()):
400+ patch.extend(f"diff --git a/{path} b/{path}\n".encode())
401+ if before is None:
402+ patch.extend(b"new file mode 100644\n")
403+ elif after is None:
404+ patch.extend(b"deleted file mode 100644\n")
405+ old_name = b"/dev/null" if before is None else ("a/" + path).encode()
406+ new_name = b"/dev/null" if after is None else ("b/" + path).encode()
407+ lines = difflib.diff_bytes(difflib.unified_diff, (before or b"").splitlines(keepends=True),
408+ (after or b"").splitlines(keepends=True), old_name, new_name)
409+ for line in lines:
410+ patch.extend(line)
411+ if not line.endswith(b"\n"):
412+ patch.extend(b"\n\\n")
413+ return bytes(patch)
414+ 
415+ 
416+def prepare(repo, args):
417+ release_date = valid_date(args.date)
418+ plan = ReleasePlan(repo, args.base)
419+ if plan.receipt_path in plan.new_archives:
420+ require(plan.check() == release_date, "已定版日期不同;不得静默重写历史")
421+ print(f"已准备 {plan.version},内容及基线一致,无需修改。")
422+ return
423+ changes = plan.changes(release_date)
424+ patch = patch_bytes(changes)
425+ if not args.write:
426+ print(f"预览:{plan.base_version} -> {plan.version};基线 {plan.base_sha};未写入文件。", flush=True)
427+ sys.stdout.buffer.write(patch)
428+ return
429+ require(not repo.git("status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"),
430+ "--write 仅接受干净、已提交的工作树;请先审查并提交源码与片段")
431+ repo.git("apply", "--check", "--whitespace=nowarn", "-", data=patch)
432+ # 全量预检成功后再次核对基线与输入;git apply 默认不允许部分应用。
433+ require(repo.resolve(args.base) == plan.base_sha, "预检期间基线引用已变化")
434+ require(ReleasePlan(repo, args.base).changes(release_date) == changes, "预检期间输入发生变化")
435+ repo.git("apply", "--whitespace=nowarn", "-", data=patch)
436+ print(f"已生成 {plan.version};未暂存、未提交。请审查差异并在提交后运行 check。")
437+ 
438+ 
439+def main(argv=None):
440+ parser = argparse.ArgumentParser(description=__doc__)
441+ commands = parser.add_subparsers(dest="command", required=True)
442+ for command in ("validate", "prepare", "check"):
443+ child = commands.add_parser(command)
444+ child.add_argument("--repo", default=str(Path(__file__).resolve().parents[1]), help="Git 工作树根目录")
445+ child.add_argument("--base", required=command != "validate", help="已抓取并确认的目标基线引用")
446+ if command == "prepare":
447+ child.add_argument("--date", required=True, help="本次发布日期 YYYY-MM-DD")
448+ child.add_argument("--write", action="store_true", help="全量预检后写入;默认仅预览")
449+ args = parser.parse_args(argv)
450+ try:
451+ repo = Repository(args.repo)
452+ if args.command == "validate":
453+ fragments = ReleasePlan(repo, args.base).development() if args.base else repo.pending(required=False)
454+ print(f"校验通过:{len(fragments)} 个待定版片段。")
455+ elif args.command == "check":
456+ plan = ReleasePlan(repo, args.base)
457+ plan.check()
458+ print(f"定版校验通过:{plan.version},基线 {plan.base_sha}。")
459+ elif args.write:
460+ with repo.write_lock():
461+ prepare(repo, args)
462+ else:
463+ prepare(repo, args)
464+ return 0
465+ except (ReleaseError, OSError, UnicodeError, ValueError) as exc:
466+ print(f"错误:{exc}", file=sys.stderr)
467+ return 1
468+ 
469+ 
470+if __name__ == "__main__":
471+ sys.exit(main())
@@ -0,0 +1,325 @@
1+#!/usr/bin/env python3
2+# SPDX-License-Identifier: MulanPSL-2.0
3+# Copyright (c) 2026 Huawei Technologies Co., Ltd.
4+# openUBMC is licensed under Mulan PSL v2.
5+# You can use this software according to the terms and conditions of the Mulan PSL v2.
6+# You may obtain a copy of Mulan PSL v2 at:
7+# http://license.coscl.org.cn/MulanPSL2
8+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
9+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
10+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
11+# See the Mulan PSL v2 for more details.
12+"""在临时 Git 仓库验证定版行为;不联网、不构建、不发布。"""
13+ 
14+import json
15+import os
16+from pathlib import Path
17+import shutil
18+import subprocess
19+import sys
20+import tempfile
21+import unittest
22+ 
23+ 
24+SCRIPT = Path(__file__).resolve().parents[1] / "release_metadata.py"
25+FIRST = "23f7e985-c120-4b17-a55c-229ca35da638"
26+SECOND = "687257bc-b4ac-4747-94b2-ff4e52c33913"
27+HISTORY = ("# Changelog\nAll notable changes to this project will be documented in this file.\n"
28+ "## [1.2.999] - 2026-10-30\n- fix: 保留原始历史\n\n").encode()
29+SERVICE = (b'{\n "name": "component_drivers",\n "version" : "1.2.999",\n'
30+ b' "options": {"version": "nested", "enabled": false}\n}\n')
31+ 
32+ 
33+@unittest.skipUnless(shutil.which("git"), "requires Git")
34+class ReleaseMetadataTests(unittest.TestCase):
35+ def setUp(self):
36+ self.temp = tempfile.TemporaryDirectory(prefix="release-metadata-test-")
37+ self.addCleanup(self.temp.cleanup)
38+ self.repo = Path(self.temp.name) / "repository"
39+ self.repo.mkdir()
40+ self.env = os.environ.copy()
41+ for name in ("GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE",
42+ "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"):
43+ self.env.pop(name, None)
44+ self.env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull,
45+ GIT_AUTHOR_NAME="Release test", GIT_AUTHOR_EMAIL="test@example.invalid",
46+ GIT_COMMITTER_NAME="Release test", GIT_COMMITTER_EMAIL="test@example.invalid")
47+ self.git("init", "-q")
48+ self.write("mds/service.json", SERVICE)
49+ self.write("CHANGELOG", HISTORY)
50+ self.write("source.c", b"int device = 1;\n")
51+ self.commit(baseline=True)
52+ 
53+ def git(self, *args):
54+ result = subprocess.run(["git", "-C", str(self.repo), *args], env=self.env,
55+ capture_output=True, text=True, timeout=20)
56+ self.assertEqual(result.returncode, 0, result.stderr)
57+ return result
58+ 
59+ def write(self, relative, data):
60+ path = self.repo / relative
61+ path.parent.mkdir(parents=True, exist_ok=True)
62+ path.write_bytes(data)
63+ return path
64+ 
65+ def commit(self, fragment=None, baseline=False, prepare=False):
66+ if fragment:
67+ self.fragment(fragment)
68+ self.git("add", "-A")
69+ self.git("commit", "-q", "-m", "测试提交")
70+ if baseline:
71+ self.git("branch", "-f", "baseline", "HEAD")
72+ if prepare:
73+ self.prepare("--write")
74+ 
75+ def fragment(self, ident=FIRST, **overrides):
76+ value = dict(schema=1, component="component_drivers", category="fix",
77+ version_action="increment_patch", summary="修正设备身份",
78+ compatibility="保留现有接口")
79+ value.update(overrides)
80+ return self.write(f"changes/pending/{ident}.json",
81+ (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode())
82+ 
83+ def run_tool(self, command, *args, success=True):
84+ before = self.snapshot()
85+ result = subprocess.run([sys.executable, str(SCRIPT), command,
86+ "--repo", str(self.repo), *args], env=self.env,
87+ capture_output=True, text=True, timeout=30)
88+ self.assertEqual(result.returncode, 0 if success else 1, result.stdout + result.stderr)
89+ if not success or command != "prepare" or "--write" not in args:
90+ self.assertEqual(self.snapshot(), before, "拒绝、预览和校验操作不能改写文件")
91+ return result
92+ 
93+ def prepare(self, *args, success=True):
94+ return self.run_tool("prepare", "--base", "baseline", "--date", "2026-11-01",
95+ *args, success=success)
96+ 
97+ def snapshot(self):
98+ return {str(p.relative_to(self.repo)): ("link", os.readlink(p)) if p.is_symlink()
99+ else ("file", p.read_bytes(), p.stat().st_mode & 0o777)
100+ for p in self.repo.rglob("*")
101+ if ".git" not in p.relative_to(self.repo).parts and (p.is_file() or p.is_symlink())}
102+ 
103+ def assert_rejected_without_write(self, *args):
104+ self.prepare("--write", *args, success=False)
105+ 
106+ def test_prepare_lifecycle_preserves_bytes_and_is_idempotent(self):
107+ for final_newline in (True, False):
108+ with self.subTest(final_newline=final_newline):
109+ self.setUp() # 每个子用例使用独立仓库,避免前一次定版污染输入。
atomgit-bot
atomgit-botatomgit-bot6 days ago

🔵 Low Priority

改动行(scripts/tests/test_release_metadata.py:109)在测试方法内直接调用 self.setUp() 以获得一个干净的临时仓库:

for final_newline in (True, False): with self.subTest(final_newline=final_newline): self.setUp() # 每个子用例使用独立仓库

问题:setUp() 不是可重入的初始化函数,每次调用都会 self.addCleanup(self.temp.cleanup)、重新 os.environ.copy() 并重建 self.repo/self.env。症状是:一次测试内注册 2-3 份 cleanup、残留多个临时目录直到 teardown;后续若在 setUp 中增加断言、类级状态或不可重入资源(共享 fixture、端口、全局缓存),本用例会以难以定位的方式失败而其它用例正常。

建议:将 setUp 中的仓库初始化抽成可重复调用的 _init_repository() 辅助方法,测试内调用它而不是 self.setUp(),避免重复注册 addCleanup 与重复复制环境变量;如需立即清理旧临时目录,可在重置前调用 self.doCleanups()。

likedislike
Incorrect?
110+ service = SERVICE if final_newline else SERVICE.rstrip(b"\n")
111+ history = HISTORY if final_newline else HISTORY.rstrip(b"\n")
112+ self.write("mds/service.json", service)
113+ self.write("CHANGELOG", history)
114+ if not final_newline:
115+ self.commit(baseline=True)
116+ business = service.replace(b"false", b"true")
117+ self.write("mds/service.json", business)
118+ fragment = self.fragment()
119+ if not final_newline:
120+ fragment.write_bytes(fragment.read_bytes().rstrip(b"\n"))
121+ fragment_bytes = fragment.read_bytes()
122+ self.commit()
123+ preview = self.prepare().stdout
124+ self.assertIn("1.2.1000", preview)
125+ self.assertIn("diff --git", preview)
126+ self.assertEqual(self.git("status", "--porcelain").stdout, "")
127+ self.prepare("--write")
128+ self.run_tool("validate")
129+ self.assertEqual((self.repo / "mds/service.json").read_bytes(),
130+ business.replace(b"1.2.999", b"1.2.1000"))
131+ entry = ("## [1.2.1000] - 2026-11-01\n- fix: 修正设备身份\n"
132+ " - 兼容性:保留现有接口\n\n").encode()
133+ insertion = history.index(b"## [")
134+ self.assertEqual((self.repo / "CHANGELOG").read_bytes(),
135+ history[:insertion] + entry + history[insertion:])
136+ archive = self.repo / "changes/releases/1.2.1000"
137+ self.assertFalse(fragment.exists())
138+ self.assertEqual((archive / f"{FIRST}.json").read_bytes(), fragment_bytes)
139+ receipt = json.loads((archive / "release.json").read_bytes())
140+ self.assertEqual([receipt[k] for k in ("base_version", "version", "base_sha")],
141+ ["1.2.999", "1.2.1000", self.git("rev-parse", "baseline").stdout.strip()])
142+ self.assertEqual(self.git("diff", "--cached", "--name-only").stdout, "")
143+ self.run_tool("check", "--base", "baseline")
144+ before = self.snapshot()
145+ self.prepare("--write")
146+ self.assertEqual(self.snapshot(), before)
147+ self.prepare("--date", "2026-11-02", "--write", success=False)
148+ self.commit()
149+ self.run_tool("check", "--base", "baseline")
150+ 
151+ def test_parallel_fragment_branches_merge_without_metadata_conflict(self):
152+ self.git("checkout", "-q", "-b", "feature-a")
153+ self.commit(fragment=FIRST)
154+ self.git("checkout", "-q", "-b", "feature-b", "baseline")
155+ self.fragment(SECOND, category="feat", summary="新增器件")
156+ self.commit()
157+ self.git("merge", "--no-edit", "feature-a")
158+ self.run_tool("validate", "--base", "baseline")
159+ self.assertEqual((self.repo / "mds/service.json").read_bytes(), SERVICE)
160+ self.assertEqual((self.repo / "CHANGELOG").read_bytes(), HISTORY)
161+ self.prepare("--write")
162+ changelog = (self.repo / "CHANGELOG").read_text()
163+ self.assertLess(changelog.index("修正设备身份"), changelog.index("新增器件"))
164+ 
165+ def test_changed_source_index_head_or_base_invalidates_receipt(self):
166+ cases = ("worktree", "committed", "untracked", "index_mask", "head_mask", "base")
167+ for case in cases:
168+ with self.subTest(case=case):
169+ self.setUp()
170+ self.commit(fragment=FIRST, prepare=True)
171+ self.commit(baseline=case == "base")
172+ if case != "base":
173+ path = "unexpected.c" if case == "untracked" else "source.c"
174+ self.write(path, b"int device = 2;\n")
175+ if case in ("committed", "head_mask"):
176+ self.commit()
177+ elif case == "index_mask":
178+ self.git("add", path)
179+ if case.endswith("_mask"):
180+ self.write(path, b"int device = 1;\n")
181+ if case == "head_mask":
182+ self.assertEqual(self.git("diff", "--cached", "--name-only").stdout, "")
183+ self.run_tool("check", "--base", "baseline", success=False)
184+ 
185+ def test_tag_collision_refuses_to_reuse_a_number(self):
186+ self.commit(fragment=FIRST)
187+ for tag in ("1.2.1000", "v1.2.1000"):
188+ with self.subTest(tag=tag):
189+ self.git("tag", tag)
190+ self.assert_rejected_without_write()
191+ self.git("tag", "-d", tag)
192+ 
193+ def test_tag_created_after_preparation_invalidates_check_and_repeat(self):
194+ self.commit(fragment=FIRST, prepare=True)
195+ self.commit()
196+ self.git("tag", "1.2.1000")
197+ self.run_tool("check", "--base", "baseline", success=False)
198+ self.assert_rejected_without_write()
199+ 
200+ def test_invalid_development_inputs_are_not_corrected_or_written(self):
201+ cases = [("version", "mds/service.json", SERVICE.replace(b"1.2.999", b"1.2.1000")),
202+ ("history", "CHANGELOG", HISTORY + b"- hand edit\n"),
203+ ("renamed", "mds/service.json", SERVICE.replace(b"component_drivers", b"other_component")),
204+ ("unnamed", "mds/service.json", SERVICE.replace(b' "name": "component_drivers",\n', b"")),
205+ ("archive", "changes/releases/1.2.1000/release.json", b"{}\n"),
206+ ("dirty", "source.c", b"int device = 9;\n")]
207+ for case, path, raw in cases:
208+ with self.subTest(case=case):
209+ self.setUp()
210+ self.commit(fragment=FIRST)
211+ self.write(path, raw)
212+ if case != "dirty":
213+ self.commit()
214+ self.run_tool("validate", "--base", "baseline", success=False)
215+ self.prepare(success=False)
216+ self.assert_rejected_without_write()
217+ 
218+ def test_archive_history_uuid_consumption_and_next_queued_release(self):
219+ self.commit(fragment=FIRST, prepare=True)
220+ self.commit(baseline=True)
221+ self.write(f"changes/releases/1.2.1000/{FIRST}.json", b"{}\n")
222+ self.commit(fragment=SECOND)
223+ self.run_tool("validate", "--base", "baseline", success=False)
224+ self.assert_rejected_without_write()
225+ for summary in ("修正设备身份", "不同的修复"):
226+ with self.subTest(replayed_summary=summary):
227+ self.git("reset", "--hard", "baseline")
228+ self.fragment(summary=summary)
229+ self.commit()
230+ self.prepare(success=False)
231+ self.assert_rejected_without_write()
232+ self.git("reset", "--hard", "baseline")
233+ self.commit(fragment=SECOND, prepare=True)
234+ self.assertEqual(json.loads((self.repo / "mds/service.json").read_bytes())["version"], "1.2.1001")
235+ for version in ("1.2.1000", "1.2.1001"):
236+ self.assertTrue((self.repo / f"changes/releases/{version}/release.json").exists())
237+ self.run_tool("check", "--base", "baseline")
238+ archive = self.repo / "changes/releases/1.2.1001"
239+ (archive / f"{SECOND}.json").rename(archive / f"{FIRST}.json")
240+ receipt = archive / "release.json"
241+ receipt.write_bytes(receipt.read_bytes().replace(SECOND.encode(), FIRST.encode()))
242+ self.run_tool("check", "--base", "baseline", success=False)
243+ 
244+ def test_baseline_identity_version_pending_ancestry_and_date_requirements(self):
245+ self.run_tool("validate")
246+ self.run_tool("validate", "--base", "baseline", success=False)
247+ self.assert_rejected_without_write()
248+ for case in ("component", "version", "pending", "unrelated", "date"):
249+ with self.subTest(case=case):
250+ self.setUp()
251+ if case in ("component", "version"):
252+ old, new = ((b"component_drivers", b"other_component") if case == "component"
253+ else (b"1.2.999", b"1.02.999"))
254+ self.write("mds/service.json", SERVICE.replace(old, new))
255+ self.write("CHANGELOG", HISTORY.replace(old, new))
256+ self.commit(baseline=True)
257+ if case == "component":
258+ self.write("mds/service.json", SERVICE)
259+ elif case == "pending":
260+ self.commit(fragment=FIRST, baseline=True)
261+ self.commit(fragment=SECOND)
262+ if case == "unrelated":
263+ self.git("checkout", "-q", "-b", "unrelated", "baseline")
264+ self.write("unrelated.c", b"int unrelated;\n")
265+ self.commit()
266+ self.git("checkout", "-q", "-")
267+ if case not in ("unrelated", "date"):
268+ self.run_tool("validate", "--base", "baseline", success=False)
269+ args = ("--base", "unrelated") if case == "unrelated" else ()
270+ if case == "date":
271+ args = ("--date", "2026-02-30")
272+ self.assert_rejected_without_write(*args)
273+ 
274+ def test_fragment_schema_rejects_invalid_fields_and_unsupported_actions(self):
275+ cases = [dict(schema=True), dict(schema=2), dict(component="other"), dict(category="unknown"),
276+ dict(version_action="increment_minor"), dict(version_action="none"),
277+ dict(summary=""), dict(summary="line\ninjection"), dict(compatibility=""),
278+ dict(unknown="field")]
279+ for fields in cases:
280+ with self.subTest(fields=fields):
281+ self.fragment(**fields)
282+ self.run_tool("validate", success=False)
283+ self.fragment()
284+ self.run_tool("validate")
285+ 
286+ def test_duplicate_json_keys_and_invalid_uuid_are_rejected(self):
287+ fragment = self.fragment()
288+ original = fragment.read_bytes()
289+ fragment.write_bytes(original.replace(b'"schema": 1', b'"schema": 1, "schema": 1'))
290+ self.run_tool("validate", success=False)
291+ fragment.write_bytes(original)
292+ fragment.rename(fragment.with_name("not-a-uuid.json"))
293+ self.run_tool("validate", success=False)
294+ 
295+ def test_metadata_symlinks_cannot_escape_or_modify_external_files(self):
296+ for relative in ("changes/pending", f"changes/pending/{FIRST}.json", "mds/service.json"):
297+ with self.subTest(path=relative):
298+ self.setUp()
299+ self.fragment()
300+ path = self.repo / relative
301+ outside = Path(self.temp.name) / "outside"
302+ path.rename(outside)
303+ path.symlink_to(outside, target_is_directory=outside.is_dir())
304+ external_file = outside / f"{FIRST}.json" if outside.is_dir() else outside
305+ saved = external_file.read_bytes()
306+ self.commit()
307+ self.assert_rejected_without_write()
308+ self.assertEqual(external_file.read_bytes(), saved)
309+ 
310+ def test_tampering_generated_metadata_is_rejected(self):
311+ self.commit(fragment=FIRST, prepare=True)
312+ original = self.snapshot()
313+ for relative, data in (("CHANGELOG", HISTORY),
314+ ("changes/releases/1.2.1000/release.json", b"{}\n"),
315+ (f"changes/releases/1.2.1000/{FIRST}.json", b"{}\n")):
316+ with self.subTest(path=relative):
317+ before = (self.repo / relative).read_bytes()
318+ self.write(relative, data)
319+ self.run_tool("check", "--base", "baseline", success=False)
320+ self.write(relative, before)
321+ self.assertEqual(self.snapshot(), original)
322+ 
323+ 
324+if __name__ == "__main__":
325+ unittest.main()
@@ -1,2 +1,10 @@
1subdir('interface')1subdir('interface')
2subdir('nv_common')2subdir('nv_common')
3+ 
4+test(
5+ 'test_nvidia_csr_identity',
6+ find_program('python3'),
7+ args: files('test_csr_identity.py'),
8+ timeout: 30,
9+ suite: 'pcie_gpu_card',
10+)
@@ -0,0 +1,107 @@
1+#!/usr/bin/env python3
2+# Copyright (c) 2026 Huawei Technologies Co., Ltd.
3+# openUBMC is licensed under Mulan PSL v2.
4+# You can use this software according to the terms and conditions of the Mulan PSL v2.
5+# You may obtain a copy of Mulan PSL v2 at:
6+# http://license.coscl.org.cn/MulanPSL2
7+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
8+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
9+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
10+# See the Mulan PSL v2 for more details.
11+# SPDX-License-Identifier: MulanPSL-2.0
12+"""校验 NVIDIA CSR 的文件名、DDS 加载身份与 SR 公开四元组。"""
13+ 
14+import json
15+from pathlib import Path
16+import unittest
17+ 
18+ 
19+def find_csr_dir():
20+ test_path = Path(__file__).resolve()
21+ relative = Path("drivers/pcie_gpu_card/nvidia/csr")
22+ for parent in test_path.parents:
23+ candidate = parent / relative
24+ if ((parent / "meson.build").is_file()
25+ and (parent / "mds/service.json").is_file() and candidate.is_dir()):
26+ return candidate
27+ raise RuntimeError(
28+ f"无法定位NVIDIA CSR源码目录:测试文件={test_path};"
29+ f"向上查找同时包含meson.build、mds/service.json和{relative}的源码根失败"
30+ )
31+ 
32+ 
33+CSR_DIR = find_csr_dir()
34+FUNCTION_INTERFACE = "bmc.dev.PCIeDevice.PCIeFunction"
35+IDENTITY_FIELDS = ("VendorId", "DeviceId", "SubSystemVendorId", "SubSystemDeviceId")
36+# 既有 V100 配置同时遗留 1db6/124a;本次不扩展产品修复范围。
37+# 精确登记当前值:增加其他差异或修正该配置都须更新此登记,不能静默跳过。
38+KNOWN_DEVIATIONS = {
39+ "14140130_10de1db4_10de1214.sr": (0x10de, 0x1db6, 0x10de, 0x124a),
40+}
41+ 
42+ 
43+class NvidiaCsrIdentityTests(unittest.TestCase):
44+ def setUp(self):
45+ self.sr_paths = sorted(CSR_DIR.glob("*.sr"))
46+ self.assertTrue(self.sr_paths, "NVIDIA CSR 目录必须包含 SR 文件")
47+ 
48+ def read_identity(self, path):
49+ with path.with_suffix(".dds").open(encoding="utf-8") as stream:
50+ dds = json.load(stream)
51+ self.assertIsInstance(dds, dict, "DDS 根节点必须是对象")
52+ identity = dds.get("ID")
53+ self.assertIsInstance(identity, str, "DDS ID 必须是字符串")
54+ self.assertRegex(identity, r"\A[0-9a-fA-F]{8}_[0-9a-fA-F]{8}\Z",
55+ "DDS ID 必须为两组8位十六进制,以一个下划线分隔")
56+ self.assertEqual(identity, path.stem.split("_", 1)[1],
57+ "DDS ID 必须与文件名一致")
atomgit-bot
atomgit-botatomgit-bot6 days ago

🔵 Low Priority

变更行(test_csr_identity.py:54-57):先用正则 \A[0-9a-fA-F]{8}_[0-9a-fA-F]{8}\Z 校验 DDS 的 ID,该正则显式允许大写十六进制;紧接着 assertEqual(identity, path.stem.split("_", 1)[1], "DDS ID 必须与文件名一致") 做的是大小写敏感的字符串相等比较。

问题链路:同一函数内两处校验对"同一字段的合法形态"给出了互相矛盾的口径。SR/DDS 文件名按现有约定为小写(如 14140130_10de26b1_10de16a1),一旦新增/修改的配对被写成大写(如 DDS 内 "ID": "10DE26B1_10DE16A1"),第一处正则通过(身份四元组完全正确),第二处却直接失败。

失败模式:语义完全等价、身份正确的配置被判为不合格,测试报错信息为"DDS ID 必须与文件名一致",指向的是大小写差异而非真实的身份错误,误导排查方向;同时该测试是本次修复的核心回归,误报会削弱其作为门禁的可信度。属于测试契约内部不一致,不是既有代码问题(本文件为新增文件)。

建议:统一 DDS ID 的合法形态口径:将比较改为大小写不敏感,或把正则收紧为只接受小写十六进制,避免"格式校验通过但一致性校验失败"的自相矛盾。

Suggestions
57
+ self.assertRegex(identity, r"\A[0-9a-fA-F]{8}_[0-9a-fA-F]{8}\Z",
58
+ "DDS ID 必须为两组8位十六进制,以一个下划线分隔")
59
+ self.assertEqual(identity.lower(), path.stem.split("_", 1)[1].lower(),
57
60
  "DDS ID 必须与文件名一致")
Apply suggestion
likedislike
Incorrect?
58+ device_id, subsystem_id = (int(pair, 16) for pair in identity.split("_"))
59+ return (device_id >> 16, device_id & 0xFFFF,
60+ subsystem_id >> 16, subsystem_id & 0xFFFF)
61+ 
62+ def test_sr_dds_pairs(self):
63+ self.assertEqual({p.stem for p in self.sr_paths},
64+ {p.stem for p in CSR_DIR.glob("*.dds")},
65+ "SR 和 DDS 必须一一配对")
66+ self.assertTrue(KNOWN_DEVIATIONS.keys() <= {p.name for p in self.sr_paths},
67+ "已知偏差登记不能引用已删除的 SR 文件")
68+ 
69+ def test_dds_identity_matches_filename(self):
70+ for path in self.sr_paths:
71+ with self.subTest(file=path.name):
72+ self.read_identity(path)
73+ 
74+ def test_sr_identity_matches_loaded_device(self):
75+ for path in self.sr_paths:
76+ with self.subTest(file=path.name):
77+ expected = self.read_identity(path)
78+ with path.open(encoding="utf-8") as stream:
79+ sr = json.load(stream)
80+ self.assertIsInstance(sr, dict, "SR 根节点必须是对象")
81+ objects = sr.get("Objects")
82+ self.assertIsInstance(objects, dict, "SR 必须包含 Objects 对象")
83+ functions = [obj[FUNCTION_INTERFACE] for obj in objects.values()
84+ if isinstance(obj, dict) and FUNCTION_INTERFACE in obj]
85+ self.assertEqual(len(functions), 1,
86+ "每份 GPU SR 必须包含恰好一个 PCIeFunction 接口")
87+ function = functions[0]
88+ self.assertIsInstance(function, dict, "PCIeFunction 接口必须是对象")
89+ actual = []
90+ for field in IDENTITY_FIELDS:
91+ value = function.get(field)
92+ self.assertIsInstance(value, str, f"{field} 必须存在且为字符串")
93+ self.assertRegex(value, r"\A0x[0-9a-fA-F]{4}\Z",
94+ f"{field} 必须为0x前缀和4位十六进制")
95+ actual.append(int(value, 16))
96+ actual = tuple(actual)
97+ if path.name in KNOWN_DEVIATIONS:
98+ self.assertNotEqual(actual, expected, "已知偏差已修复,请移除登记")
99+ self.assertEqual(actual, KNOWN_DEVIATIONS[path.name],
100+ "已知偏差只允许精确登记的四元组")
101+ else:
102+ self.assertEqual(actual, expected,
103+ "SR 公开的四元组必须与 DDS 加载身份一致")
104+ 
105+ 
106+if __name__ == "__main__":
107+ unittest.main()
@@ -1,5 +1,13 @@
1gtest_dep = dependency('gtest', required: false)1gtest_dep = dependency('gtest', required: false)
2 2 
3+test(
4+ 'test_release_metadata',
5+ find_program('python3'),
6+ args: files('../scripts/tests/test_release_metadata.py'),
7+ timeout: 120,
8+ suite: 'release_metadata',
9+)
atomgit-bot
atomgit-botatomgit-bot6 days ago

🔵 Low Priority

改动行(tests/meson.build:3-9,以及 tests/drivers/pcie_gpu_card/nvidia/meson.build:4-10)把两个 Python 单测以 find_program('python3') 作为可执行程序注册进 Meson 套件:

test( 'test_release_metadata', find_program('python3'), args: files('../scripts/tests/test_release_metadata.py'), ... )

问题:find_program 默认 required: true,找不到名为 python3 的可执行文件时是配置期硬错误,会使 meson setup 整体失败,而不仅仅是跳过该测试。此前该测试目录只有 C++ 测试入口,不依赖 python 可执行名。

触发条件/失败模式:在只有 python(无 python3 软链)或仅通过虚拟环境/pipx 提供 meson、PATH 中没有 python3 的环境里,meson setup 直接以 “Program 'python3' not found or not executable” 中断,整个构建无法配置——即使使用者并不打算跑这两个脚本测试。

这是可选的健壮性改进:改为 python3 = find_program('python3', required: false) 并在 python3.found() 时才注册 test(),与仓库其它可选依赖(如 dependency('gtest', required: false))的既有风格保持一致。

建议:把 find_program('python3') 改为 required: false 并保存到变量,仅在 found() 为真时注册 test()(两份 meson.build 同步处理),避免缺少 python3 时 meson setup 直接失败。

likedislike
Incorrect?
10+ 
3# 公共 test main 静态库,所有测试共享,避免 main.cpp 被重复编译 92 次11# 公共 test main 静态库,所有测试共享,避免 main.cpp 被重复编译 92 次
4test_main_lib = static_library(12test_main_lib = static_library(
5 'test_main',13 'test_main',