Files
HomeAgent/.github/workflows/ci.yml
JianFeeeee ed8f28ea6c test(kernelboot,ci): 用真实启动图装配整机补判据,race 门由写死两包扩到全仓
背景:老大指出「CI 测试不够灵活、覆盖不到内核方方面面,应从启动图入手」。
先定位 CI 失败:六个门禁在基线上本地全绿,复现不出红 —— 实质是**覆盖面太窄**
而非配置写错(原 race 门只跑 ./internal/agent/core/ 与 ./cmd/waiter/ 两个包,
kernelboot 下 scheduler/syncbus/stress 的并发用例全在裸奔,绿是因为没测到)。

## 一、新增 internal/kernelboot/modules/registry_test.go(913 行,5 条判据)

kernelboot 自身判据很密(图编解码/分层/调度/迁移回滚/并发压测),但**全部用
假模块**在图上验算法。「真实 17 个模块按真实登记表装配成整机、逐层启动、
再严格逆序关闭」一条用例都没有。这中间藏着一整类失败:算法对、单模块对,
但装配起来不对(漏挂模块 / 某层关闭函数没登记 / 关闭顺序反了)。

5 条判据(零假模块,全部基于 kernelmod.Registered() 的 17 个真模块):
- TestRealRegistryAssemblesWholeMachine:每模块进图、设施真就绪(不是"登记过")
- TestLayerTopologyIsRespected:层间严格有序、层内两两无依赖、分层真带来并行
- TestShutdownIsStrictlyReverseLayerOrder:关闭严格逆序(实测哨兵序 [4 3 2 1 0])
- TestShutdownLeavesNoResidue:连续启停 3 轮,goroutine 回基线 + 临时目录删净
  + syncbus 订阅者不增长
- TestEveryRegisteredModuleIsCompiledIn:源码树扫描与登记表**双向**集合比对

### 判据 5 的真相源为何是"类型+四方法集"而不是 Register 调用点(初版栽过)

初版扫 `kernelmod.Register(...)` 调用点。实测:删掉叶子模块 multimodal 的
init() 后,**5 条判据全绿** —— 调用点消失 ⇒ 源码侧与登记表侧同时少一个 ⇒
集合比对照样相等。而"忘记初始化注册"恰是它声称要抓的场景。
根因是把「要检测的对象」当成了「检测的入口」,入口一被破坏就失去视野。
改成扫「实现了 Name/Requires/Provides/Init 四方法的类型」:模块哪怕不
Register、不被 import,四个方法仍写在源码里。
坑:本仓大量类型都叫 Module,只按类型名做键会撞成一堆(实测撞到只剩 2 个)
⇒ 用「目录+类型名」复合键;agent 是指针接收者 *Module,要处理 StarExpr。
修后恰扫出 17 个类型,与登记表 17 个名字一一对应,零误报。

### 失败面逐条实测(不是注释声称)

M1 删叶子模块 init() → 只有判据 5 红(1–4 全绿,这正是它存在的理由)
M2 CloseAll 逆序改正序 → 判据 3 红
M3 删聚合 import → 判据 5 红
其余:跳过某层会让内核 panic(llm 拿到 nil LuaVM,SIGSEGV);层顺序改逆序
红在装配失败(依赖数据为 nil)而非顺序断言 —— 这两种能发现但报错可读性差,
已写入文件注释,修它属业务改动(另派)。

## 二、.github/workflows/ci.yml

- race 门:写死两包 → `go list ./...` 全仓动态枚举(剔除 cmd/gui,
  枚举 <50 包则响亮失败),实测 92 包
- 新增 -skip 排除表:每项必须写「原因 + 修复单号」,无原因该步骤自身失败;
  每次 CI 打印完整清单,避免"排除项静默腐烂"
- 新增「已知红的可核对证据」步:用**静态断言**检查两处已知缺陷是否仍在
  (webui gzipResponseWriter 无锁、kernel Set* 无锁直写)。
  不用"DATA RACE 报告数>0"当断言 —— 实测这些竞争偶发(正向 0 条、
  另一次 8 条),那样必然假绿。
- cross 门:cmd 清单由写死 → 逐包 `CGO_ENABLED=0 go build` 实测枚举
  (实测 4 个纯 Go / 8 个依赖 cgo;homed-key-rotate 一直是纯 Go 却从没进过
  清单,白少一个覆盖);矩阵补 windows/arm64 与 linux/386
- go job:单列「整机启动图判据」一步(带 -count=1,便于失败定位)

## 三、新增 .github/scripts/check-ci-gates.py(180 行)

