Files
HomeAgent/.github/workflows/ci.yml
JianFeeeee 599252f9b0 fix(ci): ID:19 空表假绿堵口 —— 空表须显式声明 # none,删空与有意清空可区分
check-ci-gates.py: TABLE 捕获体 `(.*?)` 允许空串,表被删空时静默走「无排除项」分支。
现在 ci.yml 里加显式空表标记校验(while 跳过 # 注释行),门脚本补正反自测:
合法 `# none` 必过、完全空体/只空行/非显式注释必红。

ID:18: bootgraph_test.go 失败面引用改为按区分词定位,不再依赖包名/行号。
2026-10-25 07:56:45 +08:00

746 lines
38 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.

# HologramAgent 主仓 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 ./...
# ── CI 配置门禁自检(.github/scripts/check-ci-gates.py)──
#
# ## 为什么 CI 要检查自己的配置文件
#
# ci.yml 是「静默退化」的高发区:把 race 门的动态枚举改回写死包列表,
# 流水线照样绿(只是罩得少了),没人会发现。该脚本用 yaml.safe_load
# 解析(丢掉注释 ⇒ 「注释里写一句同名机制」顶不了包),断言这些仍在:
# · race 门动态枚举 + 真剔除 cmd/gui + go test -race 真吃了枚举结果
# · -skip 排除表每项都写了原因(缺原因那一步自己会失败)
# · go job 的「整机启动图判据」步骤还在、且带 -count=1
# · cross job 的 cmd 清单是实测枚举、构建真吃枚举结果
# · 每个 run 步骤的 bash 语法
#
# ★ 位置刻意放在 go test 之前:它是纯静态检查(只读 ci.yml,秒级,
# 无网络、无 cgo、不建库不起端口),而 go test ./... 要跑 20 分钟。
# 门禁本身坏掉应该第一时间红,不该等全量测试跑完才发现。
#
# ★ 不假设 runner 自带 pyyaml:先探测、缺了才装(幂等,几秒)。
# 「我以为 runner 有」正是假红/假绿的来源,代价比显式装一行高得多。
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: CI 配置门禁自检(动态枚举 / 排除表 / 启动图判据不许静默退化)
run: |
set -euo pipefail
if ! python3 -c 'import yaml' 2>/dev/null; then
echo " pyyaml 缺失,安装"
python3 -m pip install --quiet pyyaml
fi
python3 -c 'import yaml; print(" pyyaml", yaml.__version__)'
python3 .github/scripts/check-ci-gates.py
# ./... 不点名 cmd/gui(该目录是纯 Electron,无 .go 文件):
# 显式 `go test ./cmd/gui` 会报 "no Go files",那是误报,不是缺陷。
- name: go test ./...
run: go test ./... -count=1 -timeout 20m
# 整机启动图判据(internal/kernelmod/all/bootgraph_test.go)。
#
# ## 为什么单列一步(而不是只靠上面的 go test ./...)
#
# 这组判据用**真实模块登记表**装配整机(17 个真模块,全部真 Init),
# 会真的建 SQLite、起 HTTP server、mount overlay、起蒸馏/轮询循环。
# 把它单列出来有两个作用:
# ① 失败时一眼看到是"整机装配"这一层坏了,而不是混在 93 个包的输出里
# ② 它断言的是**跨模块的顺序与资源回收**,是"多跑几次才暴露"的那类,
# 单独一步便于在 flaky 时被识别(这里带 -count=1,不吃缓存)
#
# ★ 判据宿主是 internal/kernelmod/all(真实聚合入口),不是以前的
# internal/kernelboot/modules(那个包零生产调用,已删除)。
#
# ★ 不用 -race(那在 race job 里跑):本判据含真实端口/挂载,
# -race 的插桩会让时序假设变松,两者分开放更便于定位。
- name: 整机启动图判据(真实登记表装配 + 逆序关闭 + 无残留)
run: |
go test ./internal/kernelmod/all/ \
-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 的 gzip 数据竞争(已修)
#
# TestOpenAIStreamIsActuallyStreaming 曾因真竞争被排除在本门外,2026-10-24
# 已解除 skip:7457b05 改成 w 单写者模型(未给 gzip.go 加锁)修掉该竞争。
# 现由主 `go test -race` 门覆盖(排除表里已无该条目)。
#
# ## 已修复的偶发失败:core 的嵌套压力用例(不是竞争)
#
# internal/agent/core: TestStress_NestingReachesStructuralBoundThenUnwindsLIFO
# 曾经偶发红(单独 -run + -race 时 5/12 失败),根因不是数据竞争(从未有过
# DATA RACE 报告),而是**用例自身的固定时间假设**:注入时只等"受害者已在
# 运行",但 a.cancelLLM 是在进入 Chat **之前**才装上的,两者之间存在空档,
# 中断落在空档里就取消不生效,受害者跑满延迟自然结束,栈深永远到不了目标值
# ("第 N 级注入后栈深未达 N:0")。
# 2026-10-24 已把判据改成「等状态」(ed61a8d):先等受害者真的进入可取消的
# LLM 调用(entered 信号)再注入,注入后轮询等栈深达成;延迟参数未动。
# 改后 -race 连跑 10 轮 10/10 PASS,12 路满载下 3/3 PASS。
# 回归时用它复查:
# 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),修好后: ║
# ║ · 删掉本表里 kernelmod/all 那一行 ║
# ║ · **复跑一次** -race 全仓确认整机判据在 -race 下也稳定 ║
# ║ · 删掉 evidence 步骤 ② 的静态断言 ║
# ║ 这三步没做完,本线不算收口。 ║
# ╚══════════════════════════════════════════════════════════════════╝
- name: 计算 -race 排除表并打印
id: skip
run: |
set -euo pipefail
# 列名:粒度|目标|原因|修复单号 (用 | 分隔,原因内可含空格)
read -r -d '' TABLE <<'EOF' || true
pkg|internal/kernelmod/all|*(整包)|该包把真实整机装起来(bootgraph_test.go 的整机判据) ⇒ 第 4 层 agent 与 plugin-host 并行 Init 时,双方都无锁直写同一 plugin.Registry 的 7 个 Set*(SetLuaVM/SetBaseAPIKey/SetSupervisor/SetTracker/SetConfig/SetStageHost/SetIndexer,registry.go:231-284)。竞争在**内核装配**里,不在本包代码里;任何装整机的用例都会撞上。★ 目标包随 ID:13 迁到此(原 internal/kernelboot/modules 已删)|KERNEL-REGISTRY-SETLOCK
EOF
tests=""; pkgs=""; n=0
echo "════════ -race 排除表 ════════"
while IFS='|' read -r grain target test reason ticket; do
# ★ 注释行跳过:空表用一行 `# none` 显式声明(见本步骤开头与下方校验)。
# 先在里出现 `#` 开头的一行时,否则会被当成粒度 `# none` 报
# "粒度必须是 test 或 pkg"—— 标记本身反而把 step 撞红。
case "${grain}" in \#*) continue ;; esac
[ -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"
# ★ 空表必须**显式声明**:heredoc 里保留且只保留一行 `# none`。
# 为何不能允许"什么都没写":那样"表被删空"与"有意清空"无法区分,
# 而门脚本(check-ci-gates.py)的正则捕获到空串后,会静默走下面的
# "无排除项"分支 —— 排除项漏删/误删都不会有任何人发现(假绿,ID:19)。
if [ "$n" -eq 0 ] && ! grep -qEi '^[[:space:]]*#[[:space:]]*none[[:space:]]*$' <<< "$TABLE"; then
echo "::error::-race 排除表为空却没有显式空表标记 —— 请在上面的 heredoc 里保留一行 \`# none\`"
exit 1
fi
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 模糊匹配 —— 否则 kernelmod 会把 kernelmod/all 一起剔掉)。
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 混过去**(那是"假装没红")。
# 做法相反:显式断言这件事成立,不成立 ⇒ 这一步**真红**:
# 内核那 7 个 Set* **确实还无锁**(形态仍是单行直写)
# 修好之后这条会自然失败,提醒去删排除表 —— 这是**期望的**失败,
# 报错信息里直接写了该做什么。
# (webui 那条排除项已于 2026-10-24 撤除:7457b05 改成 w 单写者模型,
# 没给 gzip.go 加锁 ⇒ 原静态判据 sync_fields/lock_calls 两值恒为 0,
# 判据永不触发,该证据从此只会假绿 ⇒ 故连同排除行一并删除。)
# 撤除时本地核对(原文,不作为判据):该用例在 -race 下单跑 5 次,
# 均 0 条 WARNING: DATA RACE 报告;但用例本身未正常退出
# (SIGQUIT: quit / signal arrived during execution,耗时 11~30s,
# 退出码非 0),故此数据不能当"已修"的证据。
# ★ 不留"跑 5 次 0 条 DATA RACE"这类替代证据步骤:排除项已删、
# 该用例已回到主 go test -race 门里跑,再单独留一次"跑 N 次 0 条"
# 等于拿偶发概率充判据——正是本次撤除要清掉的坏判据,换个地方
# 再种一遍没有意义。
- name: 已知红的可核对证据(修好后本步会红,提醒删排除项)
run: |
set -uo pipefail
fail=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 排除表删掉 kernelmod/all 那一行"
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
# 门禁本身实测 0~1 秒(csrc-lint/abi/headers/sanitize/cross 各自 <1s)。
# 这 15 分钟全部是给 apt-get 的余量 —— 卡死历来只发生在装包阶段。
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
# ── 安装 C 工具链 ─────────────────────────────────────────────
#
# ★ 加固点(2026-10-22):实测两次该 job 卡死 20 分钟并超时,
# 而**本地同一门禁 0~1 秒全通过**(make check-csrc)。卡死点全在
# apt-get:ubuntu-latest runner 上 `apt-get update` 偶发挂住不返回,
# 既没有失败也没有进展,只能等 job 级 timeout。
#
# 三重加固:
# 1. 逐包检查是否已安装 —— runner 镜像通常自带 cmake/clang,
# 已装就跳过 apt-get(多数情况直接走这条,完全不碰网络)
# 2. 需要装时给 apt-get 加自身的超时与重试,让它**自己失败**,
# 而不是把整个 job 拖到 job 级 timeout(后者报错信息毫无指向性)
# 3. 装完立即 `command -v` 复核,缺工具就红 —— 见下方"缺工具即失败"
- name: 安装 C 工具链(已装则跳过)
# 120s(update) + 180s(install) = 300s,给 5 分钟正好;
# 超出说明 timeout 命令本身没生效,此时应当红而不是继续等。
timeout-minutes: 5
run: |
set -uo pipefail
# ★ 不再需要 gcc-aarch64-linux-gnu(2026-10-22)。
#
# 实测:装它要从源拉 10MB+ 的 cpp-13 等依赖,在 runner 上卡住超时
# (15:47:39 开始下 cpp-13-aarch64,180s 后仍未完成)。
# 而 csrc-cross 只需要 `-fsyntax-only`,clang 自带交叉前端即可:
# `clang --target=aarch64-linux-gnu` 会真的切到 aarch64 目标
# (__aarch64__ 宏生效、指针宽度按该架构算),足以暴露本门禁要抓的
# 架构问题。Makefile 的 csrc-cross 已按
# CC_ARM64 > clang --target > aarch64-gcc > SKIP 的优先级挑选。
need=""
command -v cmake >/dev/null || need="$need cmake"
command -v clang >/dev/null || need="$need clang"
# aarch64 的 libc 头(1.7MB,仅头文件,**不含编译器**)。
# 用它 + 已预装的 clang 交叉前端,替代 10MB+ 的完整交叉 GCC
# (实测那个会卡在下载 cpp-13-aarch64,180s 超时)。
[ -d /usr/aarch64-linux-gnu/include ] || need="$need libc6-dev-arm64-cross"
if [ -z "$need" ]; then
echo "✅ 依赖已存在,跳过 apt-get:$(command -v cmake clang | tr '\n' ' ')"
exit 0
fi
echo "需要安装:$need"
# ★ 2026-10-22 实测:加 Acquire::*Timeout=60 + Retries=3 之后仍然卡满
# 6 分钟 step 超时,且**日志里一个 apt 输出都没有** —— 因为 `-qq`
# 把一切静默了。卡死不可观测正是它拖满超时的原因:既看不到卡在哪个
# 源,也看不到到底在下载什么。
#
# 所以:
# · 去掉 `-qq`,保留可读进度(否则下次卡死仍然无从诊断)
# · 用 `timeout` 命令做外层硬超时 —— Acquire::Timeout 只管单个连接,
# 不管 apt 整体(源重试、DPkg 锁等待都不受它约束)
# · 失败后**不重试整段**:宁可红一次让人看见,也不要为偶发网络
# 抖动掩盖真实问题
APT_OPTS="-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire::Retries=2"
# shellcheck disable=SC2086
if ! timeout 120 sudo apt-get $APT_OPTS update; then
echo "::error::apt-get update 超时/失败(120s)—— runner 网络或源不可用"
exit 1
fi
# shellcheck disable=SC2086
if ! timeout 180 sudo apt-get $APT_OPTS install -y --no-install-recommends $need; then
echo "::error::apt-get install 超时/失败(180s)需要安装:$need"
exit 1
fi
# ── 缺工具即失败(★ 这条是本次加固的核心)────────────────────
#
# 此前门禁在缺工具时打 `[SKIP] ... exit 0` —— **CI 照样变绿**。
# 那意味着:apt-get 悄悄失败(或将来有人删掉安装步骤)⇒ 五道门禁
# 全部跳过 ⇒ job 绿 ⇒ 让人以为 C 侧有门禁,实际一个都没跑。
#
# 静默降级比红更危险:红会有人修,绿没人看。
# 所以 CI 里显式要求工具必须在场,SKIP 视为失败。
- name: 确认 C 工具链齐备(缺则失败,不接受 SKIP)
run: |
set -euo pipefail
miss=""
for t in cmake clang; do
if command -v "$t" >/dev/null 2>&1; then
echo " ✅ $t: $(command -v "$t")"
else
echo " ❌ $t 缺失" >&2
miss="$miss $t"
fi
done
[ -z "$miss" ] || {
echo "::error::C 工具链缺失:$miss —— 缺工具时门禁会 SKIP 并 exit 0,等于假装通过"
exit 1
}
# ── 跑门禁 ──────────────────────────────────────────────────
# 门禁自身 0~1 秒,给它 5 分钟绰绰有余;超出说明是异常(不是慢)。
- name: make check-csrc
timeout-minutes: 5
run: |
set -o pipefail
make check-csrc 2>&1 | tee /tmp/csrc.log
# ★ 二次确认:任何 [SKIP] 都视为失败。
#
# Makefile 里每道门禁在缺依赖时 `echo "[SKIP] ..."; exit 0`,
# 这在本机是合理的(开发者未必装了交叉工具链),但在 CI 里
# 就等于把"没验证"伪装成"通过"。上面那步已保证工具齐备,
# 这里再拦一次是为了覆盖将来新增门禁时漏配依赖的情况。
if grep -q '\[SKIP\]' /tmp/csrc.log; then
echo "::error::门禁出现 [SKIP],说明有检查没真正执行:"
grep '\[SKIP\]' /tmp/csrc.log >&2
echo "CI 里不接受 SKIP —— 要么装上依赖,要么明确从 check-csrc 摘掉这条。" >&2
exit 1
fi
echo "✅ 全部门禁实际执行,无 SKIP"
# ── 文档站构建(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/HologramAgent → 301 ← 旧名,跳转到上面
# https://gitcode.com/JianFeeeee/hologramagentsdk → 200(SDK 仓,另一对象)
# https://gitcode.com/JianFeeeee/homeagentsdk → 404
#
# 背景:gitcode 侧镜像仓已由 HologramAgent 改名为 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
# ★ 改名(HologramAgent → 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}"