#ci-cd#deploy

Violet 的 CI/CD:从合并代码到生产发布

Mizuki_Miru
Mizuki_Miru
2026年10月2日27 次阅读

Violet 是一个 Go API 与 React Web 共用仓库的博客系统,生产侧通过 Docker Compose 组织服务。代码放在一起,方便跨端修改,但也让“发布一个版本”有了几种不同含义:Git 中的一个提交、release-please 生成的版本号、两份镜像,以及服务器上实际运行的组合。

这些对象不会天然保持一致。功能 PR 可以连续合并,检查和镜像构建的完成顺序可能不同;只更新 Web 时,API 仍可能来自更早的提交;应用恢复到旧镜像后,数据库也未必能退回原来的结构。

因此,这套流水线围绕一条关系展开:先确定要发布哪份代码,再固定准备部署的产物,最后用一次受控的生产切换,把候选版本变成可核对的运行状态。 版本号负责表达发布意图,提交 SHA 负责定位代码,镜像 digest 负责定位产物,而生产记录必须描述完整的实际组合。

版本号属于一批变更

日常开发合入 release/2.0。release-please 根据 Conventional Commits 维护发布 PR,更新版本清单和变更日志;发布 PR 合并后,再生成 tag 与 GitHub Release。Deploy 由 v* tag 触发,但 tag 的出现只说明版本已经生成,生产是否完成切换由后面的部署事务决定。

通常让 release-please 从提交类型推导版本即可。需要指定版本时,可以使用提交消息中的 Release-As footer。它表达的是一个具体目标版本,而不是“无论何时合并都发下一个 patch”。两者在并行开发时有很大区别。release-please 的版本指定说明

假设当前版本清单为 2.8.53,两个独立 PR 都写了 Release-As: v2.8.54。以下两种合并顺序的含义不同:

合并顺序结果及原因
A、B 先后合入,同属下一次发布批次两者都在请求尚未发布的 2.8.54,可以汇总到同一个发布 PR
A 合入,随后发布 PR 合并,B 之后才合入版本基线已经推进到 2.8.54,B 仍锁定旧目标,意图已过期

问题不在于两条分支曾经写过相同版本,而在于这份意图被执行时,相对发布基线是否仍然成立。让每条并行分支自行计算“下一个版本”,会把同一个全局决定复制成多份有时效的局部信息。

Violet 用两层检查约束这个问题。PR 检查以事件中的 base 提交为基线,只扫描尚未被 base 包含的提交,要求新增的 Release-As 严格大于基线版本;发布 PR 或实际修改版本清单的分支,还必须使清单版本前进。已经发布过的历史 footer 不重新参与判断,普通分支仅因从旧版本分叉,也不会被当作主动降版本。

release-please 执行前再检查一次当前发布分支:沿第一父链找到最近修改版本清单的提交,以它为边界检查尚未发布的批次,并核对最近一次版本清单更新确实向前。这样,PR 检查之后发生的版本基线变化,也有机会在生成发布前被发现。1

这两层检查不会分配版本,也不是合并队列。如果分支规则允许拿旧的绿色检查直接合并,过期意图仍可能先进入主干,再被第二层检查拦住。检查器也只判断目标是否大于基线,不负责协调多条不同的未来版本声明。

更稳妥的使用方式是把版本看作批次决策:默认不锁版本;确实需要指定时,在发布批次确定后集中表达意图。已经写过 footer 的未合并分支,要随基线变化更新或移除旧声明。再补一个更大的 Release-As 不会抵消旧 footer,因为检查器会检查新增提交里的每一个目标。

PR 检查和发布检查回答不同的问题

Go 和 React 共处一个仓库,并不意味着每次改动都必须执行全部检查。修改 API 时运行后端检查,修改 Web 时运行前端检查,能让日常反馈更集中。Violet 在 PR 和普通 push 上按路径裁剪;没有对应侧改动时,仍保留既有检查名称,以成功结果结束该侧任务。

