首页
/ Cline 桌面应用双渠道发布实战:Tag 契约、CI 签名公证流程与 Tauri 自动更新源

Cline 桌面应用双渠道发布实战:Tag 契约、CI 签名公证流程与 Tauri 自动更新源

2026-09-06 11:32:31作者:劳婵绚Shirley

本文以 Cline 仓库中 publish-desktop 发布技能文档(SKILL.md)为主体,完整梳理 Cline 桌面应用(apps/examples/desktop-app,基于 Tauri)从打 tag 到发布的双渠道(stable / beta)发布契约、逐步操作流程,并结合 desktop-publish.yml 工作流与配套脚本,深入解析 macOS 通用二进制签名公证、Windows Azure Trusted Signing 签名以及 Tauri 自动更新源(desktop-latest / desktop-beta)的生成与防护机制。读完本文,你可以独立完成一次桌面版本发布,并理解其中每一道安全门禁(环境密钥隔离、渠道 fail-closed 校验、编译期内嵌更新地址断言)的设计原因。

发布契约:两个渠道,一个工作流

桌面应用发布完全在 GitHub Actions 中完成,没有本地发布路径。发布产物包含两个平台:

  • macOS:单个签名 + 公证的 universal DMG,原生同时支持 Apple Silicon 与 Intel;
  • Windows:经 Authenticode 签名的 NSIS 安装器(<Product>_<version>_x64-setup.exe),在 build-windows 作业中通过 Azure Trusted Signing 签名(jsign 经 Tauri 的 signCommand 调用,见 tauri-sign-windows.ps1;需要仓库级 AZURE_* 密钥,其中包括 AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP,以及 cline-cli-signing Entra 应用上针对 PublishDesktop 环境的联邦凭据)。

已安装的 App 通过 Tauri updater 自动发现新版本,因此发布即更新——发布动作会把新版本推送给该渠道下的所有存量用户。

渠道、标签与更新源映射

两个渠道共用同一个工作流(desktop-publish.ymlchannel 输入):

渠道 标签格式 来源分支 滚动更新源(feed) 产品名 / 包标识
stable desktop-vX.Y.Z(无后缀,工作流拒绝 prerelease 后缀) main desktop-latest Cline / bot.cline.app
beta desktop-vX.Y.Z-beta.N desktop-experimental desktop-beta Cline Beta / bot.cline.app.beta,与 stable 并排安装

beta 构建额外叠加 src-tauri/tauri.beta.conf.json 配置层。beta 流程的完整背景见 EXPERIMENTAL.md

版本来源与 beta 版本号规则

版本号的来源有两处,二者必须互相对齐、且与 tag 一致:

src-tauri/Cargo.toml 有自己的版本号,但会被 tauri.conf.json 覆盖,无需改动。)

beta 版本是下一个 stable 版本的 prerelease:stable 0.0.13 → beta 0.0.14-beta.1-beta.2……一旦发出了版本 ≥ beta 基座的 stable,下一个 beta 就要抬升基座(stable 0.0.14 发布后,下一个 beta 是 0.0.15-beta.1)。beta 的 X.Y.Z 基座绝不能与已发布的 stable 相同。

发布准备包含三部分:已批准的发布说明、两处版本文件升版、以及 CHANGELOG.md 的更新——stable 提交到 main,beta 提交到 desktop-experimental

关键安全不变量:两个渠道都从 main 派发

Both channels dispatch from main。这不是便利性设计,而是安全不变量:运行执行的是 main 上的工作流副本,只有 checkout 指向 tag。因此签名密钥的两道门禁——github.ref == main 检查与 PublishDesktop 环境"仅允许 main 部署分支"策略——对 beta 同样成立。永远不要把 desktop-experimental 加入 PublishDesktop 的 deployment-branch 策略,否则在实验分支上编辑过的工作流文件就能接触到签名密钥。

