用 Release Please 跑 Java 微服务的发布列车:双轨部署、SNAPSHOT 门禁和依赖钉扎
缘起
上一篇写 GitHub Actions 构建优化的时候,贴过一段服务仓库的 pipeline.yml,里面的 run-name 会把运行分成三类:[PR gate]、[Path-A · dev/qa] 和 [Path-B · prod train]。当时没有展开讲,这篇专门说说这套发布模型。
背景是这样的:我们有十几个 Java 微服务,跑在 ECS 上,环境有 dev、qa、preprod、prod 四个。早期的流程是合并到 main 之后,一路 dev、qa、preprod、prod 串行部署下去。这样做的问题是,任何一次合并都可能一路滚到 prod 门口,”什么时候发版”和”什么时候合代码”绑在了一起。
后来我们用 Release Please 把这两件事拆开:合代码只到 qa,发版是一次单独的、有版本号的动作。
下面以 gateway 服务为例,按 workflow 源码把这套流程过一遍。
两个 workflow,各管一摊
每个服务仓库里有两个 workflow。
release-please.yml 只负责版本:
1 | name: Release Please |
它在每次 main 有 push 时运行,根据 Conventional Commits 维护一个 Release PR;Release PR 合并后,创建 GitHub Release 和 tag。
pipeline.yml 负责构建和部署,响应 pull_request、push、release: published 和 workflow_dispatch 四种事件,真正的逻辑都在 DevOps 仓库的可复用 workflow 里。
这里有一个容易踩的坑:token 不能用默认的 GITHUB_TOKEN。GitHub 的规则是,用 GITHUB_TOKEN 触发的事件(除了 workflow_dispatch 和 repository_dispatch)不会再触发新的 workflow run。如果 Release Please 用 GITHUB_TOKEN 创建 Release,那 release: published 就不会触发 pipeline.yml,生产发布也就不会开始;它创建的 Release PR 上,PR 门禁也不会跑。所以我们用的是一个专门的机器人账号的 token。
main 只有两种状态
这套模型的核心,是 main 分支上 pom.xml 的版本号只允许处于两种状态之一:
1 | 开发态:x.y.z-SNAPSHOT |
Maven 类型的 Release Please 在发版之后,会自动再开一个 SNAPSHOT PR,把版本推到下一个开发版本。在这个 SNAPSHOT PR 合并之前,main 处于”冻结态”,这时候不允许合并功能 PR。原因很简单:如果在冻结态上继续合功能,下一个 Release PR 的版本基线就乱了。
这个约束靠下面要讲的 version-state-check 来执行。
双轨:Path A 和 Path B
pipeline.yml 调用的是 DevOps 仓库里的一个 facade workflow。部署范围由事件类型决定:
1 | deploy: |
Path A(test-only),由 push 到 main(以及手动触发)启动:
1 | deploy-dev → DAST → deploy-qa → qa-automated-test |
- dev 部署完之后,在 dev 上跑一轮 OWASP ZAP baseline 扫描;
- qa 部署完之后,调用 QA 团队的自动化测试 workflow,测试失败整条流水线就是失败;
- 到这里就结束了,不碰 preprod 和 prod。
Path B(release-only),由 release: published 启动:
1 | deploy-qa-release → qa-automated-test → deploy-preprod → deploy-prod |
- 跳过 dev 和 DAST,直接把带版本号 tag 的镜像部署到 qa;
- 自动化测试通过后才进 preprod,preprod 成功后才进 prod;
- prod 的部署 job 绑定了 GitHub Environment
prod,配置了必需审批人,部署前要在 Actions 页面点批准。
两条路径的分工是:Path A 保证每次合并都在 dev 和 qa 上验证过;Path B 保证进入 preprod 和 prod 的,只能是一个有版本号、有 tag、有 changelog 的发布。
reusable-cd.yml 里还保留着一个旧的 full 模式(dev、qa、preprod、prod 一路串行),给还没迁过来的服务用。它和 Path B 有一个关键区别:在 full 模式下,qa 自动化测试和 preprod 部署是并行的,测试失败不会挡住 preprod;Path B 里,自动化测试是进入 preprod 之前的硬门禁。
三道 PR 门禁
1. version-state-check:冻结态不收功能 PR
这个检查读的是 base 分支(也就是 main)的 pom.xml,不是 PR 自己的:
1 | - uses: actions/checkout@v7 |
如果 main 上的版本不是 -SNAPSHOT 结尾,就直接失败:
1 | if [[ "$VERSION" == *-SNAPSHOT ]]; then |
Release Please 自己开的 PR 要放行:分支名以 release-please-- 开头的、标题以 chore(main): release 开头的、带 autorelease: snapshot 标签的,都跳过检查。
读版本号这一步有个小坑。Maven 项目通常有 <parent>,而 parent 里也有一个 <version>,直接 grep -m1 '<version>' 拿到的是 parent 的版本(往往也是 SNAPSHOT),检查就失去了意义。所以脚本先跳过 </parent> 之前的部分:
1 | # Project <version> comes after <parent>; grep -m1 hits parent version (often SNAPSHOT). |
2. dependency-pin-check:发版时内部依赖不能是 SNAPSHOT
我们的服务之间有内部 Maven 依赖(共同的 parent POM、各种 client 和公共库)。开发态下依赖 SNAPSHOT 版本很正常,但正式发版的时候,如果还依赖着某个 SNAPSHOT,这个”正式版”其实是不可复现的。
这个检查会对比 PR 的 base 和 head 两个版本的所有 POM,对内部依赖执行两条规则:
- 规则 A:严格模式下,所有内部依赖都不能是 SNAPSHOT;
- 规则 B:PR 里改动过的内部依赖,版本不能比 base 低(降级),除非 PR 带了
dependency-pin:allow-downgrade标签。
什么时候进严格模式,是由 PR 的类型决定的:
1 | MODE=skip |
几个细节:
- SNAPSHOT PR 永远跳过,它的目的就是把版本推回开发态;
- Release PR 进严格模式,并且额外要求 parent POM 必须是正式版本,哪怕这个 PR 根本没改 parent;
- 普通 PR 默认跳过,但可以手动打
release-train: pin-deps标签进入严格模式,用于发版前专门调整依赖版本的 PR; - 判断 SNAPSHOT PR 时,除了看标签,还看标题。注释里写得很清楚:Release Please 是先创建 PR、再打标签的,第一次触发的
pull_request事件里,标签数组可能是空的。只看标签的话,SNAPSHOT PR 的第一次检查会被误判成 Release PR,走进严格模式然后失败。标题是创建 PR 时一次性写好的,不存在这个时间差。
3. Maven -Prelease:在构建层面再挡一次
除了 PR 上的脚本检查,构建本身也会在发版场景下启用 Maven 的 release profile,里面用 enforcer 禁止内部依赖的 SNAPSHOT 版本。
启用条件在 facade 里:
1 | maven_release_profile: >- |
也就是两种情况:release: published 事件,以及 Release Please 开的正式 Release PR。
reusable-ci.yml 里还有一道保险:即使调用方传了 true,只要是 SNAPSHOT PR,也不会启用。因为 Release Please 的分支同时承载 Release PR 和 SNAPSHOT PR,对后者启用 enforcer 一定会失败。
脚本检查和 enforcer 看起来重复,其实各有分工:脚本检查的是 PR 改动前后的差异(包括降级),enforcer 检查的是构建时真正解析出来的依赖。
跳过规则:两层
Release PR 合并时会产生一次 push 到 main,紧接着 Release Please 创建 Release,又会触发一次 release: published。如果两次都构建、部署,就重复了。真正的生产构建应该只在 release: published 上发生。
第一层在 facade 的 build job 上:
1 | build: |
提交信息包含 chore(main): release、又不是 SNAPSHOT 的 push,不构建。
第二层在 reusable-cd.yml 的第一个 job check-branch 上:
1 | check-branch: |
这一层没有 SNAPSHOT 的例外。按代码推下来,合并 SNAPSHOT PR 产生的 push(提交信息是 chore(main): release x.y.z-SNAPSHOT)会通过第一层,构建出一个 SNAPSHOT 镜像,但在第二层被跳过,不会部署到 dev 和 qa。这本身没什么问题,SNAPSHOT PR 只改了版本号,没有需要验证的代码变化;只是那次构建出来的镜像,其实没人用。
check-branch 对 release 事件还有一个来源检查:只接受由发布机器人创建、或者 target_commitish 是 main、或者是一个 40 位 commit SHA 的 Release。最后这个条件是专门为 Release Please 加的,它创建 Release 时,target_commitish 填的往往是合并提交的 SHA,而不是分支名。
生产镜像从哪来
Path B 构建时,image_tag 用的就是 Release 的 tag:
1 | image_tag: ${{ github.event_name == 'release' && github.event.release.tag_name || '' }} |
后面 qa、preprod、prod 三个环境部署的都是这同一个 tag 的镜像。prod 跑的是什么版本,看 tag 就知道,也能直接对应到 GitHub Release 页面上的 changelog。
我们的 ECR 开了 tag 不可变,所以 Path B 失败重跑时,会遇到”版本 tag 已经存在”的问题。上一篇构建优化里讲过处理方式:先查这个 tag 是否存在,存在且是同一个提交构建的就复用,不是就拒绝。
release-please-config.json
gateway 的配置是这样的:
1 | { |
版本号的升级规则来自 Conventional Commits:fix: 升 patch,feat: 升 minor,feat!: 或者带 BREAKING CHANGE 的提交升 major。
bump-minor-pre-major 和 bump-patch-for-minor-pre-major 这两项,作用是在 1.0.0 之前降一级升版本:破坏性变更只升 minor,新功能只升 patch。不过 gateway 的 manifest 里当前版本已经是 2.8.0,这两项实际上不起作用。留着无害,但读配置的时候不要被它们误导。
多说几句
回头看这套模型,有几点体会。
第一,把”合代码”和”发版”拆开,是整套设计的出发点。 合并只会走到 qa,进 preprod 和 prod 的只能是一个有版本号的 Release。代价是多了一个”发版”的动作,但换来的是:prod 上跑的每个版本都有 tag、有 changelog,也有一次明确的审批。
第二,冻结态是有成本的。 Release PR 合并之后、SNAPSHOT PR 合并之前,所有功能 PR 都会被 version-state-check 挡住。如果有人发完版忘了合 SNAPSHOT PR,整个团队的 PR 都会卡住。所以发版的固定动作里,最后一步一定是”合并 SNAPSHOT PR”。
第三,靠提交信息做判断很脆弱,但这里没有更好的办法。 跳过规则、门禁的豁免条件,大量依赖 chore(main): release、-SNAPSHOT、release-please-- 这些字符串。只要 Release Please 改了 PR 标题或分支名的格式,这些判断就会失效。好在这些格式都比较稳定;升级 release-please-action 的大版本时,值得专门回归一下。
第四,并发组要留意 release 事件。 pipeline.yml 的并发配置是:
1 | concurrency: |
按事件类型拆分并发组,是为了让 Release PR 上的 pull_request 运行,不会取消 main 合并后的 push 部署。但这也意味着,如果同一个仓库短时间内发布了两个 Release,后一个会取消前一个正在进行的 Path B。正常节奏下不太会发生,但如果有人连续发两个版本,前一个可能停在 preprod 或 prod 的审批上就被取消了。
第五,门禁要能区分”谁开的 PR”。 同一套门禁,对功能 PR、Release PR、SNAPSHOT PR 的要求完全不同。如果不做区分,要么 Release Please 自己的 PR 过不了检查,要么检查对谁都形同虚设。这次梳理下来,门禁逻辑里最多的代码,其实都在处理”这是哪一种 PR”。