官方复核时发现:第一版判据只跑 gofmt/build/vet/go test,**完全不校验 ci.yml**,
而 ci.yml 正是改动占比最大的部分 —— 用「删掉 cmd/gui 剔除」这个变异咬出了该缺口。
本脚本用 YAML 解析断言 6 类门禁机制仍在,并给每个 run 步骤做 bash -n。
关键细节:`run: |` 块里的 `# ...` 是**字符串不是 YAML 注释**,`"cmd/gui" in run`
会被一句说明性注释满足 —— 真把 grep -v 删掉断言仍绿。故剥 shell 注释后再断言,
并给剥注释函数**本身**加了自测。

## 四、两处纯注释纠偏

- modules/registry.go:文档写「防漏 import 判据见 registry_test.go」(main 上该
  文件当时不存在)、第④步指向 `modules/imports.go`(真实位置是
  internal/kernelmod/all/all.go)。已纠正并落地为两条判据的指引。
- kernelboot/kernelstate.go:把「判据见 modules」具体化为函数名。

## 门禁(全部 -count=1,无缓存)

go build ./...          = 0
go vet ./...            = 0
go test ./...           = 0(59 ok / 0 FAIL)
go test -race 91 包      = 0(58 ok / 0 FAIL)
check-ci-gates.py       = 0
证据步骤正向 = 0;反向(给 SetLuaVM 加锁)= 1(证明是活断言不是死代码)

## 已知红(均待另开线,本线不越界改业务)

1. 内核:agent.wirePluginSDK 与 pluginhost.injectBaseDeps 在第 4 层并行重复
   写同一批 plugin.Registry 字段,而 Set* 全不取锁(r.mu 存在但没用)。
   实测偶发约 4%(1/25)。当前以 pkg 级排除临时挡住,**定性为临时手段**;
   唯一出口是给 Set* 加锁(修复单 KERNEL-REGISTRY-SETLOCK)。
2. webui:gzipResponseWriter 的 mode/wroteHeader/buf 无锁,handler goroutine
   与增量写 goroutine 并发写同一 ResponseWriter(修复单 WEBUI-GZIP-RACE)。
3. internal/agent/core TestStress_Nesting... 时序偶发(-race 放大 300ms 假设,
   无 DATA RACE 报告),未 skip,复现命令记在 ci.yml 注释里。

边界:未改任何内核业务逻辑(两个业务文件 diff 逐行只有注释);未 push、未开 PR。
2026-10-24 11:30:51 +08:00