工作流会为 tag 创建 GitHub release(universal DMG + macOS updater 制品 + 带 updater 签名的 Windows NSIS 安装器 + latest.json;beta 标记为 prerelease),并刷新渠道的滚动 feed release——这是该渠道所有已安装 App 轮询的静态自动更新源。永远不要删除 desktop-latestdesktop-beta 的 release 或 tag:feed URL 被编译进了二进制,updater 没有回退端点,删除会使所有存量安装永久失联。

CHANGELOG.md## <version> 小节(精确匹配,而非"顶部小节")会被逐字提取到 GitHub release 正文、Slack 公告和 updater manifest 的 notes 中。另外,推送 commit 或 tag 之前始终先询问。

逐步操作流程

以下命令都从仓库根目录执行(SKILL.md 明确要求 working directory 为 repo root)。

第 0 步:确认渠道

若用户未说明,先询问本次发布是 stable 还是 beta。后续所有步骤都以此为分支条件,绝不要猜测。

第 1 步:收集上下文

git status --short --branch
git fetch origin --tags
git tag --list 'desktop-v*' --sort=-v:refname | head -10
node -p "require('./apps/examples/desktop-app/package.json').version"
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"

如果尚不存在任何 desktop-v* tag,这是首个发布;以桌面应用的第一个 commit 作为基线,并明确告知"基线是推断的"。

对于 beta 发布:在 desktop-experimental 上工作(checkout origin/desktop-experimental;若它落后于 main,先把 origin/main 合入——冲突处理策略见 EXPERIMENTAL.md),并从该分支读取版本文件。上一个 tag 基线是两条渠道中最新的、且为当前分支祖先的 desktop-v* tag。

第 2 步:收集发布区间内的提交

# stable(在 main 上):
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
# beta(在 desktop-experimental 上):
git log <last-desktop-tag>..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml

注意日志路径包含 sdk/packages:桌面应用的 sidecar 会把 monorepo 里的 @cline/core 等 SDK 包打包进去,因此 SDK 变更也会随桌面应用一起发布。用户可见的 SDK 变更(provider、模型、行为修复)应并入发布说明;纯内部变更可以跳过。

第 3 步:起草面向用户的发布说明

扁平 bullet 列表、面向用户的语言。先呈现草稿并等待批准,再动任何文件

第 4 步:确定版本升版

  • Stable:询问本次是 patch、minor、major 还是显式版本号。用户没说清楚时不要猜。
  • Beta:按版本号规则计算——基座 = 下一个 stable 版本,N 递增(0.0.14-beta.10.0.14-beta.2;stable 0.0.14 发布后则变为 0.0.15-beta.1)。把计算出的版本与用户确认。

