# 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 的真数据竞争(**待修业务代码**) # # 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),修好后: ║ # ║ · 删掉本表里 kernelmod/all 那一行 ║ # ║ · **复跑一次** -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/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 [ -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 模糊匹配 —— 否则 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 混过去**(那是"假装没红")。 # 做法相反:显式断言两件事都成立,任一不成立 ⇒ 这一步**真红**: # ① 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 排除表删掉 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}"