跳到主要内容

发布流程

AI Switch 的发布完全由 GitHub Actions 驱动。推送一个符合规范的版本 tag,.github/workflows/release.yml 会自动完成校验、四个目标的构建、签名与发布。

触发条件

工作流只监听 tag 推送,且 tag 必须匹配 v*.*.*

yaml
on:
  push:
    tags:
      - "v*.*.*"

具体的格式校验比这个通配符更严格,实际正则要求 v<major>.<minor>.<patch>,可选带 -rc / -beta / -alpha 预发布后缀(后缀可再带 .<数字>)。合法示例:

text
v0.6.7
v0.6.7-rc.1
v0.7.0-beta
v1.0.0-alpha.2

并发组按 github.ref 划分且 cancel-in-progress: false——同一个 tag 的发布不会被后续推送打断。

三个作业

作业运行环境职责
prepareubuntu-latest校验 tag 与版本一致性、校验 tag 归属分支、创建草稿 Release 并生成发布说明
build矩阵:windows-latest / macos-latest / macos-15-intel / ubuntu-latest跑全量检查、构建 sidecar、打包 Tauri 安装包与独立服务器、上传产物
publishubuntu-latest汇总产物、生成并校验 latest.json、把草稿 Release 转为正式发布

依赖关系是 preparebuild(矩阵并行)→ publishbuild 设置了 fail-fast: false,某个目标失败时其余目标仍会跑完,方便一次看清所有问题。

prepare:发布前的两道闸门

1. 版本必须三处完全一致

tag 去掉 v 前缀后,必须与以下两个文件里的版本完全相同,包括预发布后缀

  • package.jsonversion
  • src-tauri/tauri.conf.jsonversion

校验逻辑分两步:先确认 package.jsontauri.conf.json 互相一致,再确认 tag 与它们一致。任何一步不匹配就直接 exit 1

也就是说,v0.6.7 这个 tag 要求两个文件里都写着 0.6.7v0.7.0-rc.1 要求两个文件里都写着 0.7.0-rc.1

常见踩坑

只改了 package.json 忘了改 tauri.conf.json(或反过来),是最容易触发这道闸门的原因。打 tag 前先确认两处都改了。另外 src-tauri/Cargo.tomlCargo.lock 中本项目自身的版本也应同步更新——虽然 CI 不校验它,但版本不一致会让构建产物的元数据错乱。

2. tag 必须基于默认分支

bash
git fetch origin "$DEFAULT_BRANCH"
tag_commit="$(git rev-list -n 1 "$GITHUB_REF_NAME")"
if ! git merge-base --is-ancestor "$tag_commit" "origin/$DEFAULT_BRANCH"; then
  echo "Tag ${GITHUB_REF_NAME} is not based on ${DEFAULT_BRANCH}."
  exit 1
fi

tag 指向的提交必须是默认分支的祖先。在功能分支上打 tag 并推送会被拒绝——这道检查防止未合并的代码被误发布。

3. 创建草稿 Release

通过上述校验后,用 ncipollo/release-action 创建草稿draft: true)Release 并自动生成发布说明(generateReleaseNotes: true)。是否标记为预发布由 tag 名判定:

yaml
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}

草稿状态很关键:构建期间用户不会看到一个残缺的 Release,只有 publish 作业成功后才会翻正。

build:四个目标的矩阵

标签RunnerBundle 格式
Windowswindows-latestnsis
macOS (Apple Silicon)macos-latestappdmg
macOS (Intel)macos-15-intelappdmg
Linuxubuntu-latestdebappimage

macOS 两行是两次独立的原生构建,不是一个 universal 包:arm64 的 dmg 在 Intel Mac 上完全跑不起来,Rosetta 也不管这个方向。macos-15-intel 是 GitHub 下线 macos-13 之后给 x86_64 留的标签,官方公告说它是 Actions 最后一个 Intel 镜像,可用到 2027 年 8 月;那之后要继续发 Intel 包,就得改成在 arm runner 上交叉编译 x86_64-apple-darwin。选原生而不是现在就交叉编译,是为了让 go testcargo test 和前端测试跑在真正会发出去的那个架构上。

