稳定发布准入与晋升
Stable Release 是发布门槛。单靠一个成功的构建器不等于稳定发布。稳定发布用 attempt ref 作为锁、用最终标签作为发布回执;两个标签都不是工作流触发器。Canary 构建有自己的计划工作流。
提交进仓库的项目版本始终是 0.0.0。发布作业从准入的 ref 推导载荷版本,并盖戳隔离的构建树。不要在 main 上提升版本文件。
顺序 {#order}
- 刷新
origin/main以及远端 attempt 和标记 ref(rc.*和abandoned-rc.*)。仅从0.21.4起播种的已发布发布家族推导下一个 SemVer:取受保护 R2 稳定头与最新的、已发布的、带vX.Y.Z标签的非预发布 GitHub release 中较新者。跳过 bundle 的发布只移动后者。attempt 不移动版本线。若下一个版本已有最终vX.Y.Z标签,切割会被拒绝,直到那次发布完成。 - 原子地推送一个带注释的
rc.<N>-vX.Y.Zattempt ref,在其上创建一个非预发布 GitHub draft,并在该精确 ref 上调度Stable Release。attempt 编号来自该版本既有的 attempt ref。认领消息绑定其 commit、attempt 编号、自动发布策略、skipBundles和skipTests标志,以及一个单调分配的 epoch。该 epoch 是每个矩阵流水线和重试的发布日期与原生打包时钟。任何版本有一个在飞 attempt,都会阻塞新的release。 - 准入精确的远端带注释标签对象 SHA、剥离出的 commit、
GITHUB_REF、GITHUB_SHA、检出的HEAD以及在origin/main上的祖先链。 - 运行完整的源码、Docker、Nix、PM bundle、安装/更新、Termux、Windows、签名包和原生升级验收图。
- 从已测试字节推送不可变的版本化 Docker 和 R2 产物。Docker 镜像在自己的测试通过后立即推送,按 attempt ref 打标签。暂不移动
stable或latest别名,也不为发布而重建。 - 在发布时(而非变绿时)创建带注释的最终
vMAJOR.MINOR.PATCH回执。它绑定获胜 attempt 的 ref、对象、commit、归档前缀、候选清单 SHA256(认领跳过 bundle 时为null)、Docker manifest 摘要和自动发布策略。 - 稳定发布控制器按最旧优先解析发布。它创建
vX.Y.Z,把仍是 draft 的 release 重定向到它,剥离警告块,最后才把 release 设为公开,然后验证并晋升回执绑定的 Docker 摘要,推进 App Installer、macOS、APT、下载页和受保护 R2 头。
单独一个变绿的 draft 会等待,除非其认领选择了自动发布。后来一个变绿的认领会按顺序冲刷掉所有连续的、更早的变绿 draft。一个仍在运行的旧认领阻塞更新的发布。一个烧坏的 attempt 被跳过,其版本在 abandon 后再次切割。失败、取消、缺失和意外跳过的需求保持红色。
Docker Hub、R2、APT、GitHub 和 Store 不支持跨服务的单一事务。因此控制器是幂等的:部分失败后,再运行一次,让每次变更在继续前验证自己的当前状态。最终标签是托管回执。绝不重建或替换已接受字节来修一个指针。
晋升替换 R2 公开源上 releases/stable/index.html 的稳定下载页。Canary 标签构建拥有 releases/canary/index.html,commit 构建拥有 releases/commit/<sha>/index.html。页面只列出已 staging、有回执支撑的对象;更旧的运行不能让受保护通道回退。
运行、发布或放弃一个稳定发布 {#run-publish-or-abandon-a-stable-release}
从远端 main 上一个精确 commit 起步:
python scripts/release.py release --commit "$(git rev-parse origin/main)" --bump patch --remote origin
--bump 默认为 patch;仅当该变更是有意时才传 minor 或 major。所选 commit 必须派生自最新已发布的 vX.Y.Z。没有已发布标签时,接受远端 main 上的任意 commit。一个被放弃的 attempt 不对下一次切割构成约束;它常因 commit 有问题而被放弃。当任何版本有任何在飞 attempt 时,release 也拒绝。拒绝时会先打印该 attempt 的工作流运行 URL、abandon 命令和重跑命令,再抛出。
attempt ref 是一把公开锁,不是 SemVer 预发布。它读作 rc.1-v0.21.5,而非 v0.21.5-rc.1。包版本保持普通的 0.21.5。scripts/releases/versioning.py 中的 parse_attempt_ref 是该形态的唯一句法。
draft 正文顶部和底部各带一个围栏警告块:不要从 GitHub UI 发布该 release。手动发布会跳过 vX.Y.Z 回执标签、更新 feed、Docker 别名和 Store 检查。已发布 release 不可变,因此手动发布的 release 事后无法修复,而且会阻塞流水线:attempt 既没有标记 ref 也没有最终标签,于是 release 把它当作在飞而拒绝,abandon 又因它已发布而拒绝。发布过程在 release 仍是 draft 时剥掉两个围栏块。
加 --autopublish 可在认领成为最旧的变绿发布时立即发布。不加它,release 保持 draft,直到显式发布或后来一个变绿认领强制有序解析。
跳过 bundle 或测试 {#skip-bundles-or-tests}
两个 release 标志移除流水线的部分环节。它们可一起用,并与 --autopublish 组合。
# 仅标签、GitHub release 和 Docker 镜像
python scripts/release.py release --commit "$(git rev-parse origin/main)" --skip-bundles --remote origin
# 紧急发布:构建并发布一切,不跑测试
python scripts/release.py release --commit "$(git rev-parse origin/main)" --skip-tests --remote origin
--skip-bundles | --skip-tests | |
|---|---|---|
源码 CI(ci.yaml)、Nix、Termux、Windows live、安装/更新 E2E、bootstrap 身份 | 运行 | 跳过 |
| Docker 镜像 | 构建、测试、发布 | 构建并发布,tests/docker 跳过 |
| 原生 PM bundle 检查 | 跳过 | 跳过 |
| 桌面和 Termux 候选 | 跳过 | 构建、签名并 staging,但不跑原生冒烟或构建内测试套件 |
签名包升级验收(transitions-*、*-packaged) | 跳过 | 跳过 |
| 发布 | 最终标签、GitHub release、Docker stable/latest 别名 | 一切,如同正常发布 |
标志写进认领消息,绝不作为工作流输入传入。admit 从认领读取它们并发出 skip-bundles 和 skip-tests。每个作业条件和每个门槛都读这些输出。一次恢复重跑不能改变它们。要改标志,abandon 该 attempt 再重新切割。
门槛保持严格。scripts.releases.stable gate 读取标志,并要求标志移除的每个作业报告 skipped,其他每个被门槛约束的作业报告 success。一个作业在标志移除它的情况下仍运行,也会阻塞发布。scripts/releases/stable.py 中的 SKIPPED_BY 是唯一一张"哪个标志移除哪个作业"的表。
--skip-bundles。 draft、Docker 镜像、最终标签和 GitHub release 就是整个发布。没有候选清单,因此最终标签记录 candidateManifestSha256: null。发布只移动 Docker 别名。受保护 R2 稳定头、App Installer 和 macOS feed、APT 通道、下载页、releases/stable/release-candidates.json、签名包基线和 Store 提交都停留在上一个 bundle 发布。官方仓库上的源码 checkout 跟随 releases/stable/release-candidates.json,因此它们也停留在上一个 bundle 发布。当 Docker stable 别名携带其最终标签所绑定的摘要时,这样的发布即告完成。定序器用该别名(在 R2 头旁)判断哪些发布仍需发布过程。
--skip-tests。 每个产物都按正常发布同样的方式构建、签名、staging 和发布。候选清单把每次原生冒烟记录为 skipped,绝不记为通过。下一个发布像对待其他任何清单一样用它作升级基线。每个知道该认领的读取方(complete 和受保护 R2 推进)都拒绝一份冒烟结果与认领的 skipTests 标志不一致的清单。仅用于紧急修复,并在其后切割一个正常发布。
认领推送是原子版本锁。两个调用方可能推导出同一下一个版本,但只有一个推送获胜;败者报告获胜的打标签者、时间和 commit。被拒绝或放弃的认领即作废。要发布或放弃:
python scripts/release.py publish --version 0.21.5 --remote origin
python scripts/release.py abandon --version 0.21.5 --remote origin
publish 先做一次同步的取代预检,再调度自动恢复所用的同一个有序控制器。它拒绝一个已知烧坏、且低于更新已发布 release 的版本。abandon 在 draft 存在时删除它,写一个 abandoned-rc.<N>-vX.Y.Z 标记 ref,并保留 attempt ref。标记是放弃记录;attempt ref 绝不删除。版本不作废,因此下一次切割是 rc.<N+1>-vX.Y.Z。
不要从最终标签手动调度 Stable Release。恢复保留原始认领 ref、对象 SHA、commit、draft 数据库 ID、自动发布策略和跳过标志。
失败与恢复 {#failure-and-recovery}
当一次稳定运行失败时,其完成事件启动 Stable Release Publication,它在同一次 GitHub Actions 运行中立即只重跑失败的作业。没有退避,也没有计划。该过程只应用最旧的未解决重试,因为 GitHub 在共享的稳定发布并发组中只保留一个待处理运行。重跑在该组中等待,直到过程结束。至多准入两次重试(运行尝试 2 和 3)。尝试 3 失败后,认领烧坏,定序器才可解析后续认领。丢失的重试请求、或发布变更之间的崩溃,由下一个失败事件或手动调度 Stable Release Publication 恢复。
新推送的认领若未观察到工作流运行,保持未解决一小时。这个宽限窗口覆盖非原子的 draft 和调度步骤。一小时后仍未开始的认领被推导为烧坏。在正常创建窗口内不烧坏任何认领。
桌面和 Termux 交接位于不可变的 R2 标签归档。归档键为 attempt ref:releases/tag/rc.<N>-vX.Y.Z/。releases/tag/vX.Y.Z/ 从不写入。每个生产者写一个 handoff-<target>.json 回执,含标签、commit、路径、大小和 SHA256 摘要。按架构的回执位于该前缀下候选清单旁。候选组装发出一个锁定的 release-candidates.json;消费方校验其摘要,不重新上传包。其摘要在发布前无处记录:发布过程对归档副本做哈希,并把该哈希写进最终标签。Docker 发布推送一个按 attempt ref 打标签的不可变版本化 manifest,并在最终标签记录其 registry 摘要。延迟发布在 registry 侧晋升该摘要,因此它不依赖会过期的 Actions 产物。
Windows、macOS 和 Termux 候选作业在验收前把包和 metadata staging 在 releases/tag/<attempt ref>/ 下。此阶段不写任何稳定 feed。稳定 APT pool 路径也带 attempt ref:releases/termux/stable/pool/<attempt ref>/<c>/<name>.deb。pool 上传不可变,带一年缓存头,因此同一版本的重新切割写入不同的 pool 键。不可变上传仅在字节匹配时接受既有对象。若归档对象、最终标签摘要、版本化 Docker 标签或回读不一致,停止恢复,而非替换已接受候选。
候选清单绑定准入的认领 epoch。准入从该 epoch 重算稳定 Windows 四元组,并拒绝一个仅格式正确但数值错误的 MSIX、可执行 VERSIONINFO 或 App Installer 版本。原生准入还从已构建包读取 Electron 产物文件名和 macOS plist。验收图盖戳一个隔离的 bootstrap 安装器树,请 Cargo 读生成的 Tauri 包版本,构建并检查 Python wheel/sdist 元数据和文件名,并核对 Nix 和 Docker 运行时身份。任何面向消费方的 0.0.0 或不匹配版本都使门槛失败。
draft 停在 attempt ref 上直到发布。删除它是 abandon,且不烧坏版本。带注释的最终标签绑定随认领准入的 GitHub release 数据库 ID。发布只在 release 仍是 draft 时编辑它:创建 vX.Y.Z、把 release 重定向到它、从正文剥离警告块,并回读标签和正文。把 release 设为公开是最后一步。已发布 release 不可变,因此事后无法重定向或重打标签。
在发布前,没有任何东西把客户端指向 attempt。更新器比较版本,而非 URL,而这之所以安全,全在于此。releases/tag/<attempt ref>/index.html 的诊断页不是更新 feed,从它安装的 attempt 不具备升级安全:一个版本的每个 attempt 都有相同包版本,因此已发布构建永不替换它。Docker 镜像早早就按 attempt ref 推送;stable 和 latest 别名在发布过程中随 feed 移动。Store 提交被挂起:变绿的运行在自动发布关闭下提交。提交 API 无法释放被挂起的提交,因此发布过程只检查它并打印一条 GitHub 警告;认证通过后,在 Partner Center 对该提交点 Publish now。一次失败的提交让发布运行保持红色。稳定通道记录携带一个可选 archiveRef 命名 attempt ref;releaseTag 保持 vX.Y.Z,当 archiveRef 缺失时受保护前缀回退到它。
桌面工作流的可选 termux_upgrade_from_tag 输入命名一个带 Termux R2 交接的、精确已发布 release。显式的非发布桌面构建不保留可下载的作业产物。
桌面工作流接受一个 jobs 输入,分组为 darwin-arm64、darwin-x64、win32-arm64、win32-x64、win32-bundle、linux-x64、linux-arm64 和 termux。稳定发布对每个分组调用一次,linux 分组除外——它们等待真实 Linux 构建。每个 Mac 架构和 Windows bundle staging 一个回执,每个安装 arm 一旦自己的字节 staging 好就从自己的回执起步。acceptance 是唯一阻塞发布的汇合点。
每个门槛和每个候选在 admit 后立即开始。CI 是 acceptance 所需的一个门槛,不是签名构建在其后等待的锁,因此一个慢门槛不能拖延 bundle;代价是坏掉的 main 仍要为签名候选付费,再被 acceptance 拒绝。smoke-win32-universal 作业已移除;按架构的 MSIX 冒烟覆盖每个架构,Windows 安装 arm 在两个架构上都安装 .msixbundle。
标签命名空间与回执 {#tag-namespaces-and-receipts}
在把 attempt ref 当锁依赖之前,对 refs/tags/v*、refs/tags/rc.* 和 refs/tags/abandoned-rc.* 应用一条仓库规则集,限制仅组织管理员和发布集成可创建,并阻止更新和删除。该规则集尚未应用;在管理员应用之前,这些锁靠自觉——任何有推送权限的人都能删除标记 ref 或移动回执标签。attempt ref、标记 ref 和最终标签不可变。通道和 commit 构建仅在发布成功后才写带注释的构建后回执,如 v0.0.7+channel.<YYYYMMDDTHHMMSSZ>.<run-id> 和 v0.0.0+commit.<YYYYMMDDTHHMMSSZ>.<run-id>。回执标签不创建 GitHub release,也不触发工作流。
Canary 源码身份仅为 v<stable>+canary.<YYYYMMDDTHHMMSSZ>。构建元数据刻意让它在 SemVer 上与其稳定核心相等;受保护通道记录和内嵌 UTC 时间戳决定推进。桌面客户端把那条已验证通道序列当作更新权威,而非让 SemVer 排序构建元数据。Canary Release 每天 UTC 06:41 从默认分支运行一次,手动调度可随时启动一次。运行以 cancel-in-progress: false 排队。若 main 没有新 commit,运行恢复一个未发布 canary 或什么都不做。历史的 -canary. 身份不受支持。若一个进程在推送 canary 标签后停止,重跑该命令会校验精确远端标签对象、修复缺失 draft 并重新调度,直到受保护 canary 头回执该标签。受保护发布控制器在把 GitHub 预发布从 draft 翻为公开之前校验那个精确的带注释标签对象,回读两种状态,然后才推进 R2 头。
Canary 与一次性桌面身份 {#canary-and-one-off-desktop-identities}
release.py --canary 构建独立的 canary 应用。其包身份和 CLI 命令(hermes-canary)与稳定版不同;既有 canary feed 只更新该应用。一次性构建用 release.py --build-commit REV --remote REMOTE(加 --publish 来调度)。其应用身份和 CLI 命令(hermes-<7 字符 sha>)包含锁定的 commit。两个不同 commit 构建互不替换。
品牌从这些构建输入选择,而非运行时设置:canary 用黄/深黄图标;一次性构建用带短 SHA 的红色图标。所有桌面图标格式派生自同一套美术资源。
一次性戳记用 source: commit-build。不为它们发布应用更新 feed 或 App Installer 订阅,GUI 和 bundle CLI 都拒绝更新请求。它们引导接收者向开发者索要新构建。源码 checkout 通道是分开的:hermes update --set-channel 在那里仍可用,选择已发布 release 的源码 commit。
--build-commit 在调度前打印其确定性下载页 URL,dry run 也打印:https://hermes-assets.nousresearch.com/releases/commit/<full-sha>/index.html。CLOUDFLARE_R2_PUBLIC_URL 覆盖公开源。准入后,即使某个构建或组装作业失败,commit 摘要仍运行;它只列出有回执支撑的既有下载,并把缺失二进制标记为未构建。缺失二进制链接到 View build run 下的工作流运行,而非不存在的下载。禁用平台没有下载或失败链接。页面发布仍需要可用的 R2 访问。commit 链接到其在 GitHub 上的源码;标签和通道页链接到对应 GitHub release 标签。commit 页还列出传给桌面 bundle 的显式非秘密 --bundle-env 默认值和 --bundle-unset 清除项,而非 CI 环境。值以 JSON 字符串显示(空值显示为 "");清除项标记为 Unset。未提供任何覆盖时省略该小节。
带标签构建还会在构建或 feed 失败后(包括未上传任何产物时)在 releases/tag/<tag>/index.html 发布一个按标签的诊断页。一次不完整构建不推进通道页,也不通过发布成功门槛。
Store 提交保留其固定官方稳定身份。非稳定包绝不得以该身份提交。
R2 中的动态通道 {#dynamic-channels-in-r2}
通道名是 R2 对象,不是仓库注册表。一个预览通道跨精确 commit 构建拥有一个原生应用身份。不可变构建请求分别记录其源码 commit、bundle 默认值、通道序列和包版本。既有原生构建和冒烟作业必须全部通过,通道头才推进。
预览一个自定义构建,再显式调度它。仓库身份不选择行为:同一直接调度可从任意 GitHub 远端运行,但 --channel 本地不做 R2 访问——它解析精确推送的 commit 并调度默认分支工作流,后者的特权分配步骤在 CI 中创建通道并铸造不可变构建请求。本地命令只需一个对所选仓库有 write、maintain 或 admin 权限的 gh token;不需要 R2 凭据。
python scripts/release.py --channel pm-preview --build-commit my-branch --remote origin
python scripts/release.py --channel pm-preview --build-commit my-branch --remote origin --publish
python scripts/release.py --channels --remote origin
一次性 R2 范围是可选加入,仅供测试运行。用 disposable_channel 和 build_commit 调度桌面工作流,在 ci-disposable/<repository-id>/<run-id>/ 下分配一个命名空间,然后用其摘要中精确的范围构建命令。要本地管理该分配,在命令环境中携带其 R2_DISPOSABLE_RUN 和 GITHUB_REPOSITORY_ID,配置的公开 URL 仍在未范围根。这些是测试运行输入,不是持久应用设置;仓库 ID 在读取凭据前对照所选远端校验,因此一个范围命名空间仍恰好属于一个仓库。
第一次发布调用创建通道(在 CI 分配期间),后续调用保留其身份。请求和产物位于 releases/channel-builds/BUILD_ID/ 下;可变指针是 releases/channels/NAME.json。失败构建保留其先前头不动。条件写入拒绝陈旧发布和永久退役的通道。重试重新调度 --channel NAME --build-commit SHA --publish,它在 CI 中分配新序列槽;没有单独的恢复命令。
退役把当前官方稳定构建固定为首个接收方:
python scripts/release.py --retire-channel pm-preview --to stable --minimum-version VERSION --remote origin
仅在审阅 dry run 和精确原生验收证据后才加 --publish。这不会在预览身份下重建稳定版,也不静默卸载客户端。协议感知客户端提供一次经同意的跨应用交接;目标必须在预览移除前确认就绪。保留退役对象和固定产物供离线客户端使用。既有一次性构建没有退役读取方,需要替换。
目标清单必须声明从其打包戳记读取的接收方支持。既有受保护发布工作流负责验收;没有单独的公开认证文档,也不依赖会过期的 Actions 产物。原生签名和身份检查、接收方同意、保留状态预检和目标就绪仍是强制的。一个离线预览即使在稳定版推进后也到达其固定的首个接收方;该应用随后经稳定版更新。
在发布仅 R2 的源码读取方之前,通过显式受保护 bootstrap 操作播种既有的 main 源码分支记录和已发布的稳定/canary 记录。审阅实际已接受的清单;不要为 main 编造原生清单。生产播种、CDN 缓存/CAS 校验和原生签名包认证是发布操作,不是本地辅助套件通过就能隐含的。绝不把 bootstrap 输出存为仓库通道列表。
签名包基线 {#signed-package-baseline}
上一个发布了 bundle 的成功稳定发布在配置的 R2 公开源上记录 releases/stable/release-candidates.json。跳过 bundle 的发布不替换它。它标识真实的 Windows 通用 MSIX bundle、macOS ZIP 和包来源。下一次运行把这些记录与其候选清单组合,并使用既有的原生 bundle 更新驱动。
对于早于该元数据的既有稳定发布,提供 baseline-manifest 作为配置 R2 公开源上的一个 HTTPS URL,指向其真实已发布包和成功原生冒烟结果的、当前 schema-2 清单。schema-1 候选被拒绝;没有旧版准入路径。清单重定向必须停在同源。基线标签必须是已发布稳定版,包身份必须一致,版本必须递增。稳定 Windows 包在准入的认领标签对象的不可变打标签者时间戳(year.hourOfYear.secondOfHour.0)之上使用 electron-builder 的 storePackageVersionAt 策略,独立于 SemVer 载荷标签。缺失基线产物是阻塞项,不是伪造或跳过验收的许可。见bundle 更新契约。
显式排除与策略 {#explicit-exclusions-and-policy}
- 桌面 Playwright E2E(
e2e-desktop.yml)应属主要求延后,因为它不稳定。它报告为 deferred,而非 passed。在把它加入本门槛之前,先稳定它并证明可重复的 CI 运行。 - 安装/更新 E2E 和原生签名包验收不延后。只有用
--skip-tests切割的认领才移除它们,门槛随后要求它们被跳过。 - 仅 PR 历史、标签和 diff 评审检查不适用于稳定标签。所有适用的源码 CI 作业仍运行,无论变更路径。
- OSV 漏洞发现保留其既有公告策略。必需的扫描器执行失败是失败,不是公告性发现。
- 已禁用的 Linux 桌面打包不声称为已发布。原生 Linux PM bundle、Docker、Nix 和安装/更新检查仍是必需的。
- housekeeping、autofix、comment 和 skills-index/deploy 工作流不是发布验收套件。
实现归属 {#implementation-ownership}
Actions 负责作业排序、runner 选择、权限和环境。Python 负责 scripts/releases/ 和 scripts/bundles/ 下共享的发布准入、清单、产物哈希、发布和通道晋升。electron-builder 配置/钩子和原生 Windows/macOS 适配器仍用 JavaScript 或 PowerShell。这些适配器消费发布事实,而非重新实现发布门槛。门槛作业只用 Python 标准库;它们不安装应用或 JS 工作区来报告结论。
scripts.bundles.release_artifacts 负责 App Installer XML 和 feed 发布。其序列化器接受显式包身份、发布者、版本、订阅 URI 和产物 URI。稳定晋升使用已接受候选元数据;canary 发布在上传 bundle(然后是描述符)之前,对照适配器预期身份校验原生 bundle 清单。原生 SDK 打包/签名仍在 stage-msixbundle.mjs。Store 和 commit 构建在该 feed 交接前停止;stable-store 提交已验证候选而不重建它。原生验收使用同一 Python 序列化器,包括其启动时 12 小时检查策略。
签名和发布凭据留在其受保护作业环境中。源码 CI 调用不继承部署密钥。运行本流水线之前,请配置既有的 release-signing 和 container-publish 环境。