但这种绿色结果回答的是“这次差异需要的检查是否完成”,不能直接解释为“某个完整发布快照已经验证”。例如版本清单和变更日志更新可能没有应用代码差异,路径过滤就不需要再次运行两端测试。把这种结果作为部署依据,会混淆检查范围。

发布路径采用另一份明确合同:先把 tag 解析为完整提交 SHA,确认它位于发布分支历史中,再用这个 SHA 调用可复用 CI。调用处的关键部分如下,省略了权限等外围配置:

yaml
validate:
  needs: prepare
  uses: ./.github/workflows/ci.yml
  with:
    ref: ${{ needs.prepare.outputs.revision }}
    full: true
    build_images: false

full: true 让两端检查都执行,所有 checkout 指向同一个 ref。build_images: false 则避免 CI 内重复构建 Docker 镜像,因为 Deploy 自己还有两条正式的镜像构建任务。普通 PR 和 push 仍保留 Docker 构建验证。2

这些检查覆盖不同边界:后端除了编译和静态检查,还通过临时 PostgreSQL 执行持久化与迁移相关测试;前端在构建后运行 Playwright 契约测试;Infrastructure 则校验工作流、脚本和 Compose 配置。最后一类尤其必要:修改部署脚本不一定触发 API 或 Web 的路径规则,但脚本本身决定了生产如何变化。Infrastructure 的失败会传递到既有的 Dependency Security 检查,使使用该检查名的分支门禁也能拦住基础设施检查失败。

取消策略也按目标区分。同一个 PR 的新提交可以取消它的旧检查,因为后者通常已经失去反馈价值;发布分支上的不同 SHA 则彼此独立,不能让一次后续 push 取消前一个候选版本所需的验证。部署调用也以明确 SHA 为单位,不再等着某次恰好还活着的主干 CI run 提供答案。

把等待留在生产切换之前

明确了检查对象以后,检查和构建可以并行。它们都读取固定提交,不需要先改变生产环境。

flowchart LR
    target[解析候选提交 SHA] --> ci[完整 CI]
    target --> api[构建 API 镜像]
    target --> web[构建 Web 镜像]
    ci --> gate{全部成功}
    api --> gate
    web --> gate
    gate --> prod[生产互斥任务]
    prod --> lock[服务器锁内复核当前状态]
    lock --> switch[迁移、切换与验证]

图中的汇合点很重要。并行不意味着检查失败的镜像也能上线;它只让三个准备过程不必互相等待。代价是 CI 最终失败时,已经完成的镜像构建可能成为无用工作。换来的则是生产锁不再被安装依赖、跑测试和编译镜像占用。

从依赖关系看,一次正常发布的总用时可以近似拆为:准备目标 + max(完整 CI, API 构建, Web 构建) + 等待生产锁 + 生产事务。这是执行结构,不是耗时测量;生产事务中的拉取镜像、迁移、资源准备与检查仍需要依次完成。

真正改变运行环境的 deploy job 才进入 deploy-production 并发组,并且不主动取消正在执行的生产任务。服务器侧再通过文件锁互斥,使 Actions 与手动入口调用同一个事务脚本时,也遵守同一条执行边界。

不过,互斥并不等于版本按先来后到上线。Violet 使用的 GitHub Actions 默认 concurrency 模式只保留一个待运行项,较新的等待项可能替换旧项,也不能把任务执行顺序当作 FIFO。3

即使等待队列严格有序,构建完成顺序仍可能变化。设提交 A 是 B 的祖先,B 先完成并部署,A 的构建后来才结束。若服务器只看“现在轮到 A”,就会让正常发布意外退回旧代码。

因此,发布顺序在服务器锁内重新判断。脚本先读取已确认的生产记录,再比较候选提交和当前记录的祖先关系:后代可以继续;普通发布遇到不相关历史则拒绝,要求走明确的回滚入口;候选提交比当前配置提交更旧,并且已经被各个选中组件的提交包含时,标记为被更新版本取代,不再切换。相同 SHA 不进入这个取代分支,同版本、同镜像组合仍可再次执行;同版本换成不同 digest 则由归档身份检查拒绝。

