用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
name: Release Please

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}

它在每次 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
开发态:x.y.z-SNAPSHOT
│ 功能 PR 陆续合并,每次合并走 Path A(dev / qa)
│
│ Release Please 维护 Release PR:chore(main): release x.y.z
▼
合并 Release PR
│
冻结态:x.y.z
│ Release Please 创建 GitHub Release(tag)
│ release: published 触发 Path B(qa → preprod → prod)
│
│ Release Please 打开 SNAPSHOT PR:chore(main): release x.y.(z+1)-SNAPSHOT
▼
合并 SNAPSHOT PR
│
回到开发态

Maven 类型的 Release Please 在发版之后,会自动再开一个 SNAPSHOT PR,把版本推到下一个开发版本。在这个 SNAPSHOT PR 合并之前,main 处于”冻结态”,这时候不允许合并功能 PR。原因很简单:如果在冻结态上继续合功能,下一个 Release PR 的版本基线就乱了。

这个约束靠下面要讲的 version-state-check 来执行。

双轨:Path A 和 Path B

pipeline.yml 调用的是 DevOps 仓库里的一个 facade workflow。部署范围由事件类型决定:

1
2
3
4
5
6
deploy:
uses: <org>/devops/.github/workflows/reusable-cd.yml@main
with:
image_tag: ${{ needs.build.outputs.image_tag }}
deploy_scope: ${{ github.event_name == 'release' && 'release-only' || 'test-only' }}
release_autotest_env: qa

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
2
3
4
- uses: actions/checkout@v7
with:
ref: ${{ github.base_ref }}
fetch-depth: 1

如果 main 上的版本不是 -SNAPSHOT 结尾,就直接失败:

1
2
3
4
5
6
7
if [[ "$VERSION" == *-SNAPSHOT ]]; then
echo "main is in SNAPSHOT development state: $VERSION"
exit 0
fi

echo "::error::main is at released version '$VERSION'. Merge SNAPSHOT PR first."
exit 1

Release Please 自己开的 PR 要放行:分支名以 release-please-- 开头的、标题以 chore(main): release 开头的、带 autorelease: snapshot 标签的,都跳过检查。

读版本号这一步有个小坑。Maven 项目通常有 <parent>,而 parent 里也有一个 <version>,直接 grep -m1 '<version>' 拿到的是 parent 的版本(往往也是 SNAPSHOT),检查就失去了意义。所以脚本先跳过 </parent> 之前的部分:

1
2
3
4
5
6
7
8
# Project <version> comes after <parent>; grep -m1 hits parent version (often SNAPSHOT).
if grep -q '</parent>' pom.xml; then
VERSION=$(sed -n '/<\/parent>/,$p' pom.xml | grep -m1 '<version>' \
| sed -E 's/.*<version>([^<]+)<\/version>.*/\1/' | tr -d '[:space:]')
else
VERSION=$(grep -m1 '<version>' pom.xml \
| sed -E 's/.*<version>([^<]+)<\/version>.*/\1/' | tr -d '[:space:]')
fi

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
MODE=skip
REQUIRE_PARENT=false
# release-please creates the PR before applying its label, so the
# initial pull_request event can have an empty labels array. The
# title is available atomically and identifies the snapshot bump.
if echo "$LABELS" | grep -q 'autorelease: snapshot' \
|| [[ "$PR_TITLE" =~ -SNAPSHOT[[:space:]]*$ ]]; then
echo "mode=skip" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "$HEAD_REF" == release-please--* ]] \
|| [[ "$PR_TITLE" =~ ^chore\(main\):[[:space:]]release ]]; then
MODE=strict
REQUIRE_PARENT=true
fi
if echo "$LABELS" | grep -q "$STRICT_LABEL"; then
MODE=strict
fi

几个细节:

  • 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
2
3
4
5
6
7
8
maven_release_profile: >-
${{ github.event_name == 'release'
|| (
github.event_name == 'pull_request'
&& startsWith(github.head_ref, 'release-please--branches--')
&& !endsWith(github.event.pull_request.title, '-SNAPSHOT')
&& !contains(github.event.pull_request.labels.*.name, 'autorelease: snapshot')
) }}

也就是两种情况: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
2
3
4
5
6
7
8
9
build:
if: >-
always()
&& ...
&& (
github.event_name != 'push'
|| !contains(github.event.head_commit.message || '', 'chore(main): release')
|| contains(github.event.head_commit.message || '', '-SNAPSHOT')
)

提交信息包含 chore(main): release、又不是 SNAPSHOT 的 push,不构建。

第二层在 reusable-cd.yml 的第一个 job check-branch 上:

1
2
3
4
5
6
7
check-branch:
# Skip CD for release-please merge commits (only version/CHANGELOG changes)
if: >-
github.event_name != 'push' || (
!contains(github.event.head_commit.message || '', 'release-please--branches--') &&
!contains(github.event.head_commit.message || '', 'chore(main): release')
)

这一层没有 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
2
3
4
5
6
7
8
9
10
11
12
13
{
"release-type": "maven",
"packages": {
".": {
"release-type": "maven",
"component": "<service-name>",
"changelog-path": "CHANGELOG.md",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"extra-files": ["pom.xml"]
}
}
}

版本号的升级规则来自 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
2
3
concurrency:
group: cicd-${{ github.repository }}-${{ github.event_name }}
cancel-in-progress: true

按事件类型拆分并发组,是为了让 Release PR 上的 pull_request 运行,不会取消 main 合并后的 push 部署。但这也意味着,如果同一个仓库短时间内发布了两个 Release,后一个会取消前一个正在进行的 Path B。正常节奏下不太会发生,但如果有人连续发两个版本,前一个可能停在 preprod 或 prod 的审批上就被取消了。

第五,门禁要能区分”谁开的 PR”。 同一套门禁,对功能 PR、Release PR、SNAPSHOT PR 的要求完全不同。如果不做区分,要么 Release Please 自己的 PR 过不了检查,要么检查对谁都形同虚设。这次梳理下来,门禁逻辑里最多的代码,其实都在处理”这是哪一种 PR”。

参考资料