每个目标的步骤顺序:

  1. 校验签名密钥 —— 如果 TAURI_SIGNING_PRIVATE_KEY 为空直接抛错,绝不产出未签名的更新器资源。

  2. Linux 系统依赖 —— 仅 Linux 执行 apt-get installlibwebkit2gtk-4.1-devlibayatana-appindicator3-devlibrsvg2-devpatchelflibgtk-3-devgcc-aarch64-linux-gnu)。

  3. 工具链 —— pnpm 10.12.4、Node 22(带 pnpm 缓存)、stable Rust、stable Go(按 sidecar/ai-switch-tsnet/go.sum 缓存)。

  4. 安装依赖 —— pnpm install --frozen-lockfile

  5. 计算平台变量 —— 从 rustc -vV 取 host 三元组,推导出更新器平台标识(windows-x86_64 / darwin-aarch64 / darwin-x86_64 / linux-x86_64)、APP_VERSION,以及 sidecar 与服务器二进制路径。整个 job 里没有一处写死架构:两个 macOS 目标共用同一份步骤,靠 RUNNER_ARCH 分流。

  6. 前端检查 —— pnpm typecheckpnpm test:runpnpm release:manifest:test

  7. 构建前端 —— pnpm build

  8. sidecar 测试与构建 —— go test ./...,然后 go build -trimpath -ldflags="-s -w" 输出到 src-tauri/binaries/ai-switch-tsnet-<三元组><后缀>,并校验文件确实存在。

  9. Rust 检查 —— pnpm rust:checkpnpm rust:test

  10. 打包 Tauri —— pnpm tauri build --ci --bundles <该平台的格式>

  11. 构建独立服务器 —— pnpm server:build:release

  12. 归集产物 —— 运行 scripts/stage-release-assets.mjs,把 src-tauri/target/release/bundle 下的安装包重命名为 ai-switch-<版本>-<平台>.exe 统一带 -setup 后缀),只有更新器会下载的 .app.tar.gz / .nsis.zip 命名为 ai-switch-updater-<版本>-<平台>.sig 跟随它签名的那个文件。deb 的中间产物(control.tar.gzdata.tar.gzdebian-binary)与 .app 目录内部一律跳过。没有安装包或没有 .sig 就直接报错。另外把服务器与 sidecar 二进制分别压成 ai-switch-server_<tag>_<平台>.zipai-switch-tsnet_<tag>_<平台>.zip

    命名不是随手取的:GitHub Release 页面按文件名排序,只展开前几个,其余折进「Show all N assets」。版本号紧跟 ai-switch- 能让安装包排在 ai-switch-server_ai-switch-tsnet_ 之前,用户要的 Windows .exe 和 macOS .dmg 才不会被折起来。<平台> 沿用更新器平台标识而不是更友好的 windows-x64,因为包管理器上架靠这个 token 挑安装包(见下文)。

  13. 上传产物 —— 以更新器平台标识为 artifact 名上传,if-no-files-found: error

注意 CI 会在每个目标上重跑全部检查。这意味着一次发布相当于跑四遍完整测试套件,任何平台或架构相关的问题都会在这里暴露。

更新器签名

Tauri 的更新器要求安装包附带 minisign 签名,签名密钥通过仓库 secret 注入:

Secret是否必需说明
TAURI_SIGNING_PRIVATE_KEY必需minisign 私钥。缺失时 build 作业第一步就抛错
TAURI_SIGNING_PRIVATE_KEY_PASSWORD可选私钥的口令,仅当私钥加了口令时才需要

对应的公钥硬编码在 src-tauri/tauri.conf.jsonplugins.updater.pubkey,更新端点指向:

text
https://github.com/ijry/ai-switch/releases/latest/download/latest.json

bundle.createUpdaterArtifacts: true 让 Tauri 在打包时额外产出更新器资源与 .sig 签名文件。