这里不能只检查一个“整站最新 SHA”。如果 B 只发布了 Web,API 仍停在比 A 更早的提交,A 对于 API 未必已经过时。脚本同时保留 API 和 Web 的组件 revision;当共享配置已经更新,但本次选中组件尚未包含这个旧候选时,它不会草率地宣布“版本已覆盖”,而是拒绝这次组合,要求用新的完整发布协调状态。4

这套策略允许中间候选不进入生产。它追求的是生产状态向可解释的目标前进,而不是保证每个 tag 都轮流运行一次。

生产记录必须描述两份镜像和它们的配置

准备阶段固定了代码 SHA,还需要固定构建产物。镜像 tag 可以移动,同一个名称在不同时间拉取,可能得到不同内容。digest 则按内容定位镜像,适合把“这次构建的结果”传递给部署阶段。Docker 对 image digest 的说明

Violet 的候选清单把版本名、代码 revision、API digest 和 Web digest 放在一起。服务器再为这次发布保存 Compose 配置、镜像覆盖配置及 nginx 配置。它还核对配置 checkout 的 HEAD 与候选 revision 一致,相关配置没有偏离该提交,避免“镜像是新版本、配置来自另一个工作目录”的组合。5

这些信息各有用途:

记录回答的问题
版本名这批变更如何向人表达
提交 revision候选代码来自哪里,和当前生产是什么关系
API、Web 镜像 digest实际要启动哪两个构建产物
组件版本与 revision单侧发布后,两端分别来自哪里
Compose 与 nginx 快照这组镜像按什么服务配置运行
schema 版本与迁移记录数据库已经走到哪里,恢复应用时受什么约束

完整记录也约束单侧发布。选择只发布 Web 时,清单中的 API 镜像会被替换为当前已确认的 API 镜像,而不是假装两端都升级到了候选提交。最终记录仍包含两侧镜像,恢复也以这组组合为单位。

但“单侧”只控制镜像选择,并不代表所有共享状态都不变。准备阶段仍构建两份候选镜像,生产阶段才保留未选中侧的旧镜像;候选的 Compose 和 nginx 配置仍会更新,切换时也管理两端服务。因此,新配置必须兼容被保留的旧组件;它不是两个完全独立的发布通道,也不保证未选中的进程绝不重启。

同一个归档版本不能随后换成另一组 revision 和镜像。这避免版本标识在多次操作后失去确定含义。首次接入时,脚本会捕获正在运行的实例形成一个 bootstrap 归档;已有的 Git tag 不会因此自动变成可恢复归档。缓存回滚必须指向服务器真实保存过的完整组合,并要求相关镜像仍在本地。

这里的“完整”有明确范围:它包含应用镜像与选定的部署配置,不是整台服务器或数据库的时间点备份。运行环境中的秘密配置也不因保存发布清单就获得历史恢复能力。

应用可以恢复,数据库不能假装没有前进

数据库让发布过程无法成为一个简单的“全部成功,否则原样撤销”。新 API 可能要求先增加字段,迁移已经提交后,即使应用切换失败,数据库也已经改变。

Violet 把生产迁移从应用启动中分离出来:部署入口显式运行候选镜像的迁移命令,生产 API 启动只读检查 schema,不再依靠每次启动时的自动建表补齐结构。检查要求数据库状态干净,且达到应用需要的迁移版本;数据库比旧程序认识的版本更新,本身并不构成拒绝启动的理由。

这允许应用恢复,但不自动证明业务兼容。迁移发生时,旧实例仍可能处理请求;迁移完成以后,切换失败又可能需要启动旧镜像。因此,旧代码必须在这段窗口内仍能正确读写新 schema。6

