mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
check-ci-gates.py: TABLE 捕获体 `(.*?)` 允许空串,表被删空时静默走「无排除项」分支。 现在 ci.yml 里加显式空表标记校验(while 跳过 # 注释行),门脚本补正反自测: 合法 `# none` 必过、完全空体/只空行/非显式注释必红。 ID:18: bootgraph_test.go 失败面引用改为按区分词定位,不再依赖包名/行号。
746 lines
38 KiB
YAML
746 lines
38 KiB
YAML
# 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}"
|