publish:清单生成与签名校验

  1. 下载全部产物 —— actions/download-artifact 把四个目标的 artifact 取到 release-assets/ 下,每个目标一个子目录(按更新器平台标识命名)。

  2. 还原发布说明 —— 把 prepare 作业的 release_notes 输出写回 release-notes.md。为空就报错退出。

  3. 生成更新器清单 —— 运行 scripts/create-updater-manifest.mjs,按平台子目录名识别目标平台,从预设的偏好顺序里挑出对应的更新器资源(Windows 优先 .exe.msi,macOS 优先 .tar.gz.dmg,Linux 优先 .AppImage.deb),产出 release-assets/latest.json

    bash
    node scripts/create-updater-manifest.mjs \
      --assets-dir release-assets \
      --tag "<tag>" \
      --repo "<owner/repo>" \
      --output release-assets/latest.json \
      --notes-file release-notes.md
  4. 校验签名密钥一致性 —— 运行 scripts/verify-updater-signatures.mjs,从清单里每个平台的签名载荷解出签名者 key ID,与 tauri.conf.jsonpubkey 解出的 key ID 逐一比对:

    bash
    node scripts/verify-updater-signatures.mjs \
      --manifest release-assets/latest.json \
      --tauri-config src-tauri/tauri.conf.json

    这道校验的意义是:如果签名密钥被轮换而配置里的公钥没跟着更新,已安装的客户端会验签失败、更新链路静默断裂。在发布前拦下来远比事后补救便宜。

  5. 删除 .sig —— 签名此时已经内联进 latest.json,客户端只读清单、不会去取同名 .sig,所以 find release-assets -name '*.sig' -delete,不再占用 Release 页面本来就不多的可见资产位。

  6. 生成 Release 正文 —— 运行 scripts/create-release-body.mjs,扫描各目标子目录里的安装包,在 tag 提交信息之前拼一张中英双语下载表(Windows / macOS Apple Silicon / macOS Intel / Linux 各一行,独立服务器另起一行),写入 release-body.md

    bash
    node scripts/create-release-body.mjs \
      --assets-dir release-assets \
      --tag "<tag>" \
      --repo "<owner/repo>" \
      --output release-body.md \
      --notes-file release-notes.md

    下载表只进 GitHub Release 正文。更新清单的 notes 仍然是原样的 tag 提交信息——桌面端按 29 个连字符的分隔线切分中英文,多一张表格会串进更新日志里。

  7. 转为正式发布 —— 再次调用 ncipollo/release-actiondraft: falsebodyFile: release-body.mdreplacesArtifacts: trueartifactErrorsFailBuild: true,上传 release-assets/**/*.* 下的全部文件(含 latest.json)。

发布操作示例

正式版

确认工作区干净、版本已同步、CI 在 main 上是绿的,然后:

bash
git tag -a v0.6.8 -m "Release v0.6.8"
git push origin main
git push origin v0.6.8

预发布版

tag 名带 -rc / -beta / -alpha 即自动标记为 prerelease:

bash
git tag -a v0.7.0-rc.1 -m "Release v0.7.0-rc.1"
git push origin v0.7.0-rc.1

记得 package.jsonsrc-tauri/tauri.conf.json 里也要写完整的 0.7.0-rc.1

打错了 tag 怎么办

如果 tag 还没推送,直接删掉重打:

bash
git tag -d v0.6.8

如果已经推送且工作流已失败,先删远端 tag 再重来。注意 GitHub 上可能已经留下一个草稿 Release,需要手动删除,否则 allowUpdates: true 会让下次运行复用它:

bash
git push origin :refs/tags/v0.6.8
git tag -d v0.6.8

推荐做法是在打 tag 前先本地跑一遍完整检查,见本地开发的"一次跑完所有检查"。CI 跑四个目标一次发布耗时不短,把可以本地发现的问题留到 CI 里发现很浪费时间。

发布产物一览

一次成功的发布会在 GitHub Release 上产出(按资产列表里的先后顺序):

  • Windowsai-switch-<版本>-windows-x86_64-setup.exe
  • macOSai-switch-<版本>-darwin-aarch64.dmg(Apple Silicon)与 ai-switch-<版本>-darwin-x86_64.dmg(Intel)
  • Linuxai-switch-<版本>-linux-x86_64.AppImageai-switch-<版本>-linux-x86_64.deb
  • Linux ARM64 独立服务器ai-switch-server_<tag>_linux-aarch64.zip 与对应 sidecar 归档
  • 每个目标ai-switch-server_<tag>_<平台>.zip(独立服务器)
  • 每个目标ai-switch-tsnet_<tag>_<平台>.zip(Tailscale sidecar)
  • macOSai-switch-updater-<版本>-darwin-aarch64.app.tar.gzai-switch-updater-<版本>-darwin-x86_64.app.tar.gz(只有自动更新会下载)
  • latest.json:Tauri 更新器清单,桌面端自动更新的数据源

.sig 不作为独立资产发布,签名内联在 latest.json 里。Release 正文顶部另有一张下载表,直接指向上面前三类文件。

用户如何拿到这些产物见安装,独立服务器的用法见独立服务器

发布到包管理器(Homebrew / WinGet)