例如,把一个仍被旧代码读取的字段直接重命名,可能让新代码工作正常,却使恢复失去意义。更适合这一流程的是分阶段演进:先增加新结构,保持旧读写路径可用;再迁移使用方式;等旧版本不再是恢复目标后,才删除旧结构。新增约束、修改默认值同样需要审视旧写入是否还能成立,不能只检查新程序能否查询。

对于历史镜像启动时仍会读取迁移目录的情况,部署脚本还保存与当前干净 schema 版本匹配的迁移文件集合,恢复时只读挂载给旧镜像。它解决的是旧镜像缺少新迁移版本文件的问题;不会替旧业务代码补上对新数据结构的理解。

生产事务因此按失败位置处理,而不是统一执行一次 rollback:

flowchart TD
    start[持有生产锁,写入 pending] --> migration[执行迁移并检查 schema]
    migration -->|失败或中断| blocked[保留 pending,停止推进]
    migration -->|成功| assets[准备静态资源]
    assets -->|成功| switch[切换镜像与配置]
    assets -->|失败| advanced{schema 已前进?}
    advanced -->|是| restore[恢复上一份镜像与配置]
    advanced -->|否| abort[清除 pending,失败退出]
    switch --> verify[核对镜像身份和公网服务]
    verify -->|通过| commit[归档并更新 current,清除 pending]
    switch -->|切换失败| restore
    verify -->|未通过| restore
    restore -->|恢复验证通过| previous[记录恢复后的状态,清除 pending]
    restore -->|恢复失败或恢复期间硬终止| blocked

这是“需要迁移的正常发布”的主要路径。数据库迁移失败、状态 dirty 或迁移中断时,脚本保留 pending,不自动把版本标记改成干净,也不盲目重启旧应用。schema 已确认前进后,即使只是在准备静态资源时失败、容器尚未切换,也会尝试恢复 previous,把与当前 schema 匹配的迁移文件集合交给旧 API,再重新验证。相反,schema 没有变化且服务尚未开始切换时,资源准备失败可以清除 pending 后退出,不重启 previous。开始切换之后的失败则进入完整恢复路径;恢复应用时数据库保留已经完成的迁移,不执行 down。

恢复也要经过验证。不能因为旧镜像的启动命令返回成功,就删除未完成记录。迁移已确认升级或开始切换后收到可捕获的 SIGINT/SIGTERM,会进入补偿恢复;恢复期间会忽略再次到来的这两种信号。恢复成功便清除 pending,而恢复失败,或进程在未完成恢复或提交前被硬终止而留下 pending 时,下一次执行会在锁内要求先核对生产状态,防止新的部署覆盖尚未解释清楚的现场。若只是成功写入 current 后来不及清理同一事务的 pending,脚本则可以通过事务标识识别并完成清理。7

所以,这里的事务是有日志和补偿动作的部署过程,不是数据库式的原子提交。服务切换可能出现过渡窗口,磁盘上可能留下已经准备好的资源,数据库可能已前进。它所提供的是明确的阶段、恢复目标,以及无法确认时停止继续修改生产的机制。

“服务能访问”和“目标版本在运行”要分别确认

健康检查只能证明它覆盖的行为。一个旧 API 也可能返回健康,一个 nginx 错误回退也可能给 JavaScript 请求返回状态码 200。如果只据此宣布发布完成,记录就可能与实际环境脱节。

Violet 的验证先检查身份:读取实际 API、Web 容器的镜像 ID,并与清单指定镜像解析出的 ID 比较。随后从公网入口检查 API 健康状态、文章列表响应的分页结构、SSR 首页的 HTML 和标题,并提取首页引用的同源脚本、样式及 modulepreload 资源,确认它们不是空响应或 HTML 回退页面。8

身份核对与行为检查相互补充。前者避免“运行了别的镜像却恰好健康”,后者覆盖容器启动后到公网访问之间的反向代理与资源交付链路。这仍然不是每条业务路径的证明;例如首页资源检查不能穷尽所有交互后才请求的 chunk,但它比仅查看容器存活状态多覆盖了一层真实访问过程。

