Files
HomeAgent/.github/workflows/ci.yml
JianFeeeee 0e5077497e fix(csrc): clang 交叉需要 aarch64 libc 头 —— 只装 1.7MB 头包,不装 10MB 编译器
## CI 实测暴露的下一层问题

上一版让 clang 做交叉编译后,安装步骤成功且**完全跳过 apt**(cmake/clang 都在
镜像里),但 `make check-csrc` 红在:

    In file included from /usr/lib/llvm-18/lib/clang/18/include/stdint.h:52:
    /usr/include/stdint.h:26:10: fatal error: 'bits/libc-header-start.h' file not found

真因:`clang --target=aarch64-linux-gnu` 切换的是**目标架构**,但系统头
仍按**宿主**(amd64)找 —— `bits/libc-header-start.h` 是 amd64 的布局,
aarch64 目标下不该用。本地能过是本机装了完整交叉 GCC,带来了
`/usr/aarch64-linux-gnu/include`。

## 试过但走不通的两条路(记录以免重复踩)

| 尝试 | 结果 |
|---|---|
| `-nostdinc` + clang 内建头 | ❌ clang 内建**不含 `string.h`**(那是 libc 的) |
| `-ffreestanding -nostdlibinc` | ❌ 同上,代码实际用了 `strlen`/`memcpy` |

## 采用:只装 libc 头,不装编译器

    libc6-dev-arm64-cross     1.7 MB(+ libc6-arm64-cross、linux-libc-dev-arm64-cross)
    gcc-aarch64-linux-gnu     含 cpp-13(10.8MB)+ gcc-16 等,实测下载卡死

既然只需要**头文件**(不做链接、不跑代码),就不该拉整个交叉 GCC。
装上头后 clang 就能完成 `-fsyntax-only`。

### Makefile 配合

`csrc-cross` 的 clang-target 模式现在自动探测并注入 aarch64 头路径:

    for d in /usr/aarch64-linux-gnu/include /usr/include/aarch64-linux-gnu; do
        [ -d "$d" ] && ARM64_SYSINC="$ARM64_SYSINC -isystem $d"
    done

探测改用**真实头文件**(`#include <string.h>`)而非空 main,这样
"clang 版本支持 --target" 与 "aarch64 头是否齐备" 两个条件一起验;
缺头时明确 SKIP 并说清装哪个包(`libc6-dev-arm64-cross`)。

## 本地实测

| 验证 | 结果 |
|---|---|
| 有 aarch64 头时 | ✅ clang-target 通过(0 告警) |
| `make check-csrc` 全门禁 | ✅ 全部通过,0 SKIP |
| YAML 可解析 | ✅ |
| 上一版三条失败路径(编译器不存在/真写坏/正常) | ✅ 仍然正确(PIPESTATUS 修复未回退) |
2026-10-22 23:59:37 +08:00

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