一、问题背景:我们为什么重做工作流

先说背景。团队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了其实没看":

  1. 每个MR至少1个非作者的Approval,核心模块(支付、订单、权限)要求2个,且必须包含对应CODEOWNERS;
  2. MR描述必须填模板:变更点、影响范围、测试方式、回滚方案,四项缺一不予合并;
  3. CI必须全绿,且合并队列(Merge Train) 开启,避免"本地绿了合进去红了";
  4. 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了。发布频率翻倍才是这套流程真正的收益——流程顺了,才敢发得勤。

七、总结

回头看,这套工作流能跑起来,靠的不是某个工具,而是三件事:

  1. 主干保护是底线,push_access_level=0这一条比写十页规范都管用;
  2. CI必须卡在合并前,Merge Train解决了"本地绿合进去红"的经典问题;
  3. 流程要能被团队真实执行,我们砍掉develop、给hotfix开fast lane,都是在"规范"和"人性"之间找平衡。

如果你们团队也在从"Git当SVN用"往规范化走,建议先做两件事:把main保护起来,把MR模板和CI跑通。这两步做完,80%的混乱就没了。剩下的分支策略细节,可以边跑边调——毕竟工作流是长出来的,不是设计出来的。