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

先说背景。我们是一个30人左右的后端+前端混合团队,维护一个Spring Boot 2.7 + Vue 3的中台系统,代码仓库在GitLab 15.8(自建CE版)。2023年初从SVN迁移过来时,基本是"能用就行"的状态:所有人往develop分支直接push,发版前临时拉个release分支,谁手快谁先合。

结果就是:

  • 每周三发版前,develop分支平均有11个未合并的feature分支,合并冲突要花半天解
  • 有次一个同事把本地调试用的application-dev.yml里的数据库密码提交上去了,三天后才发现
  • Code Review形同虚设,MR(Merge Request)平均存活时间6小时,大部分是"LGTM"式 rubber stamp
  • 线上出问题回滚,发现release分支和master差了20多个commit,回滚脚本直接失败

痛定思痛,我们花了两个月重新设计工作流,核心目标三个:分支职责单一、合并前必过CI、发布可追溯可回滚。下面是我们最终落地的方案。

二、环境与版本

先把环境交代清楚,避免配置对不上:

  • GitLab CE 15.8.3(自建,Runner是Docker Executor)
  • Git 2.39.2
  • JDK 17 + Maven 3.9.4
  • Node 18.17.1 + pnpm 8.7.0
  • SonarQube 9.9 LTS(社区版)
  • 部署环境:K8s 1.26,镜像仓库Harbor 2.8

分支模型参考的是Vincent Driessen的Git Flow,但砍掉了hotfix分支(我们线上问题走release分支补丁),实际保留5类分支:main、develop、feature/*、release/*、bugfix/*。

三、方案设计:分支策略与保护规则

3.1 分支职责定义

分支 来源 合并目标 生命周期 保护级别
main - - 永久 最高,仅Lead可push
develop main release 永久 高,禁止直接push
feature/* develop develop 功能上线即删 无
release/* develop main + develop 发版后保留1个版本 中
bugfix/* main main + develop 修复上线即删 无

关键约束:feature分支存活不超过5个工作日。我们后来加了个定时任务,每周一扫超过7天没活动的feature分支,发提醒到群,两周不动的自动打stale标签。这条规则把长期分支数量从平均14个压到了3个以内。

3.2 分支保护规则(GitLab配置)

在GitLab的Settings → Repository → Protected branches里配置,核心几条:

  • main:Allowed to merge = Maintainers,Allowed to push = No one,开启"Merge commit with semi-linear history"
  • develop:Allowed to merge = Developers+Maintainers,Allowed to push = No one
  • 两个分支都开启:Pipelines must succeed、All threads must be resolved、Require approval from 1 approver

半线性历史(semi-linear)这个选项很关键——它强制MR合并时rebase到目标分支最新,保证了git log是一条干净的线,出问题用git bisect定位非常快。代价是合并时偶尔要手动解冲突,但比merge commit满天飞强。

3.3 Code Review流程

我们的MR模板(.gitlab/merge_request_templates/default.md)是这样的:

## 变更类型
- [ ] 新功能  - [ ] Bug修复  - [ ] 重构  - [ ] 配置变更

## 变更说明


## 自测情况
- [ ] 本地单测通过  - [ ] 涉及接口已用Postman验证
- [ ] 涉及DB变更已附SQL  - [ ] 涉及配置变更已同步到config仓库

## Review Checklist(提交者自查)
- [ ] 没有硬编码密钥/密码/token
- [ ] 新增public方法有Javadoc
- [ ] 异常分支有日志且日志含traceId
- [ ] 单测覆盖率不低于60%(新增代码)

Review规则卡得比较死:至少1个approver,且approver不能是提交者本人;涉及核心模块(订单、支付、权限)必须2个approver。另外我们约定review响应时间:工作日4小时内必须给出第一轮反馈,否则MR作者可以直接@对方Leader。这条是从"MR平均存活6小时"压到"平均1.8小时"的主要原因——不是技术问题,是流程约束。

四、核心实现:GitLab CI/CD配置

我们的.gitlab-ci.yml(放在仓库根目录)跑了10个月,迭代了20多版,下面是精简后的核心结构。分四个stage:build → test → review → deploy。

# .gitlab-ci.yml
stages:
  - build
  - test
  - review
  - deploy

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
  DOCKER_DRIVER: overlay2
  IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"

# 缓存Maven和pnpm依赖,实测能省2分半
cache:
  key:
    files:
      - pom.xml
      - frontend/pnpm-lock.yaml
  paths:
    - .m2/repository/
    - frontend/node_modules/

build-backend:
  stage: build
  image: maven:3.9.4-eclipse-temurin-17
  script:
    - mvn -B clean package -DskipTests
  artifacts:
    paths:
      - target/*.jar
    expire_in: 1 day
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH =~ /^(develop|release\/.*|main)$/'

test-backend:
  stage: test
  image: maven:3.9.4-eclipse-temurin-17
  script:
    - mvn -B test
    - mvn -B jacoco:report
  coverage: '/Total.*?([0-9]{1,3})%/'
  artifacts:
    reports:
      junit: target/surefire-reports/TEST-*.xml
    paths:
      - target/site/jacoco/
    expire_in: 7 days

sonar-check:
  stage: test
  image: sonarsource/sonar-scanner-cli:5.0
  script:
    - sonar-scanner
      -Dsonar.projectKey=our-platform
      -Dsonar.host.url=$SONAR_URL
      -Dsonar.login=$SONAR_TOKEN
      -Dsonar.qualitygate.wait=true
  allow_failure: false
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

deploy-review:
  stage: review
  image: bitnami/kubectl:1.26
  script:
    - kubectl set image deployment/app app=$HARBOR_URL/app:$IMAGE_TAG -n review-$CI_MERGE_REQUEST_IID
    - kubectl rollout status deployment/app -n review-$CI_MERGE_REQUEST_IID --timeout=180s
  environment:
    name: review/$CI_MERGE_REQUEST_IID
    url: https://mr-$CI_MERGE_REQUEST_IID.review.ourdomain.com
    on_stop: stop-review
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"'

stop-review:
  stage: review
  image: bitnami/kubectl:1.26
  script:
    - kubectl delete namespace review-$CI_MERGE_REQUEST_IID --ignore-not-found
  environment:
    name: review/$CI_MERGE_REQUEST_IID
    action: stop
  when: manual
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

几个关键点说明:

Quality Gate卡合并。sonar.qualitygate.wait=true让CI等Sonar结果,不通过直接fail。我们的gate阈值:新代码覆盖率≥60%、重复率≤3%、Blocker问题=0。刚开始卡得全组哀嚎,两周后大家写代码就习惯性带单测了。

MR环境。每个MR自动在K8s的独立namespace里部署一套环境,URL直接回写到MR页面。测试同学点链接就能验,不用等开发打包。这个功能上线后,UI类bug的发现时间从"发版后"提前到"合并前"。

镜像tag用commit short sha,配合Harbor的tag immutable策略,保证任何一次部署都能精确回溯到代码。

部署到生产的部分单独放在另一个项目里(避免开发误操作),用的是GitLab的manual job + 双人审批:

# deploy-prod(独立项目,仅Maintainer可触发)
deploy-production:
  stage: deploy
  image: bitnami/kubectl:1.26
  script:
    - |
      if [ "$CI_COMMIT_BRANCH" != "main" ]; then
        echo "只有main分支允许部署生产"; exit 1
      fi
    - kubectl set image deployment/app app=$HARBOR_URL/app:$IMAGE_TAG -n prod
    - kubectl rollout status deployment/app -n prod --timeout=300s
    - |
      if [ $? -ne 0 ]; then
        echo "滚动更新失败,执行回滚"
        kubectl rollout undo deployment/app -n prod
        exit 1
      fi
  environment:
    name: production
  when: manual
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

rollout status超时自动rollout undo,这是我们的兜底。上线10个月触发过2次,都是因为健康检查没通过,回滚在40秒内完成,用户基本无感。

五、踩坑与优化

坑1:CI缓存key设计错误导致依赖串味。最早cache key直接用了$CI_COMMIT_REF_SLUG,结果每个feature分支一份缓存,Runner磁盘两周就爆了。改成按pom.xml和pnpm-lock.yaml的hash做key(就是上面配置里的key.files),缓存命中率反而从62%提到89%,Runner存储占用降了70%。

坑2:MR pipeline重复跑。GitLab默认对push和MR各跑一次pipeline,我们的配置里用rules只让MR事件和特定分支触发,但一开始漏了workflow规则,导致develop的push和MR双跑。加上这段后省了约35%的CI时长:

workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"'
    - if: '$CI_COMMIT_BRANCH =~ /^release\/.*/ && $CI_PIPELINE_SOURCE == "push"'
    - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'