.github/workflows/package-managers.yml已经发布的 Release 推给包管理器。它和 release.yml 故意分成两个工作流:winget 的提交要过 Microsoft 审核,Homebrew 推送可能因为 token 过期失败,这些都不应该把发版本身拖住或标成失败;反过来,补发一个几个月前的 tag 也不需要重新构建。

触发方式

触发行为
release.yml 发布完成自动运行:publish 作业最后一步按刚发布的 tag 派发本工作流
有人手工 publish 一个 Release自动运行,tag 取自事件
手动 workflow_dispatchtag 输入补发任意历史版本;留空则取最新一个 Release

release: published 事件对流水线发布的 Release 不会触发。GitHub 不会用自己的 GITHUB_TOKEN 产生的事件去启动新的工作流运行,而这个 Release 正是 release-action 用那个 token 发布的——v0.8.1 因此完全没跑过包管理器。workflow_dispatch 是这条规则的两个例外之一(另一个是 repository_dispatch),所以 release.yml 在发布之后显式派发一次,并且用 tag 而不是分支作为 ref,让清单由产出这批安装包的那个提交来渲染。派发本身失败只留一条 warning:已经发布的 Release 不该因为包管理器被标成失败,缺的那一次可以事后用同一个 tag 手动补跑。

手动运行另外有三个开关:homebrewwinget 可以分别关掉,dry_run 会渲染并校验清单、但不碰任何外部仓库。

dry_run 不需要任何 secret:它跳过的正好就是唯一读 secret 的那两处(推 cask 的 homebrew-push 作业、开 winget PR 的那一步),所以 tap 仓库和 token 都还没准备好时也能先完整预演一遍——包括在两种芯片的 Mac 上都真的 brew install --cask 装一次并断言隔离属性已清掉。对一个 ad-hoc 签名的包来说,这一次预演正是判断「Homebrew 这条渠道现在还通不通」的办法,值不值得去建 tap 仓库要看它的结果。

草稿和预发布(-rc / -beta / -alpha)一律跳过,并在日志里写明原因而不是报错:草稿的资产还没有公开下载地址,而预发布一旦进了 tap 就会推给所有执行 brew upgrade 的人。

需要配置什么

两条链路都要往别人的仓库里写东西,所以都需要一个仓库 secret。缺 secret 不会让工作流失败,只会在日志里留一条 warning 并跳过对应的那一条链路,另一条照常走。

Secret / Variable类型用途
HOMEBREW_TAP_TOKENsecret对 tap 仓库有 contents: write 的 PAT
HOMEBREW_TAP_REPOvariable,可选tap 仓库名,默认 ijry/homebrew-ai-switch
WINGET_TOKENsecretclassic PAT,只需 public_repo scope
WINGET_FORK_USERvariable,可选winget-pkgs fork 所在账号,默认仓库 owner

WinGet 的 token 必须是 classic PAT

提交 PR 的实际工具是 Komac,它走 GitHub 的 GraphQL API,而 fine-grained token 只能对「资源 owner 与 token owner 一致」的资源调用 GraphQL。目标是 microsoft/winget-pkgs,所以 fine-grained token 和 GitHub App 在这里都用不了。

Homebrew:一次性准备

  1. 建一个公开仓库 ijry/homebrew-ai-switch(名字必须以 homebrew- 开头,brew tap ijry/ai-switch 才能解析到它)。
  2. 生成一个对该仓库有 contents: write 的 PAT,存成 HOMEBREW_TAP_TOKEN

之后每次发布,homebrew 作业在 cask 声明支持的每一种芯片上各跑一遍:渲染好的 cask 放进一个本地 tap、真的 brew install --cask 装一遍,确认 /Applications/AI Switch.app 存在且隔离属性已被清掉。两边都过了,才由 homebrew-push 作业推到 tap 仓库(这一步只要 git 和 gh,跑在 ubuntu-latest 上)。对一个 ad-hoc 签名的包来说这道安装验证是必要的:cask 语法正确但装完打不开,是这类应用最容易出的问题。

跑几台 Mac 由 resolve 作业按渲染结果决定:Release 里有 darwin-x86_64 的 dmg 就是 macos-latestmacos-15-intel,没有就只有前者。补发 v0.8.2 及更早的 tag 会落进后一种——那些版本渲染出来的仍是 arm-only 的 cask,在 Intel runner 上装它只会被 depends_on arch: :arm64 正确拒绝,不该被当成失败。

