| @@ -28,3 +28,12 @@ repos: | |||
| 28 | args: [--maxkb, "1024"] | 28 | args: [--maxkb, "1024"] |
| 29 | - id: check-case-conflict | 29 | - id: check-case-conflict |
| 30 | - id: detect-private-key | 30 | - 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 | ||
| 122 | Mulan PSL v2 许可证,详见 [LICENSE](LICENSE) 文件 | 135 | Mulan 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 | ||
| 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() # 每个子用例使用独立仓库,避免前一次定版污染输入。 | ||
🔵 Low Priority 改动行(scripts/tests/test_release_metadata.py:109)在测试方法内直接调用 for final_newline in (True, False): with self.subTest(final_newline=final_newline): self.setUp() # 每个子用例使用独立仓库 问题: 建议:将 setUp 中的仓库初始化抽成可重复调用的 _init_repository() 辅助方法,测试内调用它而不是 self.setUp(),避免重复注册 addCleanup 与重复复制环境变量;如需立即清理旧临时目录,可在重置前调用 self.doCleanups()。 ![]() ![]() 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 @@ | |||
| 1 | subdir('interface') | 1 | subdir('interface') |
| 2 | subdir('nv_common') | 2 | subdir('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 必须与文件名一致") | ||||||||||
🔵 Low Priority 变更行(test_csr_identity.py:54-57):先用正则 问题链路:同一函数内两处校验对"同一字段的合法形态"给出了互相矛盾的口径。SR/DDS 文件名按现有约定为小写(如 失败模式:语义完全等价、身份正确的配置被判为不合格,测试报错信息为"DDS ID 必须与文件名一致",指向的是大小写差异而非真实的身份错误,误导排查方向;同时该测试是本次修复的核心回归,误报会削弱其作为门禁的可信度。属于测试契约内部不一致,不是既有代码问题(本文件为新增文件)。 建议:统一 DDS ID 的合法形态口径:将比较改为大小写不敏感,或把正则收紧为只接受小写十六进制,避免"格式校验通过但一致性校验失败"的自相矛盾。 Suggestions
![]() ![]() 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 @@ | |||
| 1 | gtest_dep = dependency('gtest', required: false) | 1 | gtest_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 | +) | ||
🔵 Low Priority 改动行(tests/meson.build:3-9,以及 tests/drivers/pcie_gpu_card/nvidia/meson.build:4-10)把两个 Python 单测以 test( 'test_release_metadata', find_program('python3'), args: files('../scripts/tests/test_release_metadata.py'), ... ) 问题: 触发条件/失败模式:在只有 这是可选的健壮性改进:改为 建议:把 find_program('python3') 改为 required: false 并保存到变量,仅在 found() 为真时注册 test()(两份 meson.build 同步处理),避免缺少 python3 时 meson setup 直接失败。 ![]() ![]() Incorrect? | |||
| 10 | + | ||
| 3 | # 公共 test main 静态库,所有测试共享,避免 main.cpp 被重复编译 92 次 | 11 | # 公共 test main 静态库,所有测试共享,避免 main.cpp 被重复编译 92 次 |
| 4 | test_main_lib = static_library( | 12 | test_main_lib = static_library( |
| 5 | 'test_main', | 13 | 'test_main', |


🔵 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 参数),避免异常退出后发布流程被永久阻塞。