616 lines
31 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# HomeAgent 主仓 CI。
#
# 设计原则:**CI 里跑的每一条命令,都是本地已实测通过的命令**。
# 不写「应该有用来试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态,
# 最后所有人学会忽略它。
#
# 覆盖范围与本地 `make test` 对齐(build / vet / test / client-versions /
# gui / csrc),并按依赖拆成独立 job,便于失败定位。
#
# 明确**不在** CI 里跑的东西(依赖真机/密钥/内网,跑了只会变 flaky 噪音):
# - deploy-*.sh / homed 生产部署
# - waiter 真机验证(192.168.2.x)
# - cmd/gui 的 `npm run test-live`(需真 Electron + Xvfb + 真后端)
# - scripts/kernel-stress/*(需 llmsproxy 与压测端点)
# - 需要 DEEPSEEK_API_KEY / MEDIALIVE_* 的真实 LLM 测试(已自带 t.Skip)
name: CI
on:
push:
branches: [main, 'release/**']
pull_request:
workflow_dispatch:
# 只读权限:CI 不需要写仓库。
permissions:
contents: read
# 同一分支连续推送时取消旧跑,省额度也避免过期结果误导。
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# gojieba / onnx 相关包需要 cgo ⇒ 不能用 CGO_ENABLED=0。
CGO_ENABLED: 1
# 减少 go test 输出噪音。
GOFLAGS: -buildvcs=false
jobs:
# ── Go 后端:构建 + 静态检查 + 全量测试 + 跨平台客户端版本一致性 ──
go:
name: Go build / vet / test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
# cgo 需要 gcc/g++(gojieba 会编译自带 C++ 源码)。
- name: 确认 cgo 工具链
run: |
gcc --version | head -1
g++ --version | head -1
- name: go build ./...
run: go build ./...
- name: go vet ./...
run: go vet ./...
# ./... 不点名 cmd/gui(该目录是纯 Electron,无 .go 文件):
# 显式 `go test ./cmd/gui` 会报 "no Go files",那是误报,不是缺陷。
- name: go test ./...
run: go test ./... -count=1 -timeout 20m
# 整机启动图判据(internal/kernelboot/modules/registry_test.go)。
#
# ## 为什么单列一步(而不是只靠上面的 go test ./...)
#
# 这组判据用**真实模块登记表**装配整机(17 个真模块,全部真 Init),
# 会真的建 SQLite、起 HTTP server、mount overlay、起蒸馏/轮询循环。
# 把它单列出来有两个作用:
# ① 失败时一眼看到是"整机装配"这一层坏了,而不是混在 93 个包的输出里
# ② 它断言的是**跨模块的顺序与资源回收**,是"多跑几次才暴露"的那类,
# 单独一步便于在 flaky 时被识别(这里带 -count=1,不吃缓存)
#
# ★ 不用 -race(那在 race job 里跑):本判据含真实端口/挂载,
# -race 的插桩会让时序假设变松,两者分开放更便于定位。
- name: 整机启动图判据(真实登记表装配 + 逆序关闭 + 无残留)
run: |
go test ./internal/kernelboot/modules/ \
-count=1 -timeout 10m -v
# 跨平台客户端版本一致性:内核 internal/meta 是唯一事实源,
# GUI(package.json) / 鸿蒙(AppScope/app.json5) / waiter 都必须跟它一致。
- name: 客户端版本一致性
run: make check-client-versions
# ── 竞态检测(并发改动的主要防线)──
#
# ## 为什么包列表是**动态枚举**而不是写死
#
# 原先这里写死 `./internal/agent/core/ ./cmd/waiter/` 两个包 ——
# kernelboot 下 scheduler / syncbus / stress 的并发用例因此**全在裸奔**。
# 写死列表的失败模式是**静默的**:新增包不会被覆盖,而 CI 一直是绿的,
# 没人会发现门禁没罩到。
#
# ⇒ 改成 `go list ./...` 全仓枚举(93 包 → 剔除 cmd/gui 后 92 个,
# 实测 -race 全绿耗时约 72s)。排除必须写理由,见下面的 grep -v。
#
# ## 唯一被 -skip 的用例:webui 的真数据竞争(**待修业务代码**)
#
# TestOpenAIStreamIsActuallyStreaming 在 -race 下**稳定报真竞争**(实测
# 6/6 次触发,每次 6~8 条 DATA RACE 报告):
#
# Read gzipResponseWriter.Write gzip.go:139 (读 g.mode)
# └─ 调用方:streamOpenAI 的 handler goroutine handler_openai.go:256
# Write gzipResponseWriter.commit gzip.go:206 (写 g.mode/g.wroteHeader)
# └─ 调用方:streamOpenAI **写 goroutine**(16ms 批量 flush)handler_openai.go:165
#
# 根因:`gzipResponseWriter` 的 mode/wroteHeader/buf 无锁,而
# handler goroutine(发 finish chunk/[DONE])与写 goroutine(发增量)
# **并发写同一个 http.ResponseWriter**。这是被测逻辑的真缺陷。
#
# ★ 本次任务授权是"只动 CI 与测试",**不改业务逻辑** ⇒ 只能用 -skip 挡住,
# 并在报告里带原始栈交回。修好后**必须**删掉这里与下一步的名字。
# ★ 为什么不选择"让它红着":长红 ⇒ 所有人学会忽略 ⇒ 整条 race 门失效,
# 连带把 kernelboot 等**新纳入**的覆盖一起废掉。
#
# ## 另有一个偶发失败(**没有** skip,照实记录)
#
# internal/agent/core: TestStress_NestingReachesStructuralBoundThenUnwindsLIFO
# · 本 job 跑整个包时:实测 13/13 全绿(测试之间互相预热)
# · **单独**用 -run 指它 + -race 时:5/12 失败
# ("第 N 级注入后栈深未达 N:0")
# · 不带 -race 单独跑:0/12 失败
# ⇒ 它是**用例自身的时序假设**(要求 300ms 窗口内恰好把第 N 面压进栈)
# 被 -race 插桩放大后错过,**不是数据竞争**(无 DATA RACE 报告)。
# 故**不 skip**(skip 会掩盖该用例的回归),但把结论与复现命令记在这里:
# go test -race ./internal/agent/core/ \
# -run TestStress_NestingReachesStructuralBoundThenUnwindsLIFO -count=1
race:
name: Race detector
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: 枚举 -race 覆盖的包
id: pkgs
run: |
set -euo pipefail
# cmd/gui 是纯 Electron 目录(只有 doc.go,无测试),
# 显式点名它会让 go test 报 "no Go files" 的假红。
ALL=$(go list ./... | grep -v '^.*/cmd/gui$' | tr '\n' ' ')
N=$(echo $ALL | wc -w)
echo " 将用 -race 覆盖 $N 个包"
# 兜底:枚举失败(0 个包)必须响亮失败,不能静默跳过整道门。
if [ "$N" -lt 50 ]; then
echo "::error::只枚举出 $N 个包,枚举逻辑可能失效"
exit 1
fi
echo "pkgs=$ALL" >> "$GITHUB_OUTPUT"
# ── 排除表(**唯一**允许从 -race 门里拿掉的东西,必须带原因 + 修复单号)──
#
# ## 为什么做成"表 + 每次打印",而不是把名字内联在 go test 命令里
#
# 内联的 `-skip 'X|Y'` 有三个问题:
# ① 没人能一眼看出**当前一共排除了几项、分别为什么、归谁修**
# ② 修好之后没人会记得回来删 ⇒ 它静默地一直挡着,那个用例从此
# 永不执行,而 CI 一直绿(正是"静默覆盖丢失")
# ③ 新增排除时容易顺手加个名字、不写理由,排除表就腐烂了
#
# 做成表后:每次 CI 都**打印**完整清单(项数 + 类型 + 名字 + 原因 + 单号),
# 并做结构校验:**缺 reason 或缺单号的条目直接让这一步失败**。
#
# ## 两种粒度,必须区分(混用会造成覆盖静默丢失)
#
# pkg 级 —— 该包整体不参与 -race(竞争在**内核装配**里,任何把整机
# 装起来的用例都会撞上;不是某个测试写坏了)
# test 级 —— 只有该用例不参与 -race(其它用例照跑)
#
# ★ pkg 级排除**必须**确认该包仍有不带 -race 的覆盖(见 go job 里
# 单列的"整机启动图判据"步骤),否则等于整包无人跑。
#
# ╔══════════════════════════════════════════════════════════════════╗
# ║ ★★ pkg 级排除是**临时手段**,不是可接受的终态。 ║
# ║ ║
# ║ 现状的取舍是被迫的,只有两条路: ║
# ║ ① 让 ~4% 的偶发红进主干(长红 ⇒ 所有人学会忽略 ⇒ 整道门失效) ║
# ║ ② 先挡住、同时派单修根因(本表选这条) ║
# ║ ║
# ║ **整包永久排除不被接受。** 唯一出口是给 plugin.Registry 的 Set* ║
# ║ 加锁(修复单 KERNEL-REGISTRY-SETLOCK),修好后: ║
# ║ · 删掉本表里 kernelboot/modules 那一行 ║
# ║ · **复跑一次** -race 全仓确认整机判据在 -race 下也稳定 ║
# ║ · 删掉 evidence 步骤 ② 的静态断言 ║
# ║ 这三步没做完,本线不算收口。 ║
# ╚══════════════════════════════════════════════════════════════════╝
- name: 计算 -race 排除表并打印
id: skip
run: |
set -euo pipefail
# 列名:粒度|目标|原因|修复单号 (用 | 分隔,原因内可含空格)
read -r -d '' TABLE <<'EOF' || true
test|internal/plugins/webui|TestOpenAIStreamIsActuallyStreaming|gzipResponseWriter 的 mode/wroteHeader/buf 无锁;handler goroutine(发 finish/[DONE])与增量写 goroutine 并发写同一 ResponseWriter(gzip.go:139 读 mode vs gzip.go:206 写 mode)|WEBUI-GZIP-RACE
pkg|internal/kernelboot/modules|*(整包)|该包把真实整机装起来 ⇒ 第 4 层 agent 与 plugin-host 并行 Init 时,双方都无锁直写同一 plugin.Registry 的 7 个 Set*(SetLuaVM/SetBaseAPIKey/SetSupervisor/SetTracker/SetConfig/SetStageHost/SetIndexer,registry.go:231-284)。竞争在**内核装配**里,不在本包代码里;任何装整机的用例都会撞上|KERNEL-REGISTRY-SETLOCK
EOF
tests=""; pkgs=""; n=0
echo "════════ -race 排除表 ════════"
while IFS='|' read -r grain target test reason ticket; do
[ -z "${grain// /}" ] && continue
# ★ 结构校验:缺原因或缺单号 ⇒ 拒绝(不许无理由/无归属的排除)
if [ -z "${reason// /}" ]; then
echo "::error::排除项 $target / $test 没有写原因 —— 不许无理由排除"
exit 1
fi
if [ -z "${ticket// /}" ]; then
echo "::error::排除项 $target / $test 没有挂修复单号 —— 没人负责的排除会永久腐烂"
exit 1
fi
case "$grain" in
test)
n=$((n + 1))
echo " [$n] test 级: $test"
echo " 包: $target"
echo " 原因: $reason"
echo " 修复单: $ticket"
if [ -n "$tests" ]; then tests="$tests|$test"; else tests="$test"; fi
;;
pkg)
n=$((n + 1))
echo " [$n] pkg 级: $target (整包不进 -race)"
echo " 原因: $reason"
echo " 修复单: $ticket"
pkgs="$pkgs $target"
;;
*)
echo "::error::排除项 $target 的粒度必须是 test 或 pkg,实际: $grain"
exit 1
;;
esac
done <<< "$TABLE"
if [ "$n" -eq 0 ]; then
echo " (无排除项)"
echo "::warning title=-race 无排除项::排除表已空,可考虑删除本步骤与 evidence 步骤"
else
echo "════════ 共排除 $n 项(不是静默跳过:每次 CI 都打印本表)════════"
echo "::warning title=-race 排除了 $n 项::见本步骤输出(含原因与修复单号)"
fi
echo "tests=$tests" >> "$GITHUB_OUTPUT"
echo "pkgs=$pkgs" >> "$GITHUB_OUTPUT"
echo "count=$n" >> "$GITHUB_OUTPUT"
- name: go test -race(全仓,应用上面的排除表)
run: |
set -euo pipefail
EXCL="${{ steps.skip.outputs.pkgs }}"
SKIP="${{ steps.skip.outputs.tests }}"
# 从全仓包列表里剔除 pkg 级排除项(按路径后缀精确匹配,
# 不用 grep 模糊匹配 —— 否则 kernelboot 会把 kernelboot/modules 一起剔掉)。
PKGS=""
for p in ${{ steps.pkgs.outputs.pkgs }}; do
drop=0
for e in $EXCL; do
case "$p" in
*"/${e#./}") drop=1 ;;
esac
done
[ "$drop" -eq 0 ] && PKGS="$PKGS $p"
done
NPKG=$(echo $PKGS | wc -w)
echo " 实际参与 -race 的包数: $NPKG(已剔除 pkg 级排除项)"
if [ "$NPKG" -lt 50 ]; then
echo "::error::剔除后只剩 $NPKG 个包,疑似误剔 —— 拒绝变成假绿"
exit 1
fi
# ★ 断言被排除的包确实从列表里消失了(防"写了排除但没生效")
for e in $EXCL; do
if echo "$PKGS" | grep -q -- "/${e#./}"; then
echo "::error::pkg 级排除项 $e 仍出现在待测列表里 —— 剔除逻辑失效"
exit 1
fi
done
if [ -n "$SKIP" ]; then
echo " 应用 test 级排除($SKIP)"
go test -race $PKGS -count=1 -timeout 25m -skip "$SKIP"
else
go test -race $PKGS -count=1 -timeout 25m
fi
# ── 证据步骤:把"已知红"变成每次 CI 都可核对的数字 ──
#
# ★ 这里**不用 continue-on-error 混过去**(那是"假装没红")。
# 做法相反:显式断言两件事都成立,任一不成立 ⇒ 这一步**真红**:
# ① webui 那条竞争**确实还在**(报告数 > 0)—— 证明排除表没白排除
# ② 内核那 7 个 Set* **确实还无锁**(栈里出现 registry.go 的 Set*)
# 修好之后这两条会自然失败,提醒去删排除表 —— 这是**期望的**失败,
# 报错信息里直接写了该做什么。
- name: 已知红的可核对证据(修好后本步会红,提醒删排除项)
run: |
set -uo pipefail
fail=0
echo "=== ① webui: gzipResponseWriter 无锁并发写(修复单 WEBUI-GZIP-RACE)==="
#
# ★ 同样**不**赌竞争必现:实测该竞争单跑 3/8 命中(偶发),
# 拿"报告数>0"当断言会在没撞上的那 5 次假绿。
# 改成**静态断言**:struct 无 sync 字段 且 commit 不取锁 ⇒ 无锁仍在。
# 修好(加锁)后两条件之一变化 ⇒ 本条失败 ⇒ 提醒删排除表。
GZ=internal/plugins/webui/gzip.go
sync_fields=$(awk '/type gzipResponseWriter struct/,/^}/' "$GZ" | grep -c 'sync\.' || true)
lock_calls=$(awk '/func \(g \*gzipResponseWriter\) commit\(/,/^}/' "$GZ" \
| grep -cE '\.Lock\(\)|\.RLock\(\)' || true)
echo " gzipResponseWriter 的 sync 字段数: $sync_fields"
echo " commit() 内的加锁调用数: $lock_calls"
if [ "$sync_fields" -ne 0 ] || [ "$lock_calls" -ne 0 ]; then
echo "::error title=webui gzip 竞争可能已修::WEBUI-GZIP-RACE 的锁已补(sync字段=$sync_fields 加锁=$lock_calls)—— 请从 ci.yml 排除表删掉该行,并删掉本证据步骤"
fail=1
fi
# 附:顺带跑一次,把当前是否撞上记进日志(**不作为判据**)
out=$(go test -race ./internal/plugins/webui/ \
-run 'TestOpenAIStreamIsActuallyStreaming' -count=1 -timeout 5m 2>&1 || true)
echo " (参考)本次 DATA RACE 报告数: $(echo "$out" | grep -c 'WARNING: DATA RACE')(偶发,0 不代表已修)"
echo "=== ② kernel: agent↔plugin-host 无锁直写 plugin.Registry(修复单 KERNEL-REGISTRY-SETLOCK)==="
#
# ★ 这里**不跑**偶发竞争来当证据 —— 实测该竞争单步跑 6/6 次撞不到
# (它要 92 包并发压满才容易命中),拿它当断言必然假绿。
# 改成**静态断言**:直接查那 7 个 Set* 是否仍是"单行无锁直写"形态。
# 修好(改成取锁)后形态变化 ⇒ 本条失败 ⇒ 提醒删排除表。因果稳定。
SETTERS="SetLuaVM SetBaseAPIKey SetSupervisor SetTracker SetConfig SetStageHost SetIndexer"
REG=internal/plugin/registry.go
locked=0; total=0
for fn in $SETTERS; do
total=$((total + 1))
if grep -qE "^func \\(r \\*Registry\\) ${fn}\\([^)]*\\)[[:space:]]*\\{[^}]*\\}[[:space:]]*$" "$REG"; then
: # 仍是单行直写(未加锁)
else
echo " 已不再是无锁单行: $fn"
locked=$((locked + 1))
fi
done
echo " 仍为无锁直写的 Set*: $((total - locked))/$total"
if [ "$locked" -gt 0 ]; then
echo "::error title=内核 setter 已加锁::KERNEL-REGISTRY-SETLOCK 可能已被修复($locked/$total 个 Set* 形态已变)—— 请从 ci.yml 排除表删掉 kernelboot/modules 那一行"
fail=1
fi
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo " 证据核对完成:两个排除项对应的缺陷均仍在(排除表有效)"
# ── 交叉编译:可在无 cgo 下构建的客户端/工具 ──
#
# 哪些 cmd 能纯交叉编译,**本注释不写死**——以 job 内「枚举可纯交叉编译的
# cmd」那一步的实测结果为准(`CGO_ENABLED=0 go build` 成功者才纳入)。
#
# ★ 为什么这里不列名单:名单一定会烂。实测(2026-10-24 本机)已经推翻了
# 旧注释的「只有三个」——纯 Go 的是 4 个:
# waiter / initconfig / mock-server / homed-key-rotate
# (homed-key-rotate 一直是纯 Go 却从没进过清单 ⇒ 白少一个覆盖)
# 依赖 cgo 的 8 个(含 gojieba / onnx 传递依赖,必须原生构建):
# homed / homed-centroid / homed-depgraph / homed-graph-distill /
# homed-graph-migrate / homed-kb-migrate / homed-media-backfill / memgc
# 上面的数字是**某次实测的快照**,不是契约;契约是那一步的枚举逻辑。
cross:
name: Cross-compile
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
ext: ""
- goos: linux
goarch: arm64
ext: ""
- goos: darwin
goarch: amd64
ext: ""
- goos: darwin
goarch: arm64
ext: ""
- goos: windows
goarch: amd64
ext: ".exe"
# ── 目标平台集为什么是这几个 ──
#
# ★ 这里**仍然是人工选定**的常见交付平台,不是穷举(穷举靠 `go tool dist list`)。
# 写死目标集的代价是"某个目标坏了没人知道";缓解办法不是继续加目标,
# 而是让**每个目标要构建的 cmd 列表**动态枚举(见下面 "枚举可纯交叉编译的 cmd")——
# 那样新增 cmd 不会静默漏覆盖,剩下的风险只限于"没人要的平台"。
#
# 本机实测(CGO_ENABLED=0 逐包 build,全部退出码 0):
# waiter / initconfig / mock-server / homed-key-rotate(纯 Go 那 4 个)
# 在 linux/{386,arm,ppc64le}、windows/{386,arm64}、freebsd/amd64 上均通过。
# 原先矩阵漏了 windows/arm64 与 linux/386 —— 已补入。
- goos: windows
goarch: arm64
ext: ".exe"
- goos: linux
goarch: 386
ext: ""
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
# 哪些 cmd 能纯交叉编译,**不写死**,而是逐包实测:
# `CGO_ENABLED=0 go build` 成功的才纳入。理由是纯 cgo 依赖会随代码漂移
# (实测:homed-key-rotate 一直是纯 Go 却没进清单 ⇒ 白少一个覆盖,
# 而 memgc 依赖 gojieba,写进清单就是假红)。
# 判定在本机与 CI 上是同一条命令、同一个 CGO_ENABLED,结果可复现。
- name: 枚举可纯交叉编译的 cmd
id: pure
run: |
set -euo pipefail
PURE=""
CGO_DEPS=""
for c in $(go list ./... | sed -n 's|.*/cmd/\([^/]*\)$|\1|p'); do
# ./cmd/gui 是纯 Electron 目录(无 .go 文件),不参与 Go 构建。
if [ "$c" = "gui" ]; then continue; fi
if CGO_ENABLED=0 go build -o /dev/null "./cmd/$c" 2>/dev/null; then
PURE="$PURE $c"
else
CGO_DEPS="$CGO_DEPS $c"
fi
done
echo " 纯 Go(纳入交叉矩阵):$PURE"
echo " 依赖 cgo(只能原生构建):$CGO_DEPS"
# 兜底:枚举为空说明逻辑坏了,必须响亮失败而不是静默少跑
if [ -z "${PURE// /}" ]; then
echo "::error::没有枚举出任何可交叉编译的 cmd —— 枚举逻辑失效"
exit 1
fi
echo "cmds=$PURE" >> "$GITHUB_OUTPUT"
- name: 构建 ${{ matrix.goos }}/${{ matrix.goarch }}
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
set -euo pipefail
mkdir -p dist
for c in ${{ steps.pure.outputs.cmds }}; do
out="dist/${c}_${{ matrix.goos }}_${{ matrix.goarch }}${{ matrix.ext }}"
go build -trimpath -o "$out" "./cmd/${c}"
echo " ✓ ${c} ${{ matrix.goos }}/${{ matrix.goarch }}"
done
# ── Electron GUI(纯 Node 测试,零依赖)──
#
# `npm test` 跑五个 .mjs,全部只 import node: 内置模块(fs/url/path/vm),
# 所以**不需要 npm ci、不需要 electron**,秒级完成。
# `npm run test-live` 需真 Electron + 真后端 ⇒ 不进 CI。
#
# endpoint-align.test.mjs 跨出 cmd/gui 读 ../../internal/plugins/webui/handler.go
# 做端点对账 —— checkout 深度足够(默认 fetch-depth:1 已含工作树文件),
# 不需要额外 checkout 步骤。
#
# connections-io.test.mjs 同样是沙箱里跑 main.js 的**真实函数**(node:vm),
# 在临时目录里断言连接配置的加载/保存行为,不碰真实用户数据。
gui:
name: GUI (node)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: npm test
working-directory: cmd/gui
run: npm test
# ── C 基础设施门禁(ABI / 告警 / ASan+UBSan / 跨架构)──
csrc:
name: C infrastructure gates
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
# clang 供双编译器告警对照;gcc-aarch64 供跨架构编译门禁。
# 门禁在缺工具时是显式 SKIP 而不是假通过,这里装齐以免静默降级。
- name: 安装 C 工具链
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq cmake clang gcc-aarch64-linux-gnu
- name: make check-csrc
run: make check-csrc
# ── 文档站构建(mkdocs,纯 Python,无外部依赖)──
docs:
name: Docs build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: 校验站点配置可解析
# 这里只做「配置与文档源没坏」的轻量校验,不做完整 mkdocs build
# (站点发布有独立流水线,见 deploy/scripts/deploy-sdk-site.sh)。
run: |
set -euo pipefail
if [ -f mkdocs.yml ]; then
python -c \
"import yaml; yaml.safe_load(open('mkdocs.yml'))" \
&& echo "mkdocs.yml OK"
else
echo "无 mkdocs.yml,跳过"
fi
test -d docs || echo "无 docs/,跳过"
# ── gitcode 镜像同步(main 分支)──
#
# 背景(2026-10-06 审计):gitcode 的定位是**只读镜像**(ci-cd-runbook §0),
# 但既有 sync job 只在 release.yml 里推 **tag** —— main 分支从不同步,
# 导致镜像的 main 停在 09-29、与主仓分叉 768/746(当时已 force push 对齐一次)。
#
# 本 job 把「镜像 = 主仓」交给 CI 保证:main 每次推送且**全部必需检查通过后**
# 才快进镜像分支 —— 红的代码不进镜像,镜像永远等于主仓。
#
# - 只处理 main(feature/release 分支不镜像,与 tag 语义一致)。
# - 未配置 GITCODE_TOKEN 时显式跳过(与 release.yml 的 sync-gitcode 同口径,
# 不阻断 GitHub 侧 CI)。
# - 用快进 push(非 force):正常流水线下镜像只可能落后,不可能领先;
# 若出现非快进说明有人动过镜像,此时响亮失败而不是悄悄覆盖 ——
# 镜像被旁路写入是「单向性被破坏」的信号,值得一次人工确认。
sync-gitcode-main:
name: Sync main to gitcode (mirror)
runs-on: ubuntu-latest
needs: [go, race, cross, gui, csrc, docs]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
timeout-minutes: 10
env:
# ══════════════════════════════════════════════════════════════════
# ★★ gitcode 镜像目标仓名 —— **本 job 的唯一定义处**。
# 改 gitcode 仓名时只改这一行;下面所有消费点都走 ${GC_REPO}。
# 与 release.yml 的 sync-gitcode 保持同值(两个 workflow 各自独立
# 定义:改的时候两个文件都要改,但每个文件内部只有一个定义处)。
#
# 取值依据(实测 2026-10-21,直连不带代理,非推测):
# https://gitcode.com/JianFeeeee/HologramAgent → 200 ← 当前用这个
# https://gitcode.com/JianFeeeee/HomeAgent → 301 ← 旧名,跳转到上面
# https://gitcode.com/JianFeeeee/hologramagentsdk → 200(SDK 仓,另一对象)
# https://gitcode.com/JianFeeeee/homeagentsdk → 404
#
# 背景:gitcode 侧镜像仓已由 HomeAgent 改名为 HologramAgent(与 GitHub
# 主仓同名)。旧地址仍 301 跳转,跟随后也能到达,但走重定向 ⇒ 别再用它。
# 判定两仓同一对象:两地址 git ls-remote 的 refs 列表 md5 相同。
#
# ⚠ 失败面:本 job 的 needs 是 [go, race, cross, gui, csrc, docs]。
# 前置检查 exit 1 ⇒ 本 job 红 ⇒ **main 的整条 CI 红**(不是只红一格)。
# release.yml 的 sync-gitcode 只红在镜像同步这一格(GitHub 侧产物已发)。
# ══════════════════════════════════════════════════════════════════
GC_REPO: JianFeeeee/HologramAgent
steps:
- id: tok
name: 检查 gitcode 凭据
run: |
if [ -n "${{ secrets.GITCODE_TOKEN }}" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo " 未配置 GITCODE_TOKEN —— 跳过 gitcode main 同步"
fi
- uses: actions/checkout@v7
if: steps.tok.outputs.ok == 'true'
with:
# 必须取完整历史:镜像可能在任意旧提交上,快进需要共同祖先。
fetch-depth: 0
# ★ 改名(HomeAgent → HologramAgent)后的必然过渡态:gitcode 上的镜像仓
# 是**另一个账号下的独立仓库**,改 GitHub 仓名不会自动建出它。
# 若不在这里显式探测,缺少目标仓时只会得到 git 的原始报错
# ("repository not found" 混在 push 输出里),看不出是「仓没建」。
# 语义:token 未配置 = 可选项跳过(上面那步);token 有但仓不存在 = 硬失败。
#
# ★ 有界重试(2026-10-21 dfc4e16 的教训):首次跑这一步时 gitcode 网络抖动,
# 一次 ls-remote 判失败 ⇒ job 红;重跑立刻全绿。现在只对**网络层**失败
# 重试(3 次,退避 5s/10s),而「仓不存在」与「凭据问题」**立即失败不重试**
# —— 后两者重试只会把真问题拖长、并把它埋在噪声里。
#
# 实现落在 .github/scripts/check-gitcode-repo.sh,与 release.yml 的
# sync-gitcode **共用同一个文件**(同口径靠同一份代码保证,不靠人抄两遍)。
- name: 前置检查 gitcode 目标仓是否存在
if: steps.tok.outputs.ok == 'true'
env:
GC_TOKEN: ${{ secrets.GITCODE_TOKEN }}
# GC_REPO 来自 job 级 env(唯一定义处),此处不重复字面量。
run: |
set -euo pipefail
bash .github/scripts/check-gitcode-repo.sh
- name: 推 main 到 gitcode(快进)
if: steps.tok.outputs.ok == 'true'
env:
GC_TOKEN: ${{ secrets.GITCODE_TOKEN }}
run: |
set -euo pipefail
GC_URL="https://JianFeeeee:${GC_TOKEN}@gitcode.com"
# GC_REPO 来自 job 级 env(唯一定义处),此处不重复字面量。
git push "${GC_URL}/${GC_REPO}.git" \
"refs/heads/main:refs/heads/main"
echo " gitcode/main 已快进到 ${GITHUB_SHA}"