发布检查清单
面向维护者。正在使用 T3 Code?请参阅 用户文档。
本文档说明 stable 与 nightly 桌面发布所使用的统一发布流程。
Beta 是产品标签,不是发布渠道,也不是版本后缀。发布渠道仍然是 stable 和 nightly。要发布 0.1.0 Beta,使用 channel=stable 和 version=0.1.0。以后去掉 Beta 标签,是一次与版本无关的品牌变更。
工作流做什么
- 工作流:
.github/workflows/release.yml - 触发条件:
- 手动
workflow_dispatch并带上channel=stable,这是发布 stable 的常规做法。派发 stable 和 nightly 时必须选中main;preview 可以选择任意分支。渠道默认是 preview,因此漏选时不会发布 stable。 - 推送与
v*.*.*匹配的标签,对某一个明确的提交做 stable 发布 - 每 30 分钟一次的定时 nightly 检查
- 手动
workflow_dispatch并带上channel=nightly - 手动
workflow_dispatch并带上channel=preview,这是维护者的测试列车。它针对一个终端用户绝不能收到的提交,把整条发布流程走一遍(构建、签名、公证、冒烟、发布)。未合并的分支,或一项有风险的改动,就是这样在合入之前先获得一次真实的发布运行。它按 nightly 的版本规则构建触发这次运行的提交,预发布标识为preview(0.0.41-preview.<date>.<run>),然后发布一个 GitHub 预发布,并把 npm 包发到preview这个 dist-tag 下。Preview 不在定时计划里,也没有任何默认的 npm dist-tag 指向它;它的桌面构建不带更新源,也不会附上更新器清单(latest*.yml、nightly*.yml以及 blockmap),因此 stable 或 nightly 的已安装版本不会被提供这个构建。装上它的仅有途径,是手动下载这次发布、使用npx t3@preview、让安装脚本使用T3CODE_CHANNEL=preview,或者在终端里执行t3 update --channel preview。每一种方式都会打印警告;如果当前正在运行的构建本身不是 preview,CLI 还会要求确认。这次发布被命名为维护者测试构建,正文是一段警告,而不是生成出来的说明:由未合并分支的历史做成的变更日志,并不能算一份变更日志。nightly 和 stable 的说明不受影响,因为每个系列都在自己的渠道内解析上一个标签。托管 Web 应用、AUR 和 Discord 公告都会跳过。请保留它;空闲时它没有任何成本。
- 手动
- 手动的 stable 发布构建的是最近一次已发布 nightly 的那个提交,而不是
main的 HEAD。Nightly 就是发布候选:先验证 nightly,再提升它。你验证期间,合入main的提交会继续进来,但绝不会漏进这次 stable 构建。- 版本默认就是 nightly 预览过的那个(
0.0.39-nightly.*会以0.0.39发布)。传入version可以覆盖它,例如做一次次版本号提升。 - GitHub Release 发布时,stable 标签会打在这次 nightly 的提交上。
- 手动推送
vX.Y.Z标签仍然有效,而且构建的就是被打上标签的那个提交。当要发布的提交并不是最新的 nightly 时就用这种方式,比如发布分支上拣选出来的一处修复。
- 版本默认就是 nightly 预览过的那个(
- 构建产物的同时会跑 lint、类型检查和测试。发布会等每一项检查都结束。
- 在打包客户端(client)之前,读取共享的生产环境 T3 Connect 中继(relay)URL,以及 Clerk 的客户端配置。
- 在
build_bundle作业里只构建一次与平台无关的 JS(服务端包、Web 客户端、Electron 主进程),再把它作为js-bundle产物交给每一个平台作业。平台作业只负责打包,因此没有运行器会重新构建它。 - 两个渠道都会并行构建六个桌面产物,每一个都是独立的作业(
desktop_<platform>_<arch>,也就是对release-desktop.yml的一次调用),门禁只有这个包。macOS 和 Linux 目标在与自己架构相同的硬件上构建。两个 Windows 目标都在 Linux 运行器上构建并用 jsign 签名,这种打包比 Windows 运行器更快;与目标架构相同的 Windows 作业(windows-2025或windows-11-arm,即verify_runner)会运行打包好的原生程序和 CLI 归档。Windows 作业把同架构的 Linux CLI 归档嵌进去,当作 WSL 运行时,并且在中途等待这份产物,而不是等整个 Linux 作业结束:- macOS
arm64DMG - macOS
x64DMG - Linux 的
x64与arm64AppImage,以及.deb,来自同一次 electron-builder 运行。.deb通过 electron-updater 在应用内更新,安装时使用dpkg。 - Windows 的
x64与arm64NSIS 安装程序
- macOS
- 对照一份已经灌入种子数据的数据库,打开打包后的 Linux x64 AppImage(在 Xvfb 下)和 macOS arm64 DMG,并打开一个对话线程(thread)(
scripts/smoke-desktop-app.ts)。后端或包启动不了的构建会在这里失败,因此什么都不会发布。scripts/fixtures/desktop-launch里的数据库故意是一份旧快照:打包后的服务端必须把它向前迁移,就像为正在升级的用户所做的那样。数据库模式变更时,不要重新生成它。 - 用全部产出的文件发布一个 GitHub Release。
X.Y.Z之后带有后缀的 stable 标签(例如1.2.3-alpha.1)会作为 GitHub 预发布发布。- 只有不带后缀的 stable
X.Y.Z发布,才会被标成仓库的最新发布。 - Nightly 运行始终是 GitHub 预发布,并且从不会被标成最新。
- 自动生成的发布说明固定对照同一渠道里的上一个标签,因此 stable 对照上一个 stable 标签,nightly 对照上一个 nightly 标签。
- 发布作业里的编写发布说明步骤(
scripts/generate-release-notes.ts)负责生成正文:一行计算出来的首行、一段来自gpt-6.1-terra的简短摘要,以及折叠在<details>下面的 GitHub 生成列表。这一步需要OPENAI_API_KEY密钥。桌面端的更新弹出层最多展示七条要点,并给出其余修复和性能改进的数量。只涉及移动端的提交会计入发布总数,但不能成为要点。所提供发布的已发布时长来自更新源里的releaseDate。nightly 的 Discord 帖子展示<details>以上的那一部分。如果摘要生成失败,这次发布就改用 GitHub 生成的说明。
- 发布资产里会带上 Electron 的自动更新元数据(例如
latest*.yml、nightly*.yml和*.blockmap)。 - 每个平台都会构建一份自包含的 CLI 归档(
t3-<version>-<platform>-<arch>.tar.gz,Windows 上是.zip),和该目标的桌面产物放在同一个作业里,并连同SHA256SUMS文件一起附到 GitHub Release 上。每个渠道都如此,一共五个目标:macOS arm64、Linux x64 和 arm64、Windows x64 和 arm64。每一份归档都在与自己架构相同的硬件上做冒烟测试;除了 Windows 归档,其余的也在那台硬件上构建并签名。没有 macOS x64 归档:x64 的 macOS 不支持 Node 单可执行文件(SEA 文档把 macOS 列为只有 arm64),而且这个二进制文件一启动就会段错误。x64 桌面应用走的是 Electron,不受影响。- 归档把服务端做成一个 Node 单可执行文件(
scripts/build-cli-archive.ts),所以解包时不需要 Node,不需要 npm,也不需要编译器。这是 T3 Code 管理运行时的唯一形式:桌面端的 SSH 环境(environment)、引导服务、t3 update以及安装脚本,都会下载这份归档,并对照SHA256SUMS校验。t3这个 npm 包是给那些自己运行npx t3或npm install -g t3的人用的,它下载的也是同一份归档;产品里没有任何一部分是从 npm 安装的。curl | sh的安装器是scripts/install.sh和scripts/install.ps1;营销站点在构建时把它们复制到自己的public/(apps/marketing/scripts/stage-install-scripts.mjs),并在t3.codes/install.sh和/install.ps1提供它们。 - 根目录的
package.json把engines.node固定成开发、CI 和独立可执行文件所使用的那个精确 Node 版本。请保持精确:跨目标构建会下载这个版本,而原生可执行文件的构建会拒绝不一样的宿主 Node 版本。临时:Linux arm64 可执行文件只能在 Linux arm64 宿主上构建,并用 postject 注入,因为 Node 的--build-sea输出无法在页大小为 16 KiB 或 64 KiB 的内核上加载。等 nodejs/node#67048 修复之后再撤销。运行时一旦改动,就需要一个新的 T3 发布版本,因为已经装上的运行时是按这个版本缓存的。 - Apple 密钥齐全时,macOS 归档会用 Developer ID 证书签名并完成公证(否则是 ad hoc 签名,这种归档从
curl或tar安装之后仍然能运行)。Windows 可执行文件使用和安装程序同一套 Azure Trusted Signing 配置。macOS 归档里的每一个原生插件也会签名,因为强化运行时会拒绝未签名的库。 - 每一份归档都会在自己的构建运行器上解压并执行(
scripts/smoke-cli-archive.ts);Windows 则在verify_runner上执行。
- 归档把服务端做成一个 Node 单可执行文件(
- GitHub Release 发布之后,同一个工作流文件会用 OIDC 可信发布来发布
t3这个 npm 包。它只是一个启动器(scripts/npm-launcher/t3.mjs):第一次运行时,从 GitHub Release 下载与自己版本完全一致的 CLI 归档,对照SHA256SUMS校验,再解包到<T3 home>/runtime/versions/<version>,这也是安装脚本和t3 update共用的布局。可执行文件不在 npm 上。它们以前在,是作为@t3code/t3-<platform>-<arch>的可选依赖,但 npm 在发布时的扫描会在启动器已经上线之后,把这些 80–250 MB 的包扣留长达一个小时,那段时间里每一次安装拿到的都是一个没有可执行文件的启动器。这些包以旧版本留在 npm 上,不再发布新版本。因为启动器读的是这次发布,所以publish_cli排在release之后,绝不会排在前面。- stable 发布使用的 npm dist-tag 是
latest - nightly 发布使用的 npm dist-tag 是
nightly - preview 发布使用的 npm dist-tag 是
preview;除非点名要求,否则没有任何解析会落到它上面 - 一次性设置:包
t3需要为本工作流文件注册一个可信发布者(见下文)。
- stable 发布使用的 npm dist-tag 是
- 桌面作业跑着的时候,会在 Vercel 上构建托管 Web 应用,并且只有在发布完成之后才让它上线:
- stable 发布会别名到
latest托管应用渠道 - nightly 发布会别名到
nightly托管应用渠道
- stable 发布会别名到
- 签名是可选的,各个平台会根据密钥自动检测。
拉取请求(pull request)的 macOS 预览
给 PR 打上 preview:mac 标签,会把一个已签名、已公证、并且启用了 T3 Connect 的 Apple Silicon DMG 发布到滚动的 desktop-preview 预发布上,来自 fork 的 PR 也同样适用。这个标签是针对它所标上的那一次提交的一次性请求:可信工作流一旦拿到构建,就会把标签去掉,之后的推送不会再构建,直到维护者再次打上它。因此,每一次带签名的预览都是维护者按提交做出的决定。这一点很重要,因为结果带有 Developer ID 签名。给一位贡献者作保,就能让此人被打上标签的提交获得签名;这并不是一项长期授权。构建被拆开,使 Developer ID 证书绝不会和 PR 代码出现在同一个作业里:
.github/workflows/desktop-macos-preview.yml在pull_request上运行,不带任何密钥,并且只从 PR 构建 JS 包(同一份js-bundle产物,由release.yml产出)。.github/workflows/desktop-macos-preview-publish.yml在workflow_run上、从main运行。除非 PR 仍然打开、标签还在、它的 head 就是已构建的那次提交,而且作者是机器人、协作者,或者列在.github/VOUCHED.td里(从默认分支读取,因此 PR 不能为自己作保),否则这个作业会拒绝。接着,它通过release-desktop.yml(检出在main)来打包并签名这个包,所以打包逻辑、原生辅助程序,以及 Electron 和桌面端依赖,都来自main,而不是 PR。从 PR 提交里读出的,只有.env.example中的版本和公开的 T3 Connect 标识,而且是当作数据来读,这样已签名应用的通行密钥授权项才能和这个包对得上。改动了打包的 PR,必须改用上面的channel=preview发布列车。
把包交给签名运行器之前,可信工作流会检查它的 ZIP 条目,只接受位于 server/dist 和 desktop/dist-electron 之下的常规文件,以及通向这些根目录的目录条目。这份产物不能覆盖打包代码,也不能覆盖已经安装的依赖。在签名运行器上,这个包会被复制进应用,绝不会被执行。发布工作流里的 pull_request_target 清理作业会在 PR 关闭时移除这份下载;如果构建还没用到这个标签、标签就被人手动去掉,它也会移除下载。这个作业绝不会检出 PR 代码。
发布所需凭据
Stable 发布除了下文记录的平台凭据和部署凭据之外,还需要这些 GitHub Actions 密钥:
RELEASE_APP_IDRELEASE_APP_PRIVATE_KEY
收尾作业用它们以 Release App 的身份,把已经对齐的包版本提交并推送到 main。发布 GitHub Release 时使用的是仓库范围的工作流令牌,因此它拥有一份与共享的 Release App 安装相互独立的速率限制配额。
T3 Connect 中继(relay)部署
中继是一套共享的控制平面,版本与客户端(client)发布彼此分开。Stable 和 nightly 的客户端构建必须指向同一个中继,这样用户在切换发布渠道时,会看到相同的已链接环境(environment)。
.github/workflows/deploy-relay.yml 会部署 Alchemy 阶段 prod,每一次推送到 main 都会触发。Stable 和 nightly 发布在构建桌面、CLI 或托管 Web 产物之前,会从已有的 production GitHub Actions 环境读取中继 URL 和 Clerk 客户端配置。这个环境只接受 main 和 v* 标签,因此可能从任意分支运行的 preview 发布,会从 .env.example 取得同样的公开标识;请让它与 production 的值保持一致。每个渠道都用仓库级的 CLOUDFLARE_API_TOKEN,从 Alchemy 状态读取中继的客户端追踪配置,所以把这个令牌移进 production 会破坏 preview 发布。
中继部署共用的必需仓库变量:
CLOUDFLARE_ACCOUNT_IDPLANETSCALE_ORGANIZATIONAXIOM_ORG_ID
中继部署共用的必需仓库密钥:
CLOUDFLARE_API_TOKENPLANETSCALE_API_TOKEN_IDPLANETSCALE_API_TOKENAXIOM_TOKEN
必需的 production 环境变量:
RELAY_API_ZONE_NAMERELAY_TUNNEL_ZONE_NAMECLERK_PUBLISHABLE_KEYCLERK_JWT_AUDIENCECLERK_JWT_TEMPLATECLERK_CLI_OAUTH_CLIENT_IDAPNS_ENVIRONMENTAPNS_TEAM_IDAPNS_KEY_IDAPNS_BUNDLE_ID
可选的 production 环境变量:
RELAY_DOMAIN,用于覆盖派生出来的relay.<RELAY_API_ZONE_NAME>域名RELAY_TUNNEL_CLEANUP_MODE,取值为off、dry-run或enabled。缺失值和空白值使用off。RELAY_LEGACY_TUNNEL_CLEANUP_MODE,取值与上面相同,用于宿主从未注册过恢复的隧道。缺失值和空白值使用off。
必需的 production 环境密钥:
CLERK_SECRET_KEYAPNS_PRIVATE_KEY
可选的 production 环境密钥:
- 用于 T3 Chat 账户链接的
T3CHAT_LINK_SECRET。它必须与 T3 Chat 上的T3CODE_LINK_SECRET一致。
更改变量或密钥之后,从 main 手动运行部署 T3 Connect 中继工作流,并保持 force 未勾选。Alchemy 会比较 Worker 读到的值,只要其中有一项变了,就重新部署。只有在要重新部署那些没有检测到变化的资源时,才勾选 force。
账户范围的仓库凭据由 Alchemy 在置备中继阶段时使用;它们不会绑定进中继 Worker。生产部署使用的是 Axiom 个人访问令牌,因此 AXIOM_ORG_ID 必须和 AXIOM_TOKEN 一起提供。prod 阶段拥有那份保留的 PlanetScale 数据库。本地的个人阶段从它置备出隔离分支,并且从不由 CI 部署。生产环境会把已配置的中继 API 和隧道 DNS 区域,作为保留的 Cloudflare 资源接管下来。个人阶段引用由生产环境持有的这些区域。
开发者在本地部署个人阶段:
vp run --filter t3code-relay deploy -- --stage "$USER" --env-file .env.local同一个仓库里打了 relay:preview 标签的 PR,会部署阶段 pr-<number>,经由 .github/workflows/relay-preview.yml,并要先在 relay-preview 环境中获得批准。去掉标签或关闭 PR 会运行 .github/workflows/relay-preview-cleanup.yml,它会销毁这个阶段,并清扫该阶段管理的隧道和 DNS 记录。Nightly 运行会销毁那些已经关闭、带有标签、而且拆卸失败的 PR 所对应的阶段。fork 的 PR 不会得到预览。
托管隧道清理的推出
首次生产部署请保留 RELAY_TUNNEL_CLEANUP_MODE=off。这次部署会应用可空分配迁移,并加上恢复端点。Web 和移动客户端不需要配套发布。CLI 和桌面服务端构建必须先到达用户,然后才能启用清理,因为这些构建会注册恢复,并在唤醒之后替换一条已被删除的隧道。
- 在清理为
off的情况下部署中继和迁移。 - 发布服务端构建,并确认当前宿主会注册恢复。较旧的宿主仍然标记为遗留,只有在下面的遗留开关之下才会成为候选。
- 设置
dry-run,跑一次中继部署,并在多次清扫中读取清扫计数器(scanned、wouldDelete、skippedLegacy、skippedOrphan、failed、truncated)。每一次清扫都会把这些计数器,以及当时生效的mode,记成relay.managed_endpoint_reaper.*属性,附在 Axiom 里这次清扫的relay.managed_endpoint_reaper.sweep跨度(span)上。 - 运行下面的一次性宿主金丝雀测试。
- 只有在金丝雀测试无需重启服务端就已恢复之后,才设置
enabled。
这个作业每五分钟跑一次,对丢失了连接器的隧道给五分钟宽限,因此一个候选通常在下线后五到十分钟被移除。从未连接过的隧道要等一个小时。一次清扫最多尝试 100 次删除,所以有积压时会更久。更改 RELAY_TUNNEL_CLEANUP_MODE,包括在故障期间关掉清理,都需要一次不带 force 的中继部署。在下一次清扫跨度上确认新的 mode。
要回滚,把清理设为 off,并在降级任何宿主之前跑一次中继部署。当前的服务端构建还在使用时,恢复端点要保持部署。可空列可以留着。
遗留隧道清理
遗留隧道属于从未注册过恢复的宿主,通常是在恢复构建发布之前就已经离线的宿主。一旦 Cloudflare 报告它们处于下线、或者从未连接的时间超过 7 天,RELAY_LEGACY_TUNNEL_CLEANUP_MODE 就会删除这些隧道。它与 RELAY_TUNNEL_CLEANUP_MODE 相互独立,其余每一项检查仍然适用。
被删掉的遗留隧道会保留自己的分配,因此主机名也保留下来。宿主重新上线时:
- 在带有恢复的构建上,连接器会被拒绝,宿主会请求一条同一主机名上的替换隧道。
- 在通过 CLI 链接的较旧构建上,启动时会置备一条新隧道。
- 在从 Web 或移动端链接的较旧构建上,那台计算机上的 T3 Code 更新之前,宿主保持离线。
启用遗留清理之前,先发布会显示离线原因的 Web 和移动端构建,这样宿主受到影响的用户就知道该怎么做。中继在带有这项改动的首次部署里加入 tunnel_released_at 分配列;在你设置之前,遗留开关保持为 off。
- 用只读的 Cloudflare 令牌运行
vp run --filter t3code-relay tunnels:census。它会统计每一个中继阶段里的隧道。收割器(reaper)只能看到自己这个阶段的隧道,其余的要手工清理。 - 把遗留模式设为
dry-run并部署,然后用一天时间读取清扫跨度上的wouldDeleteLegacy、legacyOver30Days、totalDown和totalInactive。wouldDeleteLegacy只统计一次清扫检查过的隧道,每种状态最多 500 条。totalDown和totalInactive是 Cloudflare 对这一阶段中下线超过五分钟、以及从未连接超过一小时的隧道的计数。其中包含收割器会跳过的隧道,因此这是积压量的上限。wouldDeleteLegacy在每次清扫的scanned里所占的比例,可以估计这个总数中有多少符合条件。 - 运行下面一次性宿主金丝雀测试里的遗留步骤。
- 启用之前,确认会显示「在那台计算机上更新 T3 Code」这条消息的 Web 和移动端构建已经上线。没有这些构建,较旧宿主丢了隧道的用户只会看到它处于离线。
- 把遗留模式设为
enabled。一次清扫最多删除 100 条隧道,每次四条,并且在 90 秒之后不再开始新的删除。如果每次清扫都能删完自己的 100 条,20,000 条积压大约需要 17 小时。请观察deletedLegacy、attempted、failed和truncated。attempted远低于 100,并且truncated已被设置,就说明这次清扫提前停了:要么时间预算用尽,要么 Cloudflare 对某次删除做了速率限制。计数器看不出是哪一种;中继会为每一次失败的删除记下一条带有 Cloudflare 错误的警告。
在 Axiom 里,按 name == "relay.managed_endpoint_reaper.sweep" 过滤中继追踪数据集,并把 attributes.custom.relay.managed_endpoint_reaper.* 字段随时间画出来。
如果出现下面任何一种情况,把遗留模式设回 off 并部署:
failed每次清扫都持续高于数条。请到警告日志里看 Cloudflare 的错误。- 用户报告:他们已经在那台计算机上更新并重启了 T3 Code,某个环境却仍然离线,而且带有那条更新提示。
- 清扫运行期间,中继的请求错误升高。删除操作和请求处理程序共用 Postgres 连接池。
关掉遗留模式会停止新的遗留删除;只要 RELAY_TUNNEL_CLEANUP_MODE 仍是 enabled,它就会继续删除那些具备恢复能力的宿主的隧道。已经删除的隧道保持删除;它们的宿主按上文所述恢复。
一次性宿主金丝雀
这项测试还没有在真实的 Cloudflare 账户上跑过。请在一次性的中继阶段、测试用 Cloudflare 账户、一次性宿主和一次性 T3 主目录上运行它。在它通过之前,生产环境的清理请保持在 off 或 dry-run。不要停掉日常使用的 T3 服务端。
- 在清理为
dry-run的情况下部署这个一次性阶段。通过 Web 或移动端设置链接第一个一次性环境,并确认它的隧道是健康的,而且恢复已经注册。 - 停掉这个宿主,再在另一个本地端口上启动同一个 T3 主目录。确认公共主机名到达的是新端口,并且不会向旧端口发送任何内容。
- 用一个早于恢复注册的服务端构建,链接第二个一次性环境。捕获它托管的
cloudflared子进程 PID,确认这个进程属于该宿主,并且只用kill -STOP <legacy-pid>暂停这一个子进程。等到 Cloudflare 报告它已下线超过五分钟。 - 从第一个环境的服务端日志里捕获它的
cloudflared子进程 PID,确认归属,再用kill -STOP <first-pid>暂停它。等到 Cloudflare 报告它已下线超过五分钟。 - 确认试运行把第一条隧道计在
wouldDelete,把第二条计在skippedLegacy。 - 在这个一次性阶段上把清理设为
enabled并部署。到测试用的 Cloudflare 账户里确认第一条隧道已经删除,遗留隧道还在。 - 用
kill -CONT <first-pid>恢复第一个子进程。确认正在运行的服务端发现了反复出现的拒绝,发起了恢复请求,并且无需重启就能在同一主机名上重新可达。 - 用
kill -CONT <legacy-pid>恢复遗留子进程,并确认它的隧道重新连上。 - 在大范围推出之前,先在一台一次性使用的笔记本电脑上,用一次真实的睡眠和唤醒再做一遍。
同一个一次性阶段上的遗留清理:
- 设置
RELAY_LEGACY_TUNNEL_GRACE_MINUTES=10,并把遗留模式设为dry-run,然后部署。这个覆盖值会缩短 7 天的宽限期,并且在prod上会被忽略。再次暂停遗留子进程,等到 Cloudflare 报告它已下线超过十分钟。 - 确认清扫把它计进了
wouldDeleteLegacy,然后把遗留模式设为enabled并部署。确认遗留隧道已经删除,它的分配行还在。 - 遗留宿主仍停留在旧构建上时,恢复这个子进程。通过 CLI 链接的宿主会在下一次重启时置备一条新隧道;通过 Web 或移动端链接的宿主则保持离线。
- 把这个宿主更新到当前构建并启动。确认它会请求恢复,并且能在同一主机名上访问到。
- 从一次性阶段中去掉
RELAY_LEGACY_TUNNEL_GRACE_MINUTES。
营销站点部署
nightly 发布时,发布工作流会在桌面作业运行期间,把同一个提交构建成营销站点 Vercel 项目(project)的暂存生产部署,并在发布完成之后用 vercel promote 把它提升上去。Stable 发布不部署营销站点,因为它们可能会提升一个更早的 nightly 提交。
这个作业查找 t3code-marketing 项目,使用的是现有的 VERCEL_TOKEN 和 VERCEL_ORG_ID 密钥。它也会遵守可选的 VERCEL_TEAM_SLUG 变量。Vercel 项目的根目录必须是 apps/marketing。apps/marketing/vercel.ts 里的 Git 部署保持禁用。
移动端 TestFlight 与商店发布
合入 main 会发布兼容的 OTA,并用 testflight 配置文件、以版本 3.0.0 构建 iOS 预发布。构建号会自动递增。已经提交到仓库的应用版本仍然是 App Store 的发布版本;Android 继续使用这个版本。
要发布 iOS,请带上 mode=build、profile=production、platform=ios 以及打算使用的版本(例如 2.0.1),派发 Mobile EAS Production。工作流会先提交并推送这个版本,然后再构建和上传。在 App Store Connect 里选中这次构建,提交 App Store 审核。
要强制重新构建 TestFlight,选择 profile=testflight 和 platform=ios,并把版本留空。在 App Store Connect 里把第一个 3.0.0 构建提交外部测试审核。之后的构建可能仍然需要审核;只上传并不能保证获得外部批准。TestFlight 构建会在 90 天后过期。
发布版和 TestFlight 的二进制都会收到兼容的生产渠道 OTA,并按各自的主版本指纹彼此分开。手动的 mode=update 使用所选配置文件的版本。在把 3.0.0 发布到 App Store 之前,把 build.testflight.env.T3CODE_MOBILE_VERSION(位于 apps/mobile/eas.json 中)推进到下一个尚未发布的主版本,并让这个主版本的第一个测试构建获得批准。
Android APK 镜像
.github/workflows/mobile-android-apk.yml 每小时检查一次 Google Play 的 Alpha 封闭测试轨道。每一个新的测试版都会得到一个 android-beta-<build-number> 预发布,其中包含它的 APK。没有变化的检查不会发布任何东西,中断了的草稿上传会在下一次检查时继续。
常规的 Nightly 发布会带上发布当时所能拿到的、最新一份已经处理完的内部测试 APK。内部构建不会单独产生 Android 发布。如果 Play 凭据或已经处理好的 APK 不可用,或者下载超过五分钟,这次 Nightly 仍然可以不带 APK 发布。Android 的提交仍然进入内部测试;把某个构建提升到 Alpha,才会把它作为测试版发布出来。
这个 APK 是 Play 根据上传的 bundle 生成并签名的通用 APK,因此 GitHub 里不存放签名密钥。在 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON 持有一把具备「发布到生产、排除设备,并使用 Play 应用签名」权限的 Play 服务账户密钥之前,这个作业会跳过自己;下载生成出来的 APK 需要这项权限。该作业读取轨道时不会打开 Play edit,因此可以和 EAS Submit 共用同一个服务账户。
托管 Web 应用的发布部署
托管应用有意不通过 Vercel 的 Git 集成来部署。Web 项目(project)在 apps/web/vercel.ts 里经由 git.deploymentEnabled: false 禁用了自动 Git 部署。.github/workflows/release.yml 在桌面作业运行期间,用 Vercel CLI 把 Web 应用构建成暂存生产部署(--skip-domain),并在 GitHub Release 成功之后,把渠道域名别名到这次部署。
必需的 GitHub Actions 密钥:
VERCEL_TOKENVERCEL_ORG_IDVERCEL_PROJECT_ID
可选的 GitHub Actions 变量:
VERCEL_TEAM_SLUG:当希望使用团队 slug,而不是VERCEL_ORG_ID密钥时,用它覆盖 Vercel CLI 的 scope。T3CODE_WEB_ROUTER_URL:默认为https://app.t3.codes。T3CODE_WEB_LATEST_DOMAIN:默认为latest.app.t3.codes。T3CODE_WEB_NIGHTLY_DOMAIN:默认为nightly.app.t3.codes。
必需的 Vercel 域名:
app.t3.codes:用户打开的路由域名,由 stable 发布更新。latest.app.t3.codes:由 stable 发布更新的渠道别名。nightly.app.t3.codes:由 nightly 发布更新的渠道别名。
路由域名使用 apps/web/vercel.ts 路由。用户通过访问 /__t3code/channel?channel=latest 或 /__t3code/channel?channel=nightly 来选择加入某个渠道;路由器会存下 t3code_web_channel cookie,并把之后发往 app.t3.codes 的请求重写到对应的渠道别名。
发布部署作业会在上传之前改写发布包的版本,让托管应用的「关于」面板显示出这次发布的版本。Stable 部署把同一次部署同时别名到 latest 渠道和路由域名,这样路由规则能保持最新。Nightly 部署只为 nightly 渠道设置别名。这个作业还会传入 VITE_HOSTED_APP_CHANNEL=latest|nightly,用来在「关于」面板里渲染托管应用的更新轨道选择器。更改选择器时,会先经过路由域名上的 /__t3code/channel,在重定向到托管应用根路径之前更新用户的渠道 cookie。
一次性的 Vercel 仪表板设置:
- 确认 Web 项目的根目录仍然是
apps/web。 - 把上面这三个域名加到 Web 项目。
- 如果需要,在仪表板里禁用自动 Git 部署;已提交的
vercel.ts设置才是事实来源,不过在仪表板里断开 Git 也同样安全。 - 跑一次 stable 发布部署,或者手动把当前的 stable 部署设成别名,让
app.t3.codes指向一份包含apps/web/vercel.ts中路由规则的部署。以后的 stable 发布会继续维护这个别名。
Nightly 构建
- 工作流:
.github/workflows/release.yml - 触发条件:
- 每 30 分钟一次的定时检查
- 手动
workflow_dispatch并带上channel=nightly
- 自动 nightly 要求有新的提交,并且距离上一次 nightly 发布(包括手动 nightly)至少已经过了六小时。
- 手动 nightly 会跳过时间检查和变更检查。Nightly 运行仍然串行执行。定时运行会先等正在进行的 nightly 结束,再在构建之前检查发布间隔。
- 运行与带标签的发布流程相同的桌面质量门禁和产物矩阵。
- 只发布 GitHub 预发布:
- 当前的标签格式:
vX.Y.Z-nightly.YYYYMMDD.<run_number> nightly-v...只在作为上一次 nightly 的遗留标签时才被接受- 发布名称里包含短提交 SHA
make_latest始终是false
- 当前的标签格式:
- 把下一个 stable 的补丁版本当作 nightly 的基数。例如,
0.0.17会在0.0.18-nightly.*上产生 nightly。 - 把 Electron 自动更新元数据发布到专用的
nightly更新器渠道,因此桌面用户可以独立于 stable,自行选择加入这条轨道。 - 使用与这次 nightly 相同的版本,把
t3这个 npm 启动器发布到nightly这个 npm dist-tag。 - 不会把版本号提升提交回
main。
服务端自更新的发布不变量
已经连上的服务端会更新到客户端(client)的精确版本,而不是某个 npm dist-tag。自己更新的服务端,会从 GitHub Release 下载这个版本的 CLI 归档;通过 npm 运行的服务端,则会得到一条要去执行的 t3@<version> 命令(command)。因此,用户能够收到某个已发布的客户端版本之前,必须先有它的发布归档,然后再有它的 npm 启动器。
工作流强制这一顺序:
release发布带有 CLI 归档和SHA256SUMS的 GitHub Release。每个渠道都先上传到草稿,等全部资产都附加上去之后才发布,因此桌面更新源里出现的新版本,归档已经齐备。全部更新靠的就是这一点,好在 npm 还没有这个版本时,先更新后台服务的宿主。发布之后再次运行release,会替换掉公开发布里的资产,所以上传完成之前,服务更新可能会失败。publish_cli在每个渠道上发布t3@<version>启动器,这个启动器从那次发布下载。deploy_web要等这两者都完成,才把托管渠道切到新的客户端。build_web会更早用vercel deploy --prod --skip-domain构建这个客户端;这不会改动自定义域名,但会移动项目(project)自己的*.vercel.app生产主机名。这个主机名位于 Vercel SSO 之后,因此用户只能通过自定义域名拿到这个客户端。
桌面自动更新直接读取 GitHub Release,因此桌面客户端可能比 t3@<version> 出现在 npm 上早几分钟就看到新版本。只有那条复制给通过 npm 运行的服务端的手动命令才依赖 npm,而且 publish_cli 一完成就可以用。
更改发布图时要保住这些依赖。如果先发布客户端,更新服务端这个操作就会指向一个还不存在的包版本。
做发布冒烟测试时,先确认 npm view t3@<version> version 返回的是预期版本,再把新客户端连到仍处于上一版本的服务端,并验证更新操作会重新连上版本匹配的服务端。如果这次发布带有数据库迁移,要验证远程更新会应用这些迁移并重新连上。试验失败时,必须恢复数据库快照,并重启先前的服务端。如果已安装的启动器不支持目标协议,要验证更新会在重启之前停下来,并在服务端那台机器上运行一次 npx t3@<version> service update。这些环境(environment)具备时,也要测试手动方式以及由桌面托管时给出的指引。
桌面自动更新说明
- 更新器运行时:
apps/desktop/src/updates/DesktopUpdates.ts。 electron-updater适配器(adapter):apps/desktop/src/electron/ElectronUpdater.ts。apps/desktop/src/main.ts只是把更新器的各层接到桌面运行时里。- 更新体验:
- 后台检查按照「启动延迟 + 间隔」执行。
- 不会自动下载,也不会自动安装。
- 有更新可用时,桌面界面会显示一个火箭更新按钮;点一次开始下载,下载完成后再点一次,即可重启并安装。
- 提供方(provider):GitHub Releases(
provider: github),在构建时配置。 - 仓库 slug 的来源:
- 若已设置,则为
T3CODE_DESKTOP_UPDATE_REPOSITORY(格式为owner/repo)。 - 否则使用来自 GitHub Actions 的
GITHUB_REPOSITORY。
- 若已设置,则为
- 更新器所需的发布资产:
- 平台安装程序(
.exe、.dmg、.AppImage、.deb,另外还有供 Squirrel.Mac 更新载荷使用的 macOS.zip) - 渠道元数据:stable 发布使用
latest*.yml,nightly 发布使用nightly*.yml *.blockmap文件(用于差分下载)
- 平台安装程序(
- macOS 元数据说明:
electron-updater在 stable 上读取latest-mac.yml,在 nightly 上读取nightly-mac.yml,Intel 和 Apple Silicon 都是如此。- 工作流在发布 GitHub Release 之前,会把各架构的 mac 清单合并成一份按渠道区分的 mac 清单。
Windows 载荷拓扑与更新校验
Windows 把捆绑进来的服务端,以及仅仅是它的运行时外部/原生依赖闭包,打包进 resources/server.asar。被这份归档声明为 unpacked 的原生模块和辅助可执行文件,必须出现在 resources/server.asar.unpacked 下面的对应路径上。Windows 原生后端通过 Electron 就地读取这份归档。打包后的 Windows 构建还会带上 resources/wsl-runtime.tar.gz 以及它的 SHA-256 伴生文件(sidecar):这份 Linux CLI 归档(t3-<version>-linux-<arch>.tar.gz,架构与 Windows 宿主相同)由 Linux 桌面作业构建,并以 --wsl-runtime 交给 Windows 桌面构建,原样复制进去,这样 WSL 跑到的字节与 Linux 用户下载到的完全一致。WSL 会校验这份归档,并把它解压到所选发行版内部的 ~/.t3/wsl-runtime/sha256-<archive-digest>,然后在同一次更新之后再次启动时复用它。
Windows 把 JavaScript 和包元数据留在 app.asar 里面,只把原生库和辅助可执行文件解出来。不要启用整包的智能解包:每一个松散文件都会增加 NSIS 安装的工作量,并且计入载荷上限。
只要下面任何一条不变量被打破,产物构建器就会拒绝这个 Windows 包:
resources/server.asar缺失,或者里面没有服务端入口。- ASAR 头里标记为 unpacked 的任何一个文件,没有出现在
resources/server.asar.unpacked中。 - 在同架构的 Windows 构建上,打包后的主程序无法从
server.asar内部、经由与之同级的.unpacked加载 fff 原生库。 - 单独解压出来的那份伴生文件,无法用普通 Node 加载服务端入口。
- 拿到了
--wsl-runtime的 Windows 构建漏掉了 WSL 归档或 SHA-256 伴生文件,或者伴生文件的摘要与实际产出的归档不一致。 - 产出的 WSL 归档不是一份 Linux CLI 发布归档:它必须解压成单独一个
t3-<version>-linux-<arch>目录,其中有t3、client/,以及带有 Linux 版 node-pty 二进制的node_modules/,并且不能带有松散的服务端包(bin.mjs)。 - 外部的 Windows 资源监视器缺失。
- 展开后的 Windows 应用包含的文件超过 80 个。
跨架构的 Windows 构建会保留全部结构检查和已解压伴生文件检查,但会跳过执行目标 Electron 二进制。每一个发布目标都必须有一次同架构构建,来实际跑一遍主程序的原生加载探测。
NSIS 差分打包保持启用。伴生文件布局的一次切换,可能带来一次更大的下载;之后的小幅发布仍会保留各自的 blockmap,一次有代表性的、从伴生文件到伴生文件的更新,上限为 60 MB。
0) npm OIDC 可信发布设置(CLI)
build_bundle 构建与版本匹配的 t3.tgz,使用的是 node scripts/build-npm-launcher.ts,并把它上传为 npm-launcher 产物。GitHub Release 发布之后,publish_cli 只会检出为这个项目(project)设置 Node.js 所需要的内容,按 build_bundle 输出的产物 ID 下载那一份上传(绝不按名称),核对 tarball 的版本,并对它运行 npm publish --provenance。每次发布只发布这一个包。
检查清单:
- 确认 npm 组织拥有包
t3。 - 针对
t3,在 npm 的包设置里配置可信发布者(Trusted Publisher):- 提供方(provider):GitHub Actions
- 仓库:本仓库
- 工作流文件:
.github/workflows/release.yml - 环境(environment)(若使用):与你的 npm 可信发布配置保持一致
- 确认 npm 账户和组织的策略允许这个包使用可信发布。
- 创建发布标签
vX.Y.Z并推送;工作流会先发布 GitHub Release,再用 npm dist-taglatest发布启动器。 - Nightly 运行用 npm dist-tag
nightly发布;preview 运行用preview。
1) 发布验证与未签名构建
没有试运行用的标签路径。推送任何一个会被接受的非 nightly 标签,包括 v0.0.0-test.1,都会把这次运行归为 stable 渠道。它会发布 t3,使用的 npm dist-tag 是 latest,创建一次真实的 GitHub Release,把托管应用别名到 latest.app.t3.codes 和 app.t3.codes,并且收尾作业还可能把版本号提升提交到 main。不要靠推送测试标签来验证工作流。
工作流没有一种不发布的 workflow_dispatch 模式。想验证检查和构建、又不真正发布时,用常规 CI 或本地的质量门禁。如果想在更低的 stable 风险下把完整的发布图走一遍,可以手动派发 channel=nightly;这样做仍然会发布真实的 nightly npm 包、GitHub 预发布、桌面更新器发布、托管的 nightly 别名和营销站点,但不会更新 stable 应用的别名,也不会把版本号提升提交到 main。只有在可以接受一次真实的 nightly 发布时才去运行它。
手动的 channel=stable 同样是一次真实的 stable 渠道发布。不提供签名密钥,只会让平台产物处于未签名状态;这并不能阻止发布。
2) Apple 签名与公证设置(macOS)
工作流使用的必需密钥:
CSC_LINKCSC_KEY_PASSWORDAPPLE_API_KEYAPPLE_API_KEY_IDAPPLE_API_ISSUERMACOS_PROVISIONING_PROFILE(经过 base64 编码、带有 Associated Domains 的预置描述文件)
必需的仓库变量:
APPLE_TEAM_ID
可选的仓库变量:
CLERK_PASSKEY_RP_DOMAINS:用逗号分隔的 RP 域名覆盖值。默认情况下,构建会从生产环境的 Clerk 可发布密钥推导出这个域名。
检查清单:
- Apple Developer 账户权限:
- 团队有权创建 Developer ID 证书。
- 为
com.t3tools.t3code创建一个显式 App ID,并启用 Associated Domains。 - 创建一张
Developer ID Application证书,以及一份为该 App ID 启用了 Associated Domains 的兼容预置描述文件。 - 在钥匙串里把证书和私钥导出为
.p12。 - 把
.p12做 Base64 编码,存成CSC_LINK。 - 把预置描述文件做 Base64 编码,存成
MACOS_PROVISIONING_PROFILE。 - 把
.p12的导出密码存成CSC_KEY_PASSWORD,并把APPLE_TEAM_ID设为 10 个字符的 Apple Developer Team ID。 - 在 App Store Connect 中创建一个 API 密钥(Team key)。
- 填上 API 密钥的值:
APPLE_API_KEY:下载到的.p8的内容APPLE_API_KEY_ID:Key IDAPPLE_API_ISSUER:Issuer ID
- 按 T3 Connect 设置 完成 Clerk Native API 和 AASA 的配置。
- 重新跑一次标签发布,确认 macOS 产物已经签名并公证,并且带有预期的
com.apple.developer.associated-domains授权项。
说明:
APPLE_API_KEY以原始密钥文本的形式存在密钥里。- 工作流在运行时会把它写到一个临时的
AuthKey_<id>.p8文件。 - 工作流会解码
MACOS_PROVISIONING_PROFILE,用security cms校验,再交给桌面打包器。 - 应用内浏览器的通行密钥依赖同一份描述文件。打包器只在描述文件授予了某项授权时才加入该项,因为如果应用声明的授权超出描述文件所允许的范围,macOS 不会启动它。构建日志会写明启用了哪几项。
keychain-access-groups(<TEAM_ID>.com.t3tools.t3code.webauthn)用来启用 Touch ID 通行密钥。凡是授予了该团队钥匙串访问组(<TEAM_ID>.*)的描述文件,都覆盖这一项。com.apple.developer.web-browser.public-key-credential让系统通行密钥面板(iCloud 钥匙串、密码管理器、手机、安全密钥)可以为任意网站服务。Apple 把它作为一项托管能力来授予:由账户持有人(Account Holder)通过 macOS Browsers Passkeys 表单 申请。批准之后,重新生成描述文件,并更新MACOS_PROVISIONING_PROFILE。
3) Azure Trusted Signing 设置(Windows)
工作流使用的必需密钥:
AZURE_TENANT_IDAZURE_CLIENT_IDAZURE_CLIENT_SECRETAZURE_TRUSTED_SIGNING_ENDPOINTAZURE_TRUSTED_SIGNING_ACCOUNT_NAMEAZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAMEAZURE_TRUSTED_SIGNING_PUBLISHER_NAME
检查清单:
- 创建 Azure Trusted Signing 账户和证书配置文件。
- 记下这些 ATS 值:
- 端点
- 账户名称
- 证书配置文件名称
- 发布者名称
- 创建或选用一个 Entra 应用注册(服务主体)。
- 授予 Trusted Signing 所要求的服务主体权限。
- 为这个服务主体创建客户端密钥(client secret)。
- 把上面列出的 Azure 密钥加到 GitHub Actions 密钥里。
- 重新跑一次标签发布,确认 Windows 安装程序已经签上名。
4) 持续发布检查清单
- 选中最新的 nightly 并验证它:拿它的产物跑一遍上面的冒烟测试,并检查 nightly 渠道有没有回归。
- 带上
channel=stable派发 Release 工作流。除非版本需要和 nightly 预览过的那个不同,否则把version留空。 - 确认
Resolve release commit的提示里写的是你验证过的那个 nightly 标签和提交。如果中间又发布了更新的 nightly,这次运行会改去构建那一个。 - 核对工作流步骤:
- 预检通过
- 发布质量检查通过
build_bundle以及全部平台构建通过publish_cli在发布作业之后,发布与这次完全一致的版本- 发布作业上传了预期中的文件
- 对下载下来的产物做冒烟测试。
5) 故障排除
- 预期应已签名的 macOS 构建实际上未签名:
- 检查全部 Apple 密钥以及
APPLE_TEAM_ID都已填写,并且不是空值。 - 确认预置描述文件属于
APPLE_TEAM_ID.com.t3tools.t3code,并且包含 Associated Domains。
- 检查全部 Apple 密钥以及
- 预期应已签名的 Windows 构建实际上未签名:
- 检查全部 Azure ATS 密钥和身份验证密钥都已填写,并且不是空值。
- 构建因签名错误失败:
- 去掉密钥后再试一次,确认未签名这条路径仍然能走通。
- 再核对证书和描述文件的名称,以及租户凭据和客户端凭据(client credentials)。