静态资源的准备也服务于这个边界。Web 发布时,脚本从候选镜像导出客户端资源,逐文件通过临时文件与重命名更新,并保留旧的 hash 文件。否则,一个早已打开旧页面的浏览器,可能在新版本清空目录后才请求旧的懒加载 chunk,结果页面本身还在,所需脚本却不存在。

逐文件原子更新不会让整个静态目录同时切换,也不回收所有旧文件。它选择用磁盘占用换取跨版本资源可达性;后续清理需要考虑仍可能存在的旧页面与缓存寿命。恢复旧应用时,同样重新准备旧镜像中的资源,而不是只把容器换回去。

只有清单中的两侧镜像与这些入口检查都满足条件,事务才把候选写成 current。之后下一次发布又会从这份记录开始,先核对运行实例是否仍与记录一致。版本记录因而既是上次部署的结果,也是下次部署的前提。

自动化止于它能够解释的失败

镜像构建失败可能适合重试,生产迁移失败则未必。Violet 的自动重试只评估 tag push 发起、已经以失败状态结束的 Deploy run,要求失败 job 只来自 API、Web 构建,且没有取消任务。总次数最多为初始执行一次、再重试两次;手动发布和回滚不进入这条通道,人工触发重试评估也不能绕过这些条件。检查、生产切换、迁移或验证失败都不会被这一机制自动重跑。9

重试前还会再次读取原 run 的状态和 attempt,避免等待期间已经有人发起重试,又叠加一次请求。构建重试成功后,下游生产任务仍可沿正常门禁执行;限制针对的是重试资格,不是承诺重试永远不进入生产。关键区别在于:失败的生产操作必须先被理解,不能因为“再试一次也许会好”而重放。

同样需要固定的是执行流水线的工具本身。第三方 Actions 使用完整提交 SHA,避免动作的版本 tag 后来移动,使同一份工作流在不同时间执行不同代码。固定动作内容不代表动作可信,也不替代更新和审查;它只是让执行依赖具有明确身份。GitHub 的第三方 Actions 安全建议

权限则按阶段划分:构建阶段需要写镜像仓库,生产阶段读取镜像,告警阶段写入问题记录。生产 runner、环境规则与受保护分支仍承担自己的职责,不能因为 YAML 固定了 SHA,就把不可信 PR 放进能够修改生产的执行环境。

从合并代码走到生产,真正需要贯穿的不是一个版本字符串,而是一组连续的依据:这批变更的版本意图仍然有效;检查和构建针对同一提交;待运行的镜像与配置已经固定;当前生产状态允许这次前进;切换后的实例和访问行为能够被核对。数据库兼容窗口与未完成事务则规定了什么时候必须停下来。这样,正常发布、单侧更新和失败恢复才是在同一套状态规则下做出的不同选择。


  1. 版本检查见 check-release-version.py;发布前的批次基线选择见 release-please.yml。 ↩︎

  2. 精确 ref、完整检查与 Docker 构建开关见 CI 工作流;并行调用见 Deploy 工作流。 ↩︎

  3. 这里讨论的是本工作流采用的默认模式;GitHub 也提供显式排队选项,不能把默认行为推广为所有配置都不支持排队。参见 GitHub Actions concurrency。 ↩︎

  4. 锁内候选版本、共享配置与组件祖先关系判断见部署入口的 transaction 方法。 ↩︎

  5. 清单生成见 Deploy 工作流;快照、首次接入与归档约束见 部署脚本。 ↩︎

  6. 应用启动中的生产分支见 infra.go;schema 检查与显式迁移见 migrate.go。 ↩︎

  7. 事务日志、迁移、补偿恢复与中断处理见 deploy-release.py;维护边界见 发布手册。 ↩︎

  8. 镜像身份与公网检查见 verify 方法,资源更新见同文件的 stage_assets。 ↩︎

  9. 自动重试资格、等待后的状态复核见 retry-deploy.py。 ↩︎

评论将在接近时加载。