坑3:rebase合并后CI结果失效。开启semi-linear后,MR合并前会rebase,rebase产生的commit没跑过CI。我们的做法是要求合并前必须"Pipeline passed on latest commit",GitLab的"Pipelines must succeed"配合"Merge when pipeline succeeds"能覆盖,但要注意别让作者在CI跑完前手动点merge——这个靠分支保护里的"Only allow merge if pipeline succeeds"卡死。

优化:CI时长从14分钟压到4分20秒。手段就三个:Maven依赖缓存、-T 1C并行构建(我们模块多)、单测分片(surefire的forkCount=2配合Runner并发)。前端那边pnpm的store缓存也起了大作用,install从90秒降到12秒。

六、效果数据

跑了10个月,几个关键指标对比(迁移前后各取3个月均值):

指标 改造前 改造后
主干合并冲突率 37% 6%
MR平均存活时间 6.1小时 1.8小时
CI平均反馈时间 14分10秒 4分20秒
月均发布事故 2.3次 0.2次
发布回滚次数 月均5次 0次(10个月)
新代码单测覆盖率 21% 67%

日均MR合并量从改造前的约25次涨到80次左右,但事故反而降了——说明大部分"慢"其实是流程混乱造成的,约束清楚之后吞吐自然上来。

七、总结

回头看,这套工作流能跑起来,技术配置只占三成,七成是规则被真的执行:分支存活期有人盯、review超时有人催、quality gate不通过就是不让你合。配置本身没什么黑魔法,.gitlab-ci.yml加起来不到200行,但每一条都是踩过坑之后加的。

给想落地的团队三个建议:一是别一上来就全套Git Flow,先从"main + develop + feature"三条线跑顺;二是CI的quality gate一定要设成阻塞式,允许失败等于没设;三是MR模板和review checklist比任何工具都重要,把"该检查什么"写清楚,比指望大家自觉靠谱得多。

配置我脱敏后放在内部wiki了,有需要的同行可以按上面的片段自己拼,基本能直接跑。