# 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 工具链(已装则跳过) # 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}"