cask 帮用户跳过了 Gatekeeper 那一套

macOS 包是 ad-hoc 签名、没有公证的(见安装里那一节),所以 cask 里带了一个 postflight,在 Homebrew 刚放好的那份 app 上执行 xattr -dr com.apple.quarantine。它只作用于那一个路径,不改任何系统设置,但用户不用再手动走「系统设置 → 仍要打开」。

cask 还声明了 auto_updates true(应用自带更新器会自己换掉 app,Homebrew 记录的版本本来就会过期)。两个架构都有 dmg 之后,depends_on 只剩下「这是个 macOS cask」这一层意思,架构限制已经去掉了。

WinGet:第一个版本必须手工提交

自动化只能做版本递增,第一次上架不行。winget-releaser 的第一步就是检查 microsoft/winget-pkgs 里有没有这个包,没有就直接报错:

text
::error::Package Lingyun.AISwitch does not exist in the winget-pkgs repository.
Please add atleast one version of the package before using this action.

所以一次性准备是三步:

  1. microsoft/winget-pkgs fork 到 ijry 名下(工具不会替你建 fork)。
  2. Komacwingetcreate 手工提交 Lingyun.AISwitch 的第一个版本,等 winget 的维护者合并。包标识符大小写敏感,且必须和目录路径完全一致(manifests/l/Lingyun/AISwitch/<版本>/)。
  3. 生成 classic PAT(public_repo),存成 WINGET_TOKEN

之后每次发布,工作流会开一个 PR 到 microsoft/winget-pkgsPR 需要 Microsoft 的维护者合并,版本才会真正对用户可见,这一步不在我们控制范围内。

安装包本身不需要代码签名

winget-pkgs 的策略文档里没有把代码签名列为要求,SignatureSha256 只对 MSIX/APPX 有意义。它的校验流水线关心的是别的东西:多引擎杀毒扫描、以非管理员身份静默安装、以及安装后的注册表项要和清单里的 Publisher / PackageName 对得上。

Tauri 的 NSIS 安装包默认是 currentUser 模式,不需要提权;InstallerType: nullsoft 也不用自己写静默参数——winget 客户端认出 nullsoft 后会自动用 /S

用户侧的安装命令

上面两处准备做完、并且各自的第一个版本已经落地之后,用户就可以这样装:

bash
# macOS(Apple Silicon 与 Intel 同一条命令,cask 自己认架构)
brew tap ijry/ai-switch
brew install --cask ai-switch
powershell
# Windows
winget install Lingyun.AISwitch

在那之前这两条命令都会找不到包,所以安装那一页仍然只写从 Releases 下载。

清单是怎么生成的

scripts/create-package-manifests.mjs 从 Release 的 API 响应出发,做三件事:

  1. 挑安装包。按更新器平台标识(darwin-aarch64darwin-x86_64windows-x86_64)加扩展名匹配,排除 .sig.app.tar.gz.nsis.zip 这些只有更新器会下载的资源。仓库先后用过两套资产命名,两套都能匹配;匹配到多于 1 个直接报错,不猜。darwin-x86_64 是唯一允许缺席的那个——v0.8.2 及更早的 tag 根本没有 Intel 构建,补发它们必须还能渲染出 cask。
  2. 取 sha256。Release API 现在对每个资产直接给出 digest,所以正常情况下不用把 31 MB 的 dmg 拉下来算哈希;老版本的资产没有这个字段,会退回下载并流式计算。
  3. 渲染 cask,并把 winget 需要的输入写进 summary.json。给 winget-releaserinstallers-regex 是用第 1 步已经确定的文件名生成的锚定正则,这样它自己再匹配一次的结果不可能和第 1 步不一致。

两个 dmg 都在时,cask 用一个 arch arm: "aarch64", intel: "x86_64" 加一条带 #{arch}url 服务两种机器,sha256 按架构分开写——这也是 Homebrew 自己的 style cop 会把 on_arm / on_intel 块自动改成的形状。两个 URL 必须只差那个架构 token,差别更大就直接报错而不是猜一个模板出来:tap 没有人工审核,猜错就是给 Intel 用户一个 404。

这个脚本由 pnpm release:manifest:test 覆盖(release.yml 的每个目标都会跑),用例里钉着 v0.8.0 真实的资产列表(arm-only)和一份两架构齐全的资产列表。

相关阅读

基于 MIT 许可发布