⚠️ 法律与用途声明:本文讨论上板测试产物的标识与防污染设计,基于开源工具(GitHub Actions / Conan 2),属于经验分享。所有企业名、项目名、内部路径与字段命名均已脱敏,代码示例中的标识符均为占位。读者须遵守所在地区法律法规。

一、上板测试,re-run 是个坑
前几篇把构建、发布、可观测串完了。但从 NO.A11 开始,我们进入一个新板块:上板测试——构建出来的东西,到底在真板上跑得怎么样。
上板测试和纯构建有个本质区别:它要和物理板子打交道。构建产物(ELF、bin)要传到板测服务器、烧到板子上、跑出结果、再传回来。这条链路比构建本身长得多,也脆弱得多。
而这条链路里藏着一个特别容易踩的坑:re-run。
GitHub 上点一下"re-run jobs",CI 会用同一个 run 编号重跑一遍。这本来是个贴心的功能——构建挂了,不用重新 push,点一下就能重试。但到了上板测试这儿,它就成了灾难的种子。
二、只用 run_id 做目录,re-run 会盖掉旧结果
最直觉的做法,是用 GitHub 的 run_id 当目录键——每次上板测试的产物和结果,都放在以 run_id 命名的目录里:
# 只用 run_id 做目录(有坑)
bundles/<run_id>/
└── <target>/
└── benchmark-report.md
乍一看没问题。但 re-run 发生时,问题就来了:

第一次 run(attempt=1)跑了上板测试,结果存在 bundles/<run_id>/。然后你点了 re-run(attempt=2),新的产物又写进同一个 bundles/<run_id>/。第二次直接覆盖了第一次——第一次的板测结果没了。
更糟的是,覆盖不只是文件覆盖。第一次 attempt 可能以 root 权限写了一些文件,第二次 attempt 是另一个用户跑的,权限对不上,直接报错。或者第一次留下了半成品临时文件,第二次踩上去崩了。你以为 re-run 是重试,实际上它变成了覆盖加污染。
这事的根子在于:run_id 标识的是哪次提交触发的构建,不标识第几次尝试。同一次提交可以 re-run 多次,每次都是一个独立的执行实例。用 run_id 做物理目录键,等于把多个实例塞进同一个物理空间,必然互相覆盖。
三、三键各管什么
解法是引入三个键,各管各的事:

``python
三键模型(伪代码)
run_id = "<GitHub run 的业务号>" # 业务检索键 attempt = <re-run 的次数, 从 1 递增> # 同 run_id 下的执行尝试号 run_key = f"{run_id}-attempt-{attempt}" # 物理实例键(目录用它)
三个键的职责分工很清晰:
- **run_id**:业务检索键。你在 GitHub 页面搜的那个号,人在 Build Registry 里查构建记录也用它。它回答"这是哪次提交触发的"。
- **attempt**:尝试号。同一次 run,re-run 一次就加一。它回答"这是第几次跑"。
- **run_key**:物理实例键。把 run_id 和 attempt 拼在一起,就是每一次物理执行的唯一身份。它回答"这次执行的产物放哪、结果存哪"。
关键在 run_key。它是拼出来的——run_id 加上 attempt,保证同一次 run 的不同 attempt 各有各的 run_key。**目录用它,不只用 run_id**。这样一来,re-run 生成的新 attempt 有自己的 run_key、自己的目录,不再覆盖旧 attempt 的任何东西。
这三个键的模型不是上板测试独有的。NO.A3 讲构建缓存时,run_key 就已经作为即时构建目录的物理键了(re-run 不复用旧缓存)。NO.A10 的 Build Registry 里,每条记录也挂着 run_key。**三键模型贯穿了从构建到上板测试的整条链路**——构建层用它隔离缓存,上板层用它隔离测试结果,同一套契约,不同场景。
## 四、board bundle 目录契约:用 run_key 不用 run_id
CI 构建成功后,构建产物和元数据会打包成一个 bundle,传输到板测服务器。这个 bundle 的目录契约,首层键就是 run_key:

# 新契约(attempt-aware)
bundles/<run_key>/<phase>/<owner>/<repo>/<target>-<toolchain>/
├── artifacts/
│ ├── package-folder.tar.gz
│ ├── benchmark-<target>-<toolchain> # benchmark ELF
│ └── benchmark-info.json
├── result.json
├── package_refs.json
└── summary.md
其中 phase 标记这次 bundle 是构建阶段产出的还是晋升阶段产出的(build / promote)。整个目录的含义是:某次物理执行(run_key)、某个阶段(phase)、某个仓库的某个目标,它的产物就在这个唯一路径下。
因为是 run_key 不是 run_id,re-run 产生的新 attempt 会落在 bundles/<run_id>-attempt-2/...,和 bundles/<run_id>-attempt-1/... 物理隔离,谁也盖不到谁。
五、bundle 和 runtime 要分开
CI 传过来的 bundle(产物)和 watcher(板测执行者)自己产生的运行态,必须放在不同的目录树里,不能混:

