From de890aadd65351d61bb6b78eb863ac2326693944 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 29 Sep 2026 13:21:08 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20=E6=8A=8A=20CI=20=E4=B8=8E=20Release=20?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF=E5=B8=A6=E5=88=B0=E6=9C=AC=E6=9D=A1?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub 用**被推送 commit 里的** .github/workflows/*.yml 决定是否触发, 所以 workflow 文件必须存在于发布分支本身,否则推 release/** 不会发版。 只带 workflow 定义,不带 main 上的其它未发布改动。 --- .github/workflows/ci.yml | 199 +++++++++++++++++++++++ .github/workflows/release.yml | 294 ++++++++++++++++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e99aaf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,199 @@ +# HomeAgent 主仓 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。 + 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 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + # clang 供双编译器告警对照;gcc-aarch64 供跨架构编译门禁。 + # 门禁在缺工具时是显式 SKIP 而不是假通过,这里装齐以免静默降级。 + - name: 安装 C 工具链 + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq cmake clang gcc-aarch64-linux-gnu + + - name: make check-csrc + run: make check-csrc + + # ── 文档站构建(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-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/,跳过" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1129ec7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,294 @@ +# 发布流水线:release/** 分支推送即发版。 +# +# 设计依据 docs/git-branching.md §七(发版产物清单)与 git-release-discipline +# skill。核心事实:**推 tag ≠ 完成发版** —— 完整发版是四件事: +# bump meta.Version → 打 tag → 打包产物 → 建 release 条目并上传附件。 +# (v1.3.1–v1.3.6 曾只推了 tag,产物与 release 条目全缺,事后补做。) +# +# 版本号来源:internal/meta/meta.go 的 Version(唯一事实源)。 +# 所以发版动作 = 在 release/vX.Y.x 上把 meta.Version 改成目标版本后推送。 +# 版本未变的推送(如改文档)会因 tag 已存在而**整轮跳过**,不会重复发版。 +name: Release + +on: + push: + branches: ['release/**'] + workflow_dispatch: + +# 发布必须能写仓库(打 tag、建 release、传附件)。 +permissions: + contents: write + +# 发布不允许并发/取消:半途中断会留下 tag 存在但附件不全的状态。 +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + # gojieba 需要 cgo;onnxruntime 版本经 dlopen 加载,编译期无需装 ORT。 + CGO_ENABLED: 1 + GOFLAGS: -buildvcs=false + # CI 用的大资产(模型/运行库)存于这个 release。 + ASSETS_TAG: ci-assets-v1 + +jobs: + # ── 读版本号并判断是否需要发版 ── + prepare: + name: Prepare + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.ver.outputs.version }} + tag: ${{ steps.ver.outputs.tag }} + prerelease: ${{ steps.ver.outputs.prerelease }} + exists: ${{ steps.ver.outputs.exists }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: ver + name: 读取 meta.Version 并检查 tag + run: | + set -euo pipefail + V=$(sed -n 's/^[[:space:]]*Version = "\(.*\)"/\1/p' \ + internal/meta/meta.go | head -1) + if [ -z "$V" ]; then + echo "ERROR: 无法从 internal/meta/meta.go 读出 Version" + exit 1 + fi + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "tag=v$V" >> "$GITHUB_OUTPUT" + # SemVer 预发布(1.3.13-beta.1)⇒ release 标记为预发布 + case "$V" in + *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;; + *) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;; + esac + # 幂等闸门:tag 已存在说明该版本发过了,整轮跳过。 + if git ls-remote --exit-code --tags origin "refs/tags/v$V" \ + >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo " tag v$V 已存在 —— 跳过发版" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo " 将为 v$V 发版" + fi + + # ── 构建 Linux 产物(amd64)── + # + # 三个 deb + 一个 tar.gz,总约 2.4GB(server/full/tar 含 719MB 模型)。 + # 编译不需要 ONNX Runtime —— onnxruntime_go 是 dlopen 方式,运行期才加载 + # libonnxruntime.so;但**打包**需要它(要打进 deb),故从 ASSETS_TAG 下载。 + build-linux: + name: Build linux/amd64 + needs: prepare + if: needs.prepare.outputs.exists == 'false' + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + + - name: 确认 cgo 工具链 + run: | + gcc --version | head -1 + g++ --version | head -1 + + # 发版前的最后一道门:产物若建立在编译失败的代码上,发布了也没用。 + - name: go build + go test(发版前验证) + run: | + set -euo pipefail + go build ./... + go test ./... -count=1 -timeout 20m + + - name: 下载构建资产(模型 + ONNX Runtime) + run: | + set -euo pipefail + BASE="https://github.com/${GITHUB_REPOSITORY}/releases/download/${ASSETS_TAG}" + mkdir -p /tmp/assets/model /tmp/assets/ort + for f in chinese-clip-vit-b16-onnx.tar \ + onnxruntime-linux-amd64-1.28.0.tar SHA256SUMS; do + echo " 下载 $f" + curl -sSL --retry 3 -o "/tmp/assets/$f" "$BASE/$f" + done + # 校验(资产是构建输入,损坏会打出坏包) + (cd /tmp/assets && sha256sum -c SHA256SUMS) + tar -xf /tmp/assets/chinese-clip-vit-b16-onnx.tar \ + -C /tmp/assets/model + ORT_TAR=/tmp/assets/onnxruntime-linux-amd64-1.28.0.tar + tar -xf "$ORT_TAR" -C /tmp/assets/ort + echo " 模型文件:" + ls /tmp/assets/model/chinese-clip-vit-b16-onnx + echo " ORT 文件:" + ls /tmp/assets/ort + + - name: 打包(tar.gz + full/server/client deb) + env: + VERSION: ${{ needs.prepare.outputs.version }} + CHINESECLIP_BUNDLE_DIR: /tmp/assets/model/chinese-clip-vit-b16-onnx + ONNXRUNTIME_ASSET_DIR: /tmp/assets/ort + run: | + set -euo pipefail + bash deploy/packaging/package-linux.sh amd64 all + + - name: 平铺产物(附件必须同目录,SHA256SUMS 用平铺名) + run: | + set -euo pipefail + mkdir -p /tmp/out + cp dist/linux/deb/*.deb /tmp/out/ + cp dist/linux/tar/*.tar.gz /tmp/out/ + cp dist/linux/SHA256SUMS /tmp/out/ + echo " 产物:" + for f in /tmp/out/*; do + printf " %8.1fMB %s\n" \ + "$(stat -c %s "$f" | awk '{print $1/1048576}')" "$(basename "$f")" + done + + - name: 验证产物(deb 元数据 + 校验和自验) + run: | + set -euo pipefail + cd /tmp/out + for f in *.deb; do + echo " $f" + dpkg-deb -f "$f" Package Version Architecture | sed 's/^/ /' + done + # full/server 必须真的带模型,否则是"默认启用但装完不能用"的假包 + dpkg-deb -c homeagent-full_*_amd64.deb \ + | grep -q "chinese-clip-vit-b16-onnx/TextEncoder.onnx" + echo " ✓ full 包含模型" + dpkg-deb -c homeagent-full_*_amd64.deb \ + | grep -q "libonnxruntime.so" + echo " ✓ full 包含 ONNX Runtime" + sha256sum -c SHA256SUMS + + - uses: actions/upload-artifact@v7 + with: + name: linux-amd64 + path: /tmp/out/* + retention-days: 7 + if-no-files-found: error + + # ── 建 tag、建 release、上传附件 ── + publish: + name: Publish + needs: [prepare, build-linux] + if: needs.prepare.outputs.exists == 'false' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v8 + with: + name: linux-amd64 + path: dist + + - name: 打 tag(打在触发本次发版的 commit 上) + env: + TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "$TAG" + git push origin "$TAG" + + - name: 建 release 并上传附件 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + PRE: ${{ needs.prepare.outputs.prerelease }} + run: | + set -euo pipefail + cd dist + FLAGS=() + [ "$PRE" = "true" ] && FLAGS+=(--prerelease) + gh release create "$TAG" \ + --title "$TAG" \ + --notes "HomeAgent $VERSION + + 产物清单与校验见 SHA256SUMS。 + - \`homeagent_${VERSION}_linux_amd64.tar.gz\` — 内核 + CLI + GUI 打包 + - \`homeagent-client_${VERSION}_amd64.deb\` — 客户端 + - \`homeagent-server_${VERSION}_amd64.deb\` — 服务端(含向量模型) + - \`homeagent-full_${VERSION}_amd64.deb\` — 全量" \ + "${FLAGS[@]}" \ + ./*.deb ./*.tar.gz ./SHA256SUMS + echo "=== release 内容 ===" + gh release view "$TAG" --json assets \ + --jq '.assets[] | " \(.name) \(.size) 字节"' + + - name: 回读校验(下载回来验证附件可读且校验和成立) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -euo pipefail + mkdir -p /tmp/back + cd /tmp/back + gh release download "$TAG" + for f in *; do + printf " %8.1fMB %s\n" \ + "$(stat -c %s "$f" | awk '{print $1/1048576}')" "$f" + done + sha256sum -c SHA256SUMS + echo " ✓ 回读校验通过" + + # ── 同步到 gitcode(国内镜像)── + # + # 需要仓库 secret GITCODE_TOKEN;未配置则跳过(不阻断 GitHub 侧发布)。 + # gitcode 的 release 附件是"同名只写一次",故只在此处上传一次。 + sync-gitcode: + name: Sync to gitcode + needs: [prepare, publish] + if: needs.prepare.outputs.exists == 'false' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + - 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 同步" + fi + - uses: actions/download-artifact@v8 + if: steps.tok.outputs.ok == 'true' + with: + name: linux-amd64 + path: dist + - name: 推 tag 与附件到 gitcode + if: steps.tok.outputs.ok == 'true' + env: + GC_TOKEN: ${{ secrets.GITCODE_TOKEN }} + TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -euo pipefail + # 1) 推 tag(附件上传前 release 条目必须先存在) + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "$TAG" 2>/dev/null || true + GC_URL="https://JianFeeeee:${GC_TOKEN}@gitcode.com" + git push "${GC_URL}/JianFeeeee/HomeAgent.git" "$TAG" + # 2) 建 release 条目 + curl -sS --max-time 60 -X POST \ + -H "private-token: ${GC_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://gitcode.com/api/v5/repos/JianFeeeee/HomeAgent/releases" \ + -d "{\"tag_name\":\"$TAG\",\"body\":\"同步自 GitHub\"}" \ + -o /tmp/.gcrel -w " 建 release → %{http_code}\n" + # 3) 上传附件(用仓库既有脚本,它处理 OBS 预签名两步流程) + cd dist + python3 ../deploy/scripts/upload_assets.py "$TAG" "$GC_TOKEN" \ + ./*.deb ./*.tar.gz ./SHA256SUMS