第 5 步:更新发布文件(stable 在 main,beta 在 desktop-experimental

第 6 步:提交前验证

bun -F @cline/code typecheck
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts

完整的桌面 bundle 只能在 macOS 上构建,工作流的 build 作业才是真正的构建验证。在 Mac 上想要额外信心时,可在应用目录执行 bun run package:desktop:mac --allow-unsigned-mac

第 7 步:提交发布变更

git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
git commit -m "chore(desktop): release vX.Y.Z"

推送发布 commit 前询问一次,创建并推送 tag 前再询问一次:

git push origin HEAD
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"       # beta: desktop-vX.Y.Z-beta.N / "Desktop vX.Y.Z-beta.N"
git push origin refs/tags/desktop-vX.Y.Z

第 8 步:派发工作流

前提:发布 commit 已在渠道分支上(stable 为 main,beta 为 desktop-experimental),且 tag 先推送。两个渠道都从 main 派发(原因见上文安全不变量):

# stable:
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z -f channel=stable -f confirm_publish=publish
# beta:
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z-beta.N -f channel=beta -f confirm_publish=publish

gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'

运行会暂停等待审批。 validate 立即执行,随后 build 作业等待 PublishDesktop 环境——直到必需审阅人批准,运行停留在 waiting,这是预期行为而非卡死。可在运行的 Web UI("Review deployments")批准,或:

gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
  --method POST -f state=approved -f comment="desktop vX.Y.Z" \
  -F 'environment_ids[]=19152605990'   # PublishDesktop

在此之前,validate 之后什么都不会执行——签名密钥也读不到。

工作流随后执行的内容(与 desktop-publish.yml 对应):

  • 构建一个 universal macOS bundle(tauri build --target universal-apple-darwin 对 aarch64 + x86_64 的 Rust 二进制做 lipo;Bun sidecar 由 build-sidecar-bin.ts 做 lipo;beta 叠加 tauri.beta.conf.json),验证 bundle 内每个 Mach-O 都携带两个架构切片,并验证编译后的二进制恰好内嵌本渠道的 feed URLVerify updater feed endpoint 步骤用 strings/grep 断言:stable 必须含 desktop-latest/latest.json 且不含 desktop-beta,反之亦然);
  • 用 Developer ID 证书签名、用 App Store Connect API key 公证、用 Tauri updater key 签名 updater 制品;
  • 并行地,build-windows 在 Windows runner 上构建 x64 NSIS 安装器,通过 Azure Trusted Signing 对每个二进制做 Authenticode 签名(Tauri signCommandtauri-sign-windows.ps1),执行同样的 feed 端点与遥测护栏,并用 Get-AuthenticodeSignature 验证最终安装器;
  • release 作业创建 GitHub release(beta 为 prerelease)、刷新渠道 feed(desktop-latest/latest.jsondesktop-beta/latest.json)、发 Slack 公告。公证通常额外增加 2–10 分钟。

如果工作流因缺少凭据失败,见下文"Publish 密钥一次性配置"。

第 9 步:发布成功后验证更新源

curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30   # stable
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30    # beta

version 字段必须等于新版本;darwin-aarch64darwin-x86_64 两个条目都必须指向该 release tag 下同一个新的 universal .app.tar.gz 资产(因为 fat 二进制的每个切片在运行时各自请求自己的架构 key,所以两个 key 服务同一制品);windows-x86_64 条目必须指向新的 *_x64-setup.exe 资产。该渠道的已安装 App(包括旧的按架构安装)会在下次启动或 2 小时内拿到更新。

beta 发布后还要确认 stable feed 未被触碰:desktop-latest/latest.json 仍应服务上一个 stable 版本。工作流对此有 fail-closed 防护,但验证成本极低、漏检后果极重——updater 的比较器是朴素的 semver"大于",beta manifest 落到 desktop-latest 会让所有 stable 安装自动更新到 beta。

第 10 步:汇报

报告内容:渠道、版本、tag、changelog 是否更新、commit hash、推送了什么、工作流 URL、feed 验证结果。

工作流内部机制解析(desktop-publish.yml)

.github/workflows/desktop-publish.yml 由四个作业组成:validatebuild(macOS)与 build-windows(Windows)并行 → release。几个值得关注的实现细节:

validate 作业:fail-closed 的渠道映射

渠道映射是"承重墙":每个渠道定义自己的 tag 形状、祖先分支、feed 与产品名,未知渠道直接失败。源码中的注释点明了原因——updater 比较器是朴素的 semver"newer than",beta manifest 落到 desktop-latest 会让所有 stable 安装自动更新到 beta。stable 的正则拒绝 prerelease 后缀也是同一原因。validate 还做三件事:

  1. 密钥作用域检查Verify signing secrets are not repository-scoped):该作业不声明任何 environment,因此凡是能在此解析出的签名密钥只可能是 repository/organization 级密钥——意味着全仓库任何工作流都能读到它。这一步与 build 作业的存在性检查配合,共同证明密钥确实来自 PublishDesktop 环境;
  2. tag 与版本一致性:checkout tag 后,比对 package.jsontauri.conf.json 的版本与 tag 剥离 desktop-v 前缀后的值;
  3. 祖先检查:tag 指向的 commit 必须可从渠道分支(origin/mainorigin/desktop-experimental)到达。

