一、问题背景:我们为什么重做工作流
先说背景。团队30人左右,后端18人、前端8人、测试4人,产品迭代周期两周。2022年底从SVN迁到Git,当时大家的用法基本是"把Git当SVN用":所有人往master上直接push,偶尔有人开个dev分支但没人维护。结果就是:
- 每周发布前必有一次"合并地狱",
master上经常出现半成品代码; - 线上出问题想回滚,发现回滚commit又带出别人的改动;
- Code Review形同虚设,MR(Merge Request)平均存活时间不到10分钟,基本是"自己批自己";
- 没有CI,测试环境靠人手动部署,发布窗口固定在周四晚上,出问题只能周五救火。
真正的转折点是一次事故:某次发布把还没测完的支付回调逻辑带上了生产,导致约40分钟的对账异常。事后复盘,根因不是代码本身,而是没有一条能拦住"未验证代码进入主干"的流程。
于是我们花了三周时间重新设计工作流。目标很明确:主干永远可发布、每次合并必须过CI和Review、发布可追溯可回滚。
二、环境与版本
先把环境列清楚,避免读者对不上:
- Git 2.43.0(服务端和客户端统一)
- GitLab CE 16.9.1(自建,Docker部署)
- GitLab Runner 16.9.1,注册了4个executor:2个docker、1个shell(跑部署)、1个k8s
- 代码规范检查:ESLint 8.57 / Checkstyle 10.12(Java侧)
- 构建:Maven 3.9.6 + Node 20.11
- 镜像仓库:Harbor 2.10
分支模型参考了Git Flow和GitHub Flow,最后砍成三个长期分支+两类临时分支,后面细说。
三、方案设计:分支策略与保护规则
3.1 分支模型
我们最终定下的结构:
main:主干,永远可发布,受保护,只接受MR合入;release/*:发布分支,从main切出,命名如release/2024.06,只接受cherry-pick的修复;develop:集成分支,日常功能合并到这里(实际上我们后来发现develop和main差异很小,就把它降级为"预发验证分支",保留但轻量化);feature/*:功能分支,从main切出,命名feature/ORDER-1234-refund,生命周期不超过5天;hotfix/*:紧急修复,从main切出,修完同时合回main和release。
关键决策:功能分支从main切,不从develop切。这是和标准Git Flow最大的差异。原因是我们的发布节奏快,develop长期存在会和main漂移,反而增加合并成本。改成从main切之后,每个功能分支的基线都是最新的可发布代码,冲突面小很多。
3.2 保护规则配置
在GitLab的Settings → Repository → Protected branches里配置,等价的项目级API配置如下(我们是用Terraform管理的,这里给curl形式便于理解):
# 保护 main 分支:仅 Maintainer 可合并,禁止直接 push
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/42/protected_branches" \
--form "name=main" \
--form "push_access_level=0" \
--form "merge_access_level=40" \
--form "allow_force_push=false" \
--form "code_owner_approval_required=true"
# release/* 通配保护
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/42/protected_branches" \
--form "name=release/*" \
--form "push_access_level=40" \
--form "merge_access_level=40"
其中push_access_level=0表示没人能直接push(包括Maintainer),merge_access_level=40表示只有Maintainer能点合并。这一条直接把"随手push主干"的路堵死了。
3.3 Code Review流程
Review规则我们定得很具体,避免"看起来review了其实没看":
- 每个MR至少1个非作者的Approval,核心模块(支付、订单、权限)要求2个,且必须包含对应CODEOWNERS;
- MR描述必须填模板:变更点、影响范围、测试方式、回滚方案,四项缺一不予合并;
- CI必须全绿,且合并队列(Merge Train) 开启,避免"本地绿了合进去红了";
- MR大小软限制:diff超过400行会打
large-mr标签,需要在周会上说明原因。
CODEOWNERS文件示例:
# .gitlab/CODEOWNERS
* @team/backend-lead
/services/payment/ @team/payment-owners
/services/auth/ @team/security-owners
/frontend/ @team/frontend-lead
MR模板(.gitlab/merge_request_templates/default.md):
## 变更点
## 影响范围
- [ ] 涉及数据库变更(附迁移脚本链接)
- [ ] 涉及接口变更(附文档链接)
- [ ] 涉及配置变更
## 测试方式
## 回滚方案
这套模板刚上线时被吐槽"填表麻烦",但两个月后没人再提——因为每次出问题复盘时,回滚方案那一栏救了好几次场。
四、核心实现:CI/CD流水线配置
4.1 流水线阶段设计
我们的流水线分5个stage:validate → build → test → review-app → deploy。前三个跑在每个MR上,review-app只在feature/*和release/*上跑(起临时环境),deploy只在main和release/*上跑。
4.2 .gitlab-ci.yml 完整配置
这是经过多轮调整后的版本,直接可跑(变量部分需按实际替换):
# .gitlab-ci.yml
stages:
- validate
- build
- test
- review-app
- deploy
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
DOCKER_DRIVER: overlay2
IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"
# 缓存 Maven 和 node_modules,实测命中后 build 阶段从 4min 降到 1min20s
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .m2/repository/
- frontend/node_modules/
# ---------- 1. 静态检查 ----------
lint:
stage: validate
image: node:20.11-alpine
script:
- cd frontend && npm ci --prefer-offline
- npm run lint -- --max-warnings=0
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH =~ /^(main|release\/.*)$/'
# ---------- 2. 构建 ----------
build-backend:
stage: build
image: maven:3.9.6-eclipse-temurin-17
script:
- mvn -B -q clean package -DskipTests
artifacts:
paths:
- target/*.jar
expire_in: 1 day
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH =~ /^(main|release\/.*)$/'
# ---------- 3. 测试 ----------
unit-test:
stage: test
image: maven:3.9.6-eclipse-temurin-17
script:
- mvn -B test
coverage: '/Total.*?([0-9]{1,3})%/'
artifacts:
when: always
reports:
junit:
- target/surefire-reports/TEST-*.xml
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# ---------- 4. 临时评审环境 ----------
review-app:
stage: review-app
image: bitnami/kubectl:1.29
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.example.com
on_stop: stop-review-app
script:
- kubectl -n review set image deploy/app app=harbor.example.com/app:$IMAGE_TAG
- kubectl -n review rollout status deploy/app --timeout=180s
rules:
- if: '$CI_COMMIT_BRANCH =~ /^feature\//'
- if: '$CI_COMMIT_BRANCH =~ /^release\//'
stop-review-app:
stage: review-app
image: bitnami/kubectl:1.29
script:
- kubectl -n review delete deploy/app --ignore-not-found
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
when: manual
rules:
- if: '$CI_COMMIT_BRANCH =~ /^feature\//'
when: manual
# ---------- 5. 部署 ----------
deploy-staging:
stage: deploy
image: bitnami/kubectl:1.29
environment:
name: staging
url: https://staging.example.com
script:
- kubectl -n staging set image deploy/app app=harbor.example.com/app:$IMAGE_TAG
- kubectl -n staging rollout status deploy/app --timeout=300s
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
deploy-prod:
stage: deploy
image: bitnami/kubectl:1.29
environment:
name: production
url: https://www.example.com
script:
- kubectl -n prod set image deploy/app app=harbor.example.com/app:$IMAGE_TAG
- kubectl -n prod rollout status deploy/app --timeout=300s
rules:
- if: '$CI_COMMIT_BRANCH =~ /^release\//'
when: manual
几个关键点说明:
rules里区分了merge_request_event和分支,MR流水线跑轻量检查,主干/发布分支才跑部署,避免每个feature分支都去动环境;review-app用environment绑定,GitLab会在MR页面显示环境链接,测试同学点进去就能验,MR合并后自动清理;- 生产部署是
when: manual,必须人工点,且只有Maintainer有权限,这是最后一道闸。
4.3 合并队列配置
GitLab 16.9的Merge Train在项目设置里开启:
Settings → Merge requests → Merge trains: 勾选
Merge method: Merge commit(保留合并历史,便于追溯)
Squash commits: Allow(feature分支允许squash)
开启后,多个MR可以排队合并,每个都会基于前一个的结果重新跑流水线。我们实测:日均50次合并的情况下,因为"合进去红了"导致的返工从每天2-3次降到接近0。
五、踩坑与优化
坑1:develop分支成了僵尸分支。 一开始按Git Flow保留了develop,结果大家习惯往main合,develop三天就落后几十个commit。后来直接砍掉它的"集成分支"定位,改成"预发验证分支",只从release/*同步,问题消失。教训是:分支模型要匹配团队真实习惯,不要照搬。
坑2:CI缓存key设计错误导致构建变慢。 最初cache.key用了固定值,不同分支互相覆盖缓存,Maven依赖经常拉不全,build阶段从1分20秒劣化到5分钟。改成$CI_COMMIT_REF_SLUG后恢复正常。另外注意cache和artifacts别混用,我们一开始把target/放cache,导致增量构建出过两次诡异的class冲突。
坑3:Review环境资源泄漏。 feature/*分支多的时候,review namespace堆了20多个deployment没清理。后来加了GitLab的environment自动停止(on_stop+定时清理策略,默认30天),再配合一个每周跑的清理Job,稳定在5个以内。
坑4:合并队列下hotfix被卡。 Merge Train按顺序跑,紧急修复如果排在十几个MR后面要等很久。解决办法是给hotfix/*分支单独开一条"fast lane":允许Maintainer临时关闭Merge Train直接合并(有审计日志),同时该MR必须带priority/hotfix标签。这条规则写进了团队规范,滥用要复盘。
优化后的一些参数调整:
- Runner并发从2调到6,MR流水线平均等待时间从90秒降到15秒;
- unit-test加了parallel: 4按测试包分片,Java单测从6分钟降到2分10秒;
- 流水线整体时长(MR场景)从11分钟压到4分半。
六、效果数据
跑了三个月后的对比(统计口径:GitLab API + 发布记录):
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 周均合并冲突 | 6-8次 | 2次左右 |
| MR平均存活时间 | 8分钟 | 4.2小时 |
| 发布回滚次数 | 每周约2次 | 每月0.3次 |
| 主干构建失败率 | 约18% | 2.1% |
| MR场景流水线时长 | 11分钟 | 4分30秒 |
| 发布频率 | 双周1次 | 每周2次 |
MR存活时间变长不是退步,而是因为真的有人在review了。发布频率翻倍才是这套流程真正的收益——流程顺了,才敢发得勤。
七、总结
回头看,这套工作流能跑起来,靠的不是某个工具,而是三件事:
- 主干保护是底线,
push_access_level=0这一条比写十页规范都管用; - CI必须卡在合并前,Merge Train解决了"本地绿合进去红"的经典问题;
- 流程要能被团队真实执行,我们砍掉
develop、给hotfix开fast lane,都是在"规范"和"人性"之间找平衡。
如果你们团队也在从"Git当SVN用"往规范化走,建议先做两件事:把main保护起来,把MR模板和CI跑通。这两步做完,80%的混乱就没了。剩下的分支策略细节,可以边跑边调——毕竟工作流是长出来的,不是设计出来的。