Files
HomeAgent/.github/workflows/ci.yml
JianFeeeee 1aec7688a6 ci: 加固 C 门禁 —— 卡死不再拖满超时,且缺工具不再假装通过
## 两个独立问题

### ① 卡死:20 分钟全花在 apt-get,不是门禁

实测证据:本机 `make check-csrc` 五个子门禁**各自 0~1 秒**:

    csrc-lint 1s / csrc-abi 0s / csrc-headers 0s / csrc-sanitize 0s / csrc-cross 0s

而 CI 里同一个 job 两次跑到 20 分钟 job 级超时(其中一次直接 cancelled)。
卡死点只可能在 `apt-get update` —— ubuntu-latest runner 上偶发挂住不返回,
既不失败也没进展,只能等 job timeout,而报错信息毫无指向性。

### ② 假通过:缺工具时门禁 `[SKIP] ... exit 0`

这是更危险的一个。Makefile 里每道门禁在缺依赖时:

    if ! command -v $CC_ARM64 >/dev/null 2>&1; then
        echo "  [SKIP] $CC_ARM64 不存在(未装交叉工具链)"; exit 0
    fi

本机这样合理(开发者未必装交叉工具链),但在 CI 里意味着:apt-get 悄悄失败
⇒ 五道门禁全部跳过 ⇒ **job 绿** ⇒ 让人以为 C 侧有门禁,实际一个都没跑。

**静默降级比红更危险:红会有人修,绿没人看。**

实测确认(`CC_ARM64=nonexistent-gcc-xyz make csrc-cross`):

    == C 侧交叉编译(linux/arm64)==
      [SKIP] nonexistent-gcc-xyz 不存在(未装交叉工具链)
    --- 退出码: 0 ---        ← 这就是问题

## 加固内容

| 措施 | 作用 |
|---|---|
| 逐包检查是否已安装 | runner 镜像通常自带 cmake/clang ⇒ **多数情况完全不碰网络** |
| apt-get 自带 `Acquire::*Timeout=60` + `Retries=3` | 卡住时自身失败,而不是拖到 job 级超时(后者报错无指向性) |
| 装包 step `timeout-minutes: 6` | 双保险 |
| 新增「确认工具链齐备」步骤 | 缺任一工具 ⇒ 红,并 `::error::` 说明"缺工具时门禁会 SKIP 等于假装通过" |
| 跑门禁 step `timeout-minutes: 5` | 门禁实测 0~1 秒,超出即异常(不是慢) |
| `grep '\[SKIP\]'` 二次拦截 | 覆盖将来新增门禁时漏配依赖的情况 |
| job `timeout-minutes` 20 → 15 | 已经是纯 apt-get 余量 |

## 为什么要两道 SKIP 拦截

「确认工具链齐备」+「grep [SKIP]」看似重复,其实针对不同风险:

- 前者:**当前**三道门禁的依赖齐不齐
- 后者:**将来**新增门禁时,新依赖没写进安装步骤 —— 那时前者不知道要检查它,
  只有日志里的 `[SKIP]` 能抓到

## 本地实测(逐条验证,不是只改完就推)

| 验证 | 结果 |
|---|---|
| YAML 可解析 | ✅ |
| 三件套本机齐备 | ✅ cmake / clang / aarch64-linux-gnu-gcc |
| `make check-csrc` 无 SKIP | ✅ 五门禁全实际执行 |
| 已装则跳过 apt-get 分支 | ✅ 「三件套已存在,跳过 apt-get」 |
| 部分缺失时拼装需要安装列表 | ✅ 正确识别出 `clang` |
| **变异:交叉工具链缺失** | ✅ grep 拦截生效(若不加这层,CI 会绿) |
2026-10-22 23:39:21 +08:00

377 lines
17 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 ./...
# ./... 不点名 cmd/gui(该目录是纯 Electron,无 .go 文件):
# 显式 `go test ./cmd/gui` 会报 "no Go files",那是误报,不是缺陷。
- name: go test ./...
run: go test ./... -count=1 -timeout 20m
# 跨平台客户端版本一致性:内核 internal/meta 是唯一事实源,
# GUI(package.json) / 鸿蒙(AppScope/app.json5) / waiter 都必须跟它一致。
- name: 客户端版本一致性
run: make check-client-versions
# ── 竞态检测(并发改动的主要防线)──
race:
name: Race detector
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: go test -race(并发核心)
run: |
go test -race \
./internal/agent/core/ ./cmd/waiter/ \
-count=1 -timeout 15m
# ── 交叉编译:可在无 cgo 下构建的客户端/工具 ──
#
# 只有这三个 cmd 支持纯交叉编译。另外三个依赖 cgo(gojieba / onnx):
# homed / memgc / homed-kb-migrate → internal/memory(gojieba)
# homed → internal/agent/api(onnx)
# 它们必须在原生平台构建(见 Makefile 的 build target)。
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"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- 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 waiter initconfig mock-server; 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 工具链(已装则跳过)
timeout-minutes: 6
run: |
set -uo pipefail
need=""
command -v cmake >/dev/null || need="$need cmake"
command -v clang >/dev/null || need="$need clang"
command -v aarch64-linux-gnu-gcc >/dev/null || need="$need gcc-aarch64-linux-gnu"
if [ -z "$need" ]; then
echo "✅ 三件套已存在,跳过 apt-get:$(command -v cmake clang aarch64-linux-gnu-gcc | tr '\n' ' ')"
exit 0
fi
echo "需要安装:$need"
# apt-get 自己的超时:卡住时 4 分钟内失败,而不是拖到 6 分钟 step 超时。
# 重试一次 —— runner 网络偶发抖动不该让门禁变红。
sudo apt-get -o Acquire::http::Timeout=60 \
-o Acquire::https::Timeout=60 \
-o Acquire::Retries=3 \
update -qq
# shellcheck disable=SC2086
sudo apt-get -o Acquire::http::Timeout=60 \
-o Acquire::https::Timeout=60 \
-o Acquire::Retries=3 \
install -y -qq --no-install-recommends $need
# ── 缺工具即失败(★ 这条是本次加固的核心)────────────────────
#
# 此前门禁在缺工具时打 `[SKIP] ... exit 0` —— **CI 照样变绿**。
# 那意味着:apt-get 悄悄失败(或将来有人删掉安装步骤)⇒ 五道门禁
# 全部跳过 ⇒ job 绿 ⇒ 让人以为 C 侧有门禁,实际一个都没跑。
#
# 静默降级比红更危险:红会有人修,绿没人看。
# 所以 CI 里显式要求工具必须在场,SKIP 视为失败。
- name: 确认 C 工具链齐备(缺则失败,不接受 SKIP)
run: |
set -euo pipefail
miss=""
for t in cmake clang aarch64-linux-gnu-gcc; 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}"