build 作业:macOS universal 构建与三道护栏

build 作业声明 environment: PublishDesktop 且带 if: github.ref == 'refs/heads/main'。工作流注释明确说明该 if 是"咨询性"的——真正强制的门禁是环境的 deployment-branch 策略,因为"被派发的分支会运行自己的工作流副本"。作业内的关键步骤:

  • 密钥存在性前置检查:Tauri 在 APPLE_CERTIFICATE 为空时会静默跳过代码签名、在 APPLE_API_KEY 为空时静默跳过公证,构建照样成功并产出一个未签名未公证的 bundle——所以必须在任何构建前 fail up front;
  • universal 校验:Tauri 自己 lipo 主二进制,但 sidecar 由自研的 build-sidecar-bin.ts 合并,所以逐一把 Contents/MacOS/*lipo -archs,任一不是双架构即失败——单架构 sidecar 会"发布顺利、另一架构上崩溃";
  • feed 端点断言:updater 端点由 tauri-build 以字符串字面量编入主二进制(合并配置经 codegen 内嵌),strings + grep 断言 bundle 恰好内嵌本渠道 feed URL,捕获 --config 叠加层静默未生效的场景;
  • 遥测自检:sidecar 以 --telemetry-selfcheck 运行,断言 "enabled":true 且 OTLP endpoint host 可解析——--define 内联是打包后 App(从 Finder/Dock 启动、无运行时环境变量)唯一能上报遥测的途径;
  • 刻意不使用 Rust 构建缓存:这是唯一能读到 Apple 证书与 updater key 的作业,Actions 缓存一旦中毒,恢复的缓存归档就是攻击者可控的。

产物收集阶段把 DMG 与 updater tarball 及其 .sig 复制为 dist/publish/ 下的 <PREFIX>_<VERSION>_universal.dmg / .app.tar.gz / .sigClineClineCline BetaCline-Beta)。

build-windows 作业:Azure Trusted Signing 全链签名

该作业需要 id-token: write,通过 PublishDesktop 环境的联邦凭据(cline-cli-signing Entra 应用,subject repo:cline/cline:environment:PublishDesktop)走 OIDC 登录 Azure。设计要点:

  • 全有或全无:未签名的 Windows 桌面构建绝不可接受(Smart App Control / WDAC 拦截未签名 exe,SmartScreen 标记未签名安装器),且 Tauri 在 updater key 缺失时会静默跳过 updater 制品签名——与 CLI 管线不同,这里没有未签名回退
  • 作业内所有 action 均 SHA 钉死:它们持有 id-token: write 与 updater 签名密钥,被劫持的上游 tag 不得触达签名身份;
  • 签名配置是运行时生成的 overlaysignCommand 需要签名脚本的绝对路径),指向 tauri-sign-windows.ps1。脚本本身:下载 jsign 7.5 并校验 SHA-256、每次调用时(Tauri 对主 exe、sidecar、NSIS 卸载器、安装器各调一次 signCommand)从 az account get-access-token 现取短时效 token 经环境变量(而非 argv)传给 jsign、以 SHA-256 算法 + RFC3161 微软时间戳服务签名,并在签完立即 Get-AuthenticodeSignature 自验;
  • 独立的 Verify Authenticode signatures 步骤对 dist/publish/*.exe 和 sidecar exe 再验一遍——即使某个安装器完全跳过了 signCommand 也能被捕获(sidecar 必须在场:WDAC 锁定的机器会在运行时拦截它,即使安装器本身完好)。

release 作业:changelog 提取、manifest 生成与 feed 刷新

  • changelog 精确匹配:用 awk 抓取 ## <version> 到下一个 ## <数字> 之间的内容并逐字写入 release 正文。注释解释了为何必须是精确匹配而非"顶部小节":maindesktop-experimental 交叉合并后,stable 与 beta 小节会按时间交错,顶部小节可能属于另一渠道;
  • Slack 截断:Slack section 块拒绝超过 3000 字符的文本且不报错,因此对超长 changelog 发送带"Read the full release notes"链接的截断副本,而 GitHub release 正文与 updater manifest 保持完整;
  • updater manifest:由 generate-update-manifest.ts 生成。它扫描制品目录,把 *_universal.app.tar.gz 同时映射到 darwin-aarch64darwin-x86_64 两个平台 key(指向同一资产与同一签名),*_x64-setup.exe 映射到 windows-x86_64,并拒绝同一平台 key 被多个制品声称;
  • feed 二次校验Update auto-update feed 步骤从 channel 重新计算期望 feed 并要求与 validate 输出一致("belt and braces",防止单点线程化 bug 把发布指到另一渠道的 feed),再确保滚动 release 存在(不存在则创建,--latest=false——仓库级 "latest" release 归 CLI 发布所有),最后 gh release upload "$FEED" dist/desktop/latest.json --clobber 刷新滚动资产;
  • 创建 release 时 make_latest: false,beta 加 prerelease

自动更新源的配置与生成原理

stable 的 updater 配置在 tauri.conf.json 中:plugins.updater.pubkey(minisign 公钥,用于校验 updater 制品签名)与 endpoints 指向 https://github.com/cline/cline/releases/download/desktop-latest/latest.json。beta 由 tauri.beta.conf.json 覆盖 productName(Cline Beta)、identifierbot.cline.app.beta)与 updater 端点(desktop-beta/latest.json)。

Tauri 按顺序合并重复的 --config 标志,因此 beta 叠加层(产品名、包标识、beta 更新源)覆盖在 release 叠加层之上而不必复制它;工作流的 build 步骤据此为 beta 传两个 --config

滚动 feed 的运作方式:desktop-latest / desktop-beta滚动 release,其 latest.json 资产(由 release 作业 --clobber 上传)指向最新的按 tag 不可变资产。generate-update-manifest.ts 中每个平台条目的 url 形如 https://github.com/<repo>/releases/download/<tag>/<artifact>——即 manifest 每发版刷新一次,下载 URL 永远指向对应版本的 release tag。

EXPERIMENTAL.md 特别强调命名不对称的陷阱:desktop-latest 就是 stable feed,不要改名成 desktop-stable——URL 被编译进每一个已发布的 stable 二进制,updater 没有回退端点,改名会让所有改名前安装永久停在死 feed 上;desktop-beta 在首个 beta 发出后同理。

Publish 密钥一次性配置

以下密钥必须配置在 PublishDesktop 环境(Settings → Environments → PublishDesktop → Environment secrets),而不是仓库级,使得只有 build 作业能读到、且只在审批之后能读到。该环境同时限制部署来源为 main 并强制审阅人。

把它们配成仓库级密钥是常见错误。环境门禁的作业同样会解析仓库级密钥(环境值只是优先),构建照样成功——凭据就此静默暴露在整个仓库。validate 作业专门在任何无 environment 的作业中探测这些密钥,一旦发现解析成功即失败整次运行。遇到该报错时,删除仓库级副本,而不是再复制一份。

若某处都缺失,build 的前置检查会点名缺失项使运行失败。Apple 值来自用于手工签名的同一 Apple Developer 账户(获取方式见桌面应用 README 的 "macOS signing & notarization" 一节):

密钥
APPLE_CERTIFICATE 从 Keychain Access 导出的 Developer ID Application 身份 .p12(须含私钥)的 Base64:base64 -i certificate.p12 | pbcopy
APPLE_CERTIFICATE_PASSWORD 导出 .p12 时设定的密码
APPLE_SIGNING_IDENTITY Developer ID Application: <Team Name> (<TEAMID>)——来自 security find-identity -v -p codesigning
APPLE_API_KEY App Store Connect API Key ID(用于公证)
APPLE_API_KEY_CONTENT AuthKey_<KEYID>.p8 文件内容
APPLE_API_ISSUER App Store Connect Issuer ID(Users and Access → Integrations 的 UUID)
TAURI_SIGNING_PRIVATE_KEY Tauri updater 私钥内容(tauri signer generate)。此钥一旦丢失,已发布的 App 将无法再验证更新——务必妥善保管
TAURI_SIGNING_PRIVATE_KEY_PASSWORD 该密钥的密码

Windows 侧还需仓库级 AZURE_CLIENT_IDAZURE_TENANT_IDAZURE_SUBSCRIPTION_IDAZURE_TRUSTED_SIGNING_ENDPOINTAZURE_TRUSTED_SIGNING_ACCOUNT_NAMEAZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP(工作流注释:AZURE_* 为 repository secret,TAURI_* 在 PublishDesktop 环境)。

Slack 与遥测类密钥(SLACK_RELEASE_BOT_TOKENTELEMETRY_SERVICE_API_KEYERROR_SERVICE_API_KEY、OTEL 配置)与 CLI、SDK、扩展发布工作流共享且已配置不要把它们移入 PublishDesktop——一旦作用域限定到该环境,其他所有发布工作流中的这些值会被静默清空,除了"遥测缺失 + Slack 发不出"之外不会有任何报错。

Beta 渠道的分支模型与合并冲突策略(EXPERIMENTAL.md 要点)

EXPERIMENTAL.md 补充了 beta 渠道的流程背景,发布时需要知道:

  • beta 是独立 App 而非 stable 的模式:产品名、包标识不同,两者并排安装以便直接对比;两个 App 共享 ~/.cline(provider 凭据、全局设置、hub daemon),beta 需要更新版 hub 时可能触发 hub-update-required 流程——这是预期的版本偏斜;
  • 分支模型:特性 PR 目标 desktop-experimental 并在其上迭代(对 main 的原始 PR 保持 draft 状态积累后续工作);毕业 = 向 main 提全新 PR,按正常 main PR 标准走评审。同步方向单向:定期把 main 合入 desktop-experimental(至少每个 stable 桌面版本发布后一次),绝不整支反合;
  • 合并冲突策略package.json / tauri.conf.json 版本号冲突时保留分支的 beta 版本;CHANGELOG.md 保留双方小节、按版本新者优先(stable 与 beta 小节按时间交错);特性代码在已毕业内容上以 main 为准;
  • 无自动降级:退出 beta 就是删掉 beta App(stable 从未被触碰),已毕业功能的 stable 版本通过 stable App 的正常发布获得;
  • tauri.beta.conf.json 必须存在于被 tag 的 commit 上(build 作业 checkout 的是 tag),因此要在 maindesktop-experimental 两个分支上都保留它。

发布前自检清单

汇总 SKILL.md 全文的门禁要求,发布动作完成后可按此核对:

  1. 版本号在 package.jsontauri.conf.json 中一致,且与 tag 一致;
  2. CHANGELOG.md 存在精确的 ## <version> 小节(beta 为 ## X.Y.Z-beta.N),无日期;
  3. 发布 commit 在渠道分支上,tag 已推送且可从渠道分支到达;
  4. 工作流从 main 派发,PublishDesktop 审批通过;
  5. 渠道 feed 的 latest.json 已刷新且版本正确,另一渠道 feed 未被触碰;
  6. beta 基座未与任何已发布 stable 的 X.Y.Z 相同;
  7. 报告含渠道、版本、tag、commit hash、工作流 URL 与 feed 验证结果。

以上流程与护栏均以当前仓库中的 desktop-publish.ymlgenerate-update-manifest.tstauri-sign-windows.ps1 及两份 tauri 配置的实际实现为准;当前桌面应用版本号为 0.0.22,可作为流程理解时的现实参照。

登录后查看全文
热门项目推荐
相关项目推荐