# CI 传入的产物(只读消费)
bundles/<run_key>/<phase>/<owner>/<repo>/<target>-<toolchain>/
# watcher 自己的运行态(可写)
runtime/runs/<run_key>/ # 运行日志、锁文件
runtime/results/<run_key>/ # 板测结果、报告
为什么分开?因为产物是 CI 给的,运行态是 watcher 自己产生的。它们的写入者不同、生命周期不同、清理策略也不同。混在一起,出了问题分不清"是 CI 传错了,还是 watcher 写坏了"。分开之后,CI 产物是只读输入,watcher 运行态是可写输出,职责清晰。
而两边都用 run_key 做首层键,保证了同一个 run_key 的产物和结果能对上号——查某次执行的板测结果,先从 bundle 找产物、再从 runtime/results 找结果,两边的 run_key 是同一个,不会串。
六、attempt-aware:每次尝试各存一份
整套设计的最终效果是这样:每一次 re-run,都有自己独立的、完整的产物和结果,互不覆盖。

watcher 写板测结果时的逻辑(伪代码):
def save_board_result(run_id, attempt, result):
# 拼出本次尝试的 run_key
run_key = f"{run_id}-attempt-{attempt}"
# 结果写入独立目录,不碰其他 attempt
path = f"runtime/results/{run_key}/"
write(path + "board-test-result.json", result)
write(path + "benchmark-report.md", render_report(result))
注意这里写入用的是 run_key 不是 run_id。所以 attempt-1 的结果在 runtime/results/<run_id>-attempt-1/,attempt-2 在 runtime/results/<run_id>-attempt-2/,各回各家。
如果只有 run_id(比如历史数据),还有一个兼容层(伪代码):
def resolve_bundle(run_id, attempt=None):
if attempt is not None:
run_key = f"{run_id}-attempt-{attempt}"
if exists(f"bundles/{run_key}/"):
return f"bundles/{run_key}/" # 新契约优先
# 历史兼容:只有 run_id 的旧数据
if exists(f"bundles/{run_id}/"):
return f"bundles/{run_id}/" # 旧格式,仍能识别
新写入一律用 run_key;旧数据(只有 run_id 的)仍能被识别,不会因为升级了契约就找不到老结果。这是个前向兼容的工程细节——升级归升级,老数据不能丢。
七、几条攒下来的判断
物理键和业务键要分开。run_id 是业务键(人查的),run_key 是物理键(机器存目录的)。混用必然覆盖。
re-run 是合法操作,不能让它的代价是丢数据。re-run 的本意是"重试",不是"覆盖"。设计上要保证每次重试都是独立实例。
三键模型贯穿全链路。从构建缓存(NO.A3)到 Build Registry(NO.A10)到上板测试(本篇),run_key 作为物理实例键统一了整套体系的隔离契约。
CI 产物和 watcher 运行态必须分目录。写入者不同、生命周期不同,混在一起就是糊涂账。
升级契约要前向兼容。从只用 run_id 到用 run_key,老数据仍要能识别,不能升级一次就丢一批历史。
这套三键模型立住之后,最踏实的感觉是:你随便 re-run,每次的结果都好好存在各自的格子里,想翻哪次翻哪次,谁也盖不到谁。好的标识设计,是让"重试"这件事不会产生任何附带伤害。
下一篇,我们讲上板测试的具体闭环:烧录 + 日志采集 + 判定规则——产物到了板子上之后,怎么烧、怎么采、怎么判定跑没跑通。

参考链接:
[1] GitHub Actions:re-run workflows 与 run_attempt https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs
[2] GitHub Actions:查看 workflow run 详情 https://docs.github.com/en/actions/how-tos/manage-workflow-runs/viewing-workflow-run-history
[3] Conan 2:交叉编译产物管理 https://docs.conan.io/2/how-tos/cross_platform/cross_building_with_conan.html
[4] 幂等性与防覆盖设计模式 https://martinfowler.com/articles/patterns-of-distributed-systems/versioned-value.html
[5] 前向兼容与数据迁移策略 https://learn.microsoft.com/en-us/azure/architecture/best-practices/data-partitioning