一、问题背景:5人团队和18人团队是两种生物
先说结论:小团队的Git习惯,在人数翻倍后一定会反噬。
我们团队最初5个人,工作流非常“野生”:所有人直接在master上开发,git pull --rebase一下就开始写,写完push。5个人的时候冲突概率低,互相看一眼代码就合了,也没人管CI。
到18人(前端6、后端9、测试2、运维1)之后,问题集中爆发:
- 平均每周3~4次合并冲突,最长一次解冲突花了4小时;
- 有人把没测完的代码直接推到
master,导致预发环境挂了2次; - 发布前需要“冻结”两天专门解冲突和联调;
- 没有Code Review,线上事故复盘时发现一半是低级错误(空指针、漏判null、配置写错环境)。
于是我们决定正经梳理一套工作流。目标很明确:分支隔离风险、Review拦住低级错误、CI保证每次合并都可构建可测试。
二、环境与版本
先把家底交代清楚,避免配置对不上:
| 组件 | 版本/配置 |
|---|---|
| Git | 2.39.2 |
| GitLab | 16.5 (CE,自建) |
| GitLab Runner | 16.5,Docker executor |
| Node.js | 18.18.2(前端项目) |
| JDK | 17.0.9(后端项目) |
| Docker | 24.0.7 |
| pre-commit | 3.5.0 |
项目结构:前端web-app、后端api-service两个仓库,共用一套GitLab CI模板。
三、方案设计:分支策略选“简化版Git Flow”
我们对比过三种方案:
- GitHub Flow(只有master+feature):简单,但我们的发布有明确版本节奏(两周一个release),需要有个稳定的发布分支兜底。
- 标准Git Flow(master/develop/feature/release/hotfix五类分支):太啰嗦,
develop和master长期并存,团队嫌merge来merge去烦。 - 简化版Git Flow(最终选择):保留
main(生产)、release/*(发布候选)、feature/*、hotfix/*,砍掉长期存在的develop。
最终分支模型:
main ─────●────────────●───────────●─────→ (生产,只接受MR合并)
\ / /
release/1.4.0 ─●──●──● / (发布候选,只修bug)
/ /
feature/user-login ─●──●──● / (功能开发)
/
hotfix/1.4.1 ─────●──────────● (紧急修复,同时合回main和release)
规则:
main、release/*受保护:禁止直接push,必须MR + 至少1个Approve + CI通过;feature/*从main切出,命名feature/需求号-简述,如feature/PAY-231-alipay-refund;- 合并方式统一用 Squash Merge,保持
main历史线性、一个feature一个commit; release/*只允许cherry-pick bugfix,不允许新功能,发布后打tagv1.4.0。
GitLab上的分支保护配置(Settings → Repository → Protected branches):
Branch: main
Allowed to merge: Maintainers
Allowed to push: No one
Allowed to force push: No
Code owner approval required: Yes
Branch: release/*
Allowed to merge: Maintainers
Allowed to push: No one
四、核心实现:MR模板、CI配置、pre-commit钩子
4.1 Code Review流程与MR模板
Review流程定死三条:
- MR标题格式:
[PAY-231] 支持支付宝退款,关联Jira需求号; - 作者自查清单必须勾选(本地跑过测试、无调试代码、无敏感信息);
- 至少1个同模块Reviewer Approve,涉及支付/权限的改动要求2个。
.gitlab/merge_request_templates/Default.md:
## 变更说明
## 关联需求
Jira: PAY-231
## 自查清单
- [ ] 本地 `npm run test` / `mvn test` 通过
- [ ] 无 `console.log` / `System.out.println` 调试代码
- [ ] 无硬编码密钥、IP、测试账号
- [ ] 涉及DB变更已附SQL及回滚方案
- [ ] 新增接口已更新API文档
## 影响范围
## 截图/日志(可选)
Review本身我们要求24小时内响应,超过就由Tech Lead介入。实际执行下来,MR平均Review时长从最初的11小时降到3.5小时。
4.2 GitLab CI流水线配置
这是重头戏。.gitlab-ci.yml(前端web-app仓库,后端结构类似,只是构建命令不同):
# .gitlab-ci.yml GitLab 16.5
stages:
- install
- lint
- test
- build
- deploy
variables:
NODE_IMAGE: node:18.18.2-alpine
DOCKER_DRIVER: overlay2
npm_config_cache: "$CI_PROJECT_DIR/.npm"
# 缓存 node_modules,跨 job 复用
cache:
key:
files:
- package-lock.json
paths:
- .npm/
- node_modules/
install:
stage: install
image: $NODE_IMAGE
script:
- npm ci --prefer-offline --no-audit
artifacts:
paths:
- node_modules/
expire_in: 2 hours
lint:
stage: lint
image: $NODE_IMAGE
needs: ["install"]
script:
- npx eslint src --max-warnings=0
- npx prettier --check "src/**/*.{ts,tsx,css}"
allow_failure: false
test:
stage: test
image: $NODE_IMAGE
needs: ["install"]
script:
- npm run test:unit -- --coverage --ci
coverage: '/Statements\s*:\s*(\d+\.\d+)%/'
artifacts:
when: always
reports:
junit: coverage/junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
build:
stage: build
image: docker:24.0.7
services:
- docker:24.0.7-dind
needs: ["lint", "test"]
rules:
- if: '$CI_COMMIT_BRANCH =~ /^release\// || $CI_COMMIT_BRANCH == "main"'
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# 仅 release/* 分支自动部署到预发环境
deploy:staging:
stage: deploy
image: bitnami/kubectl:1.28
needs: ["build"]
rules:
- if: '$CI_COMMIT_BRANCH =~ /^release\//'
environment:
name: staging
script:
- kubectl set image deployment/web-app web-app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n staging
- kubectl rollout status deployment/web-app -n staging --timeout=180s
# main 分支打 tag 后才允许上生产,手动触发
deploy:production:
stage: deploy
image: bitnami/kubectl:1.28
needs: ["build"]
rules:
- if: '$CI_COMMIT_BRANCH == "main" && $CI_COMMIT_TAG'
when: manual
environment:
name: production
script:
- kubectl set image deployment/web-app web-app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n prod
几个关键设计点:
needs做DAG:lint和test并行跑,不必等彼此,流水线整体从原来的14分钟压到6分半;rules控制触发:feature分支只跑 lint+test,release分支才构建镜像并部署预发,避免浪费Runner资源;- 覆盖率门槛:后面用GitLab的Coverage Check(Settings → CI/CD → Coverage check)设了语句覆盖率低于70%直接MR不可合并。
4.3 pre-commit钩子:把低级错误拦在本地
CI再快也不如本地拦。我们用pre-commit 3.5.0,.pre-commit-config.yaml:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-added-large-files
args: ["--maxkb=1024"]
- id: detect-private-key
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.54.0
hooks:
- id: eslint
files: \.[jt]sx?$
args: ["--fix", "--max-warnings=0"]
安装:pre-commit install,之后每次git commit自动跑。实测它拦下了团队约60%的“漏删调试代码”和“提交密钥”问题,成本是每次commit多1~2秒。
五、踩坑与优化
坑1:Squash Merge之后feature分支没删,仓库里堆了200多个僵尸分支。
解决:GitLab设置里勾选“Delete source branch when merge request is accepted”,同时写了个每月跑的清理脚本,删掉90天无提交的feature/*。
坑2:CI缓存node_modules导致偶发依赖不一致。
一开始cache key用分支名,不同分支锁文件不同却共享缓存,出现过npm ci跳过后运行报错。改成key.files: package-lock.json后,锁文件一变缓存自动失效,问题消失。
坑3:release分支上直接改代码,和main的修复产生分叉。
规定所有release上的修复必须走MR,并且合并后由CI自动往main回合并(GitLab有“Merge train”机制,我们用了简单的cherry-pick job)。这条规则执行后,发布后再回补的冲突从每周2次降到几乎为零。
坑4:docker:24.0.7-dind在共享Runner上偶发超时。
把Runner的concurrent从4调到8,并给dind加了--mtu=1450,超时率从5%降到0.3%。
六、效果数据
跑了3个月,对比前后:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 发布周期 | 14天 | 2天 |
| 每周合并冲突次数 | 3.5 | 0.7 |
| 平均MR Review时长 | 11h | 3.5h |
| CI流水线时长 | 14min | 6.5min |
| 因低级错误导致的回滚 | 2次/月 | 0.3次/月 |
| 语句覆盖率 | 无统计 | 74% |
七、总结
回头看,这套工作流真正起作用的不是某个工具,而是三个约束:分支保护让“直接推main”这条路物理上不存在;MR模板+Approve让Review成为流程的一部分而不是靠自觉;CI的rules+覆盖率门槛让“合并即质量”有了硬标准。
如果要给正在踩同样坑的团队一句建议:先上分支保护和CI,再谈分支模型多优雅。 工具链的强制力,远比文档里的规范有效。配置都在上面了,拿去改改就能用。