From dc9e7d049521b2dab2279cc0d7b7ed36c3859e54 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 29 Sep 2026 14:09:35 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20SDK=20=E4=BB=93=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF=20=E2=80=94=E2=80=94=20release/**?= =?UTF-8?q?=20=E6=8E=A8=E9=80=81=E5=8D=B3=E5=87=BA=20hmapdev=20=E4=BA=94?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与主仓 Release 流水线同构,差异只在产物与打包命令: prepare 读 meta.Version;tag 已存在则整轮跳过;go test 门可显式跳过 (改 meta 的提交里写 [skip-release-tests],查该提交而非 HEAD) build go build(硬门)→ go test → build.sh all hmapdev → 验证 5 平台齐全 → 生成 SHA256SUMS → artifact publish 打 tag → gh release create 传附件 → 回读校验 sync-gitcode 有 GITCODE_TOKEN 时同步(无则跳过) 产物清单不是猜的,依 gitcode 上 v1.2.0/v1.3.0 的实际附件(各 6 个): hmapdev_{linux,darwin}_{amd64,arm64} + hmapdev_windows_amd64.exe + SHA256SUMS 两个易错点都有实测依据: 1. **测试要分两处跑**:tools/hmapdev 是**独立 module**,根模块的 `go test ./...` 不会进入它(Go 的模块边界,不是配置问题)。 2. **gitcode 上传必须显式列文件名**:上传脚本的路径语义是 `os.path.join(ASSET_DIR, name)`,所以要 cd 进目录 + `ASSET_DIR=.` + 裸名; 而它按扩展名识别产物的默认扫描对 hmapdev **无效** —— 五个产物里只有 windows 那个有扩展名,自动扫描会静默地一个都不传。 故把主仓的 upload_assets.py 一并纳入本仓 scripts/(两仓各自独立可取)。 本地已验证:`VERSION=1.4.0 bash package/build.sh all hmapdev` 产出 5 个 二进制(各 27–29M),根模块 go test 通过,actionlint 全绿。 --- .github/workflows/release.yml | 312 ++++++++++++++++++++++++++++++++++ scripts/upload-assets.py | 133 +++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/upload-assets.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..33ad6ec --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,312 @@ +# HomeAgent SDK 仓发布流水线:release/** 推送即发版。 +# +# 与主仓的 Release 流水线同构(见主仓 .github/workflows/release.yml), +# 差异只在产物与打包命令: +# +# 主仓 → 3 个 deb + 1 个 tar.gz(含 719MB 向量模型) +# SDK → hmapdev_{linux,darwin}_{amd64,arm64} + hmapdev_windows_amd64.exe +# + SHA256SUMS(约 140MB) +# +# 产物清单依据:gitcode 上 v1.2.0/v1.3.0 的实际附件(各 6 个), +# 以及 docs/git-branching.md §七 的「发版产物清单」。 +name: Release + +on: + push: + branches: ['release/**'] + workflow_dispatch: + inputs: + skip_tests: + description: '跳过发版前的 go test 门(仅用于已知红的历史维护线)' + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + CGO_ENABLED: 0 + GOFLAGS: -buildvcs=false + +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 }} + skip_tests: ${{ steps.ver.outputs.skip_tests }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # 版本事实源是 meta/meta.go 的 Version,故发版动作 = + # 在 release/vX.Y.x 上把它改成目标版本后推送。 + # + # 幂等闸门:tag 已存在 ⇒ 整轮跳过(改文档不会重发版)。 + # + # go test 门可跳过:新旧发布线的测试健康状况不同,硬门会让历史 + # 维护线完全无法发版。发版人可在**改动 meta 的那个提交**里写 + # [skip-release-tests] 显式跳过 —— 决定因此可被 git 历史审计。 + # (标记查在改 meta 的提交上而不是 HEAD:发版提交之后常还会跟 + # 几个提交,只看 HEAD 会让标记被顶掉、静默失效。) + - id: ver + name: 读取 meta.Version 并检查 tag + run: | + set -euo pipefail + V=$(sed -n 's/^[[:space:]]*Version = "\(.*\)"/\1/p' \ + meta/meta.go | head -1) + if [ -z "$V" ]; then + echo "ERROR: 无法从 meta/meta.go 读出 Version" + exit 1 + fi + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "tag=v$V" >> "$GITHUB_OUTPUT" + case "$V" in + *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;; + *) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;; + esac + 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 + + SKIP="${{ inputs.skip_tests }}" + REL_COMMIT=$(git log -1 --format=%H -- meta/meta.go) + REL_MSG=$(git log -1 --pretty=%B "$REL_COMMIT") + case "$REL_MSG" in + *'[skip-release-tests]'*) MARKER=1 ;; + *) MARKER=0 ;; + esac + echo " 发版提交: ${REL_COMMIT:0:12}" + if [ "$SKIP" = "true" ] || [ "$MARKER" = "1" ]; then + echo "skip_tests=true" >> "$GITHUB_OUTPUT" + echo " ⚠️ **已请求跳过发版前的 go test 门**" + else + echo "skip_tests=false" >> "$GITHUB_OUTPUT" + echo " 发版前会跑 go test 门" + fi + + build: + name: Build hmapdev + needs: prepare + if: needs.prepare.outputs.exists == 'false' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + + # 门:go build 是硬门;go test 可在发版 commit 里显式跳过。 + # + # 测试分两处跑 —— tools/hmapdev 是**独立 module**,根模块的 + # `go test ./...` 不会进入它(这是 Go 的模块边界,不是配置问题)。 + - name: go build(硬门) + run: | + set -euo pipefail + go build ./... + + - name: go test(根模块) + if: needs.prepare.outputs.skip_tests != 'true' + run: go test ./... -count=1 -timeout 15m + + - name: go test(tools/hmapdev 独立 module) + if: needs.prepare.outputs.skip_tests != 'true' + working-directory: tools/hmapdev + run: go test ./... -count=1 -timeout 15m + + - name: go test 被跳过(显式声明的后果) + if: needs.prepare.outputs.skip_tests == 'true' + run: | + echo "::warning title=go test 门已跳过::本次发版未跑 go test,产物可能建立在单元测试失败的代码上。" + + - name: 打包 hmapdev(5 个平台) + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + rm -rf build + bash package/build.sh all hmapdev + echo + echo " 产物:" + for f in build/*; do + printf " %8.1fMB %s\n" \ + "$(stat -c %s "$f" | awk '{print $1/1048576}')" "$(basename "$f")" + done + + # 逐个确认 5 个平台都产出了 —— 只查目录非空会漏掉"少了一个平台"。 + - name: 验证产物齐全 + run: | + set -euo pipefail + missing=0 + for f in hmapdev_linux_amd64 hmapdev_linux_arm64 \ + hmapdev_darwin_amd64 hmapdev_darwin_arm64 \ + hmapdev_windows_amd64.exe; do + if [ -s "build/$f" ]; then + echo " ✓ $f" + else + echo " ✗ 缺 $f" >&2 + missing=1 + fi + done + [ "$missing" = "0" ] || exit 1 + + # 校验和必须**全部产物齐全之后**一次算完(边打边算会漏包); + # 且只覆盖本批产物 —— build/ 可能残留上次的,故先清干净再建。 + - name: 生成 SHA256SUMS + run: | + set -euo pipefail + mkdir -p /tmp/out + cp build/hmapdev_linux_amd64 build/hmapdev_linux_arm64 \ + build/hmapdev_darwin_amd64 build/hmapdev_darwin_arm64 \ + build/hmapdev_windows_amd64.exe /tmp/out/ + cd /tmp/out + sha256sum hmapdev_* > SHA256SUMS + echo " SHA256SUMS:" + sed 's/^/ /' SHA256SUMS + sha256sum -c SHA256SUMS + + - uses: actions/upload-artifact@v7 + with: + name: hmapdev + path: /tmp/out/* + retention-days: 7 + if-no-files-found: error + + publish: + name: Publish + needs: [prepare, build] + if: needs.prepare.outputs.exists == 'false' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v8 + with: + name: hmapdev + path: dist + + - name: 打 tag + 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 "HomeAgent SDK $VERSION" \ + --notes "HomeAgent 插件 SDK $VERSION + + \`hmapdev\` 工具链(Linux / macOS / Windows,amd64 + arm64)。 + 校验见 SHA256SUMS。 + + 内核版本需与 SDK 的中版本对齐;协议不配套时插件握手会失败 + (魔数不匹配),此时升级内核或改用对应版本的 SDK。" \ + "${FLAGS[@]}" \ + ./hmapdev_* ./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 " ✓ 回读校验通过" + + sync-gitcode: + name: Sync to gitcode + needs: [prepare, publish] + if: needs.prepare.outputs.exists == 'false' + runs-on: ubuntu-latest + timeout-minutes: 30 + 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: hmapdev + 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 + 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-sdk.git" "$TAG" + 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-sdk/releases" \ + -d "{\"tag_name\":\"$TAG\",\"body\":\"同步自 GitHub\"}" \ + -o /tmp/.gcrel -w " 建 release → %{http_code}\n" + cd dist + # 脚本的路径语义是 os.path.join(ASSET_DIR, name) ⇒ + # 必须 cd 进资产目录、ASSET_DIR=.、并传**裸文件名**。 + # 传 "./x" 或 "dist/x" 都会拼成 dist/dist/x 而找不到文件。 + # + # 为何显式列名而不是让它自动扫描:自动扫描只认 ARTIFACT_SUFFIXES + # 里的扩展名,而 hmapdev 的产物多数**没有扩展名**(只有 windows + # 那个是 .exe)⇒ 自动扫描会静默地一个都不传。 + ASSET_DIR=. GITCODE_REPO=JianFeeeee/homeagent-sdk \ + python3 ../scripts/upload-assets.py "$TAG" "$GC_TOKEN" \ + hmapdev_linux_amd64 hmapdev_linux_arm64 \ + hmapdev_darwin_amd64 hmapdev_darwin_arm64 \ + hmapdev_windows_amd64.exe SHA256SUMS diff --git a/scripts/upload-assets.py b/scripts/upload-assets.py new file mode 100755 index 0000000..1e8211e --- /dev/null +++ b/scripts/upload-assets.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""上传 release 资产到 gitcode(两步:取签名 URL → PUT 到 OBS)。 + +用法: upload_assets.py [file...] +不传 file 时上传 dist/release/ 下全部发布产物。 + +环境变量: + GITCODE_REPO 目标仓库,默认 JianFeeeee/HomeAgent(SDK 仓传 JianFeeeee/homeagent-sdk) + ASSET_DIR 资产目录,默认 /dist/release + +为何两步:gitcode 的 release 附件不走 API 直传,而是先向 +`releases//upload_url` 要一个 OBS 预签名 URL(带 x-obs-* 回调头), +再把文件 PUT 到那个 URL。回调头必须原样透传,否则 OBS 收下了文件但 +gitcode 侧不会登记为 release 附件。 +""" +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +REPO = os.environ.get("GITCODE_REPO", "JianFeeeee/HomeAgent") +API = "https://gitcode.com/api/v5/repos" + +# 发布产物后缀。注意 Windows 安装器是 HomeAgent_v*_win64.exe, +# 与 bin/ 里的裸 .exe 靠 _win64.exe 后缀区分。 +ARTIFACT_SUFFIXES = ( + ".tar.gz", + ".zip", + ".deb", + ".rpm", + ".pkg", + "_win64.exe", + # 插件包。之前不在白名单里,会被静默跳过——而 release 本该带上它们, + # 否则用户要自己装 Go + hmapdev 逐插件构建(见 SDK 仓 scripts/build_plugin_bundles.sh)。 + ".hmap", + # 插件包汇总校验和(与 SHA256SUMS 同性质,独立文件免得混淆内核包与插件) + "SHA256SUMS.plugins", +) + + +def is_artifact(name: str) -> bool: + return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES) + +def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]: + q = urllib.parse.urlencode({"file_name": filename}) + url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}" + req = urllib.request.Request(url, headers={"private-token": token}) + with urllib.request.urlopen(req, timeout=30) as r: + data = json.loads(r.read()) + return data["url"], data.get("headers", {}) + + +def put_file(url: str, headers: dict, path: str) -> tuple[int, str]: + size = os.path.getsize(path) + with open(path, "rb") as f: + body = f.read() + req = urllib.request.Request(url, data=body, method="PUT") + for k, v in headers.items(): + req.add_header(k, v) + req.add_header("Content-Length", str(size)) + try: + # 大文件(Full 变体安装包近 100MB)给足超时。 + with urllib.request.urlopen(req, timeout=900) as r: + return r.status, r.read().decode("utf-8", "replace")[:300] + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace")[:300] + except Exception as e: # noqa: BLE001 + return 0, f"{type(e).__name__}: {e}" + + +def project_root() -> str: + """向上找带 go.mod 的目录作为仓库根。 + + 为何不数 dirname:本脚本初版在 scripts/(深度 1),移到 deploy/scripts/ + (深度 2)后写死的两层 dirname 就指向了 deploy/dist/release,上传直接 + FileNotFoundError。这正是 v0.7.2 那次 package/ → deploy/packaging/ 打断 + PROJECT_ROOT 的同一个坑,改成按标记文件定位以后怎么挑位置都不会错。 + """ + d = os.path.dirname(os.path.abspath(__file__)) + while d != os.path.dirname(d): + if os.path.exists(os.path.join(d, "go.mod")): + return d + d = os.path.dirname(d) + # 实在找不到(脚本被单独拷出仓库)就回退到 cwd,给 ASSET_DIR 一个机会 + return os.getcwd() + + +def main() -> int: + if len(sys.argv) < 3: + print(__doc__) + return 2 + tag, token = sys.argv[1], sys.argv[2] + outdir = os.environ.get("ASSET_DIR") or os.path.join( + project_root(), "dist", "release" + ) + if not os.path.isdir(outdir): + print(f"error: 资产目录不存在: {outdir}") + print(" 用 ASSET_DIR=<目录> 显式指定,或先跑构建生成 dist/release/") + return 2 + files = sys.argv[3:] or sorted( + f for f in os.listdir(outdir) if is_artifact(f) + ) + if not files: + print(f"error: {outdir} 下没有可识别的发布产物") + return 2 + print(f"repo={REPO} tag={tag} dir={outdir}", flush=True) + failed = [] + for name in files: + path = os.path.join(outdir, name) + if not os.path.isfile(path): + print(f"skip (missing): {name}", flush=True) + continue + mib = os.path.getsize(path) / 1048576 + print(f"==> {name} ({mib:.1f} MiB)", flush=True) + try: + url, headers = get_upload_url(tag, token, name) + except Exception as e: # noqa: BLE001 + print(f" upload_url FAILED: {e}", flush=True) + failed.append(name) + continue + status, body = put_file(url, headers, path) + ok = 200 <= status < 300 + print(f" PUT -> {status} {'OK' if ok else body}", flush=True) + if not ok: + failed.append(name) + print(f"\n{'ALL OK' if not failed else f'{len(failed)} FAILED: ' + ', '.join(failed)}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())