一、问题背景:为什么“会用Git”不等于“用好Git”
我们团队大概15个人,后端8个、前端5个、测试2个。2023年初从SVN迁到Git,当时大家的认知就是“Git比SVN高级”,但具体怎么协作,没人说得清。结果前三个月踩了一堆坑:
- 有人直接在
master上提交,导致预发环境代码和线上不一致; - 发布分支合回
develop时漏合,下一次迭代把已经修好的bug又带回去了; - 热修复分支(hotfix)改完只合了
master,忘了合develop,两周后问题复现; - Code Review靠口头说“你帮我看下”,没有记录,出问题无法追溯;
- CI只是跑个单元测试,构建和部署还得手动登录服务器执行脚本。
最严重的一次,线上支付回调出问题,我们花了47分钟才定位到是某个feature分支的代码被误合进release。那次之后,我决定把工作流固化下来。这篇文章就是这半年实践的总结,不是理论科普,是我们真实在用的配置。
二、环境与版本
先交代清楚版本,避免你照着配发现命令对不上:
- Git:2.43.0(服务端和客户端统一)
- GitLab:16.8.1-ee(自托管,Docker部署)
- GitLab Runner:16.8.1,Docker executor
- Jenkins:2.440.1(部分老项目还在用,后面会提)
- JDK:17.0.10(CI构建用)
- Node:20.11.1(前端构建用)
分支模型参考的是Git Flow的简化版,但砍掉了Git Flow里比较重的部分,比如release分支不单独拉太久,feature分支强制短生命周期。
三、方案设计:五类分支 + 保护规则 + 触发式流水线
3.1 分支策略
我们最终定下来五类分支:
| 分支类型 | 命名规范 | 生命周期 | 来源 | 合并目标 |
|---|---|---|---|---|
| main | main | 永久 | - | - |
| develop | develop | 永久 | main | - |
| feature | feature/xxx | ≤5天 | develop | develop |
| release | release/v1.2.0 | ≤3天 | develop | main + develop |
| hotfix | hotfix/v1.2.1 | ≤1天 | main | main + develop |
几个关键决策:
- main分支只接受release和hotfix的合并,且必须打tag。main上的每次提交都对应一个线上版本。
- feature分支强制短生命周期,超过5天没合并的,我会在周会上点名。长分支是合并冲突的根源,我们统计过,超过7天的feature分支合并冲突率是短分支的3.2倍。
- release分支只做bug修复,不加新功能。这个规矩救过我们好几次。
3.2 分支保护规则
在GitLab的Settings → Repository → Protected branches里配置:
main:Maintainer才能merge,不允许直接push,允许force push关闭develop:Developer可以merge,不允许直接pushrelease/*:Maintainer才能merge,不允许直接pushhotfix/*:Maintainer才能merge
同时开启Merge request approvals,要求至少1个approval才能合并。这个在Settings → Merge requests里配。
3.3 Code Review流程
我们的MR流程是这样的:
- 开发者在feature分支完成后,push到远程,创建MR到develop;
- MR描述必须填:变更内容、影响范围、测试情况、关联issue;
- CI自动跑lint + unit test,失败直接block;
- 至少1个reviewer approve,且不能是作者自己;
- reviewer重点看:边界条件、日志、异常处理、SQL索引;
- approve后由作者自己点merge(squash merge),保持develop历史干净。
squash merge这个很关键。我们要求feature分支合并时squash成一个commit,commit message格式:feat: 用户登录支持手机号验证码 (#123)。这样develop的历史是一条线,方便回溯。
3.4 CI/CD集成
我们用GitLab CI做主流水线,Jenkins只保留给两个老Java项目。核心思路是:
- push到feature分支:跑lint + unit test;
- 创建MR到develop:跑lint + unit test + 构建;
- 合并到develop:跑完整流水线 + 部署到dev环境;
- 合并到release:部署到staging环境 + 跑集成测试;
- 合并到main并打tag:部署到生产环境(手动触发)。
四、核心实现:可运行的配置文件
4.1 .gitlab-ci.yml
这是我们的主配置文件,放在项目根目录。注意我用的是rules而不是only/except,因为GitLab 16.x里only/except已经不太推荐了。
stages:
- lint
- test
- build
- deploy
variables:
MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"
DOCKER_DRIVER: overlay2
IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .m2/repository/
- node_modules/
lint:
stage: lint
image: node:20.11.1-alpine
script:
- npm ci
- npm run lint
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH =~ /^feature\//'
unit-test:
stage: test
image: maven:3.9.6-eclipse-temurin-17
script:
- mvn -B test -Dmaven.test.failure.ignore=false
artifacts:
when: always
reports:
junit:
- target/surefire-reports/TEST-*.xml
expire_in: 7 days
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "develop"'
- if: '$CI_COMMIT_BRANCH =~ /^release\//'
build-image:
stage: build
image: docker:24.0.7
services:
- docker:24.0.7-dind
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" .
- docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
- if: '$CI_COMMIT_BRANCH =~ /^release\//'
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
deploy-dev:
stage: deploy
image: bitnami/kubectl:1.29
script:
- kubectl set image deployment/app app="$CI_REGISTRY_IMAGE:$IMAGE_TAG" -n dev
- kubectl rollout status deployment/app -n dev --timeout=120s
environment:
name: dev
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
deploy-staging:
stage: deploy
image: bitnami/kubectl:1.29
script:
- kubectl set image deployment/app app="$CI_REGISTRY_IMAGE:$IMAGE_TAG" -n staging
- kubectl rollout status deployment/app -n staging --timeout=180s
environment:
name: staging
rules:
- if: '$CI_COMMIT_BRANCH =~ /^release\//'
deploy-prod:
stage: deploy
image: bitnami/kubectl:1.29
script:
- kubectl set image deployment/app app="$CI_REGISTRY_IMAGE:$IMAGE_TAG" -n prod
- kubectl rollout status deployment/app -n prod --timeout=300s
environment:
name: prod
when: manual
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
几个参数说明:
rollout status的timeout我设了120/180/300秒,分别对应dev/staging/prod。prod给到5分钟是因为有一次镜像拉取慢导致误判失败。expire_in: 7 days是测试报告保留7天,够用了,再长占存储。when: manual只在prod上开,其他环境自动部署。
4.2 Jenkinsfile(老项目用)
两个老Java项目还在Jenkins上,这是我们的声明式Pipeline。注意disableConcurrentBuilds()一定要加,否则同一个分支并发构建会互相覆盖。
pipeline {
agent any
options {
disableConcurrentBuilds()
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
}
environment {
REGISTRY = 'harbor.internal.com'
IMAGE_NAME = 'legacy-order-service'
TAG = "${env.BUILD_NUMBER}-${env.GIT_COMMIT.take(8)}"
}
triggers {
gitlab(triggerOnPush: true, triggerOnMergeRequest: true,
branchFilterType: 'RegexBasedFilter',
targetBranchRegex: '^(develop|release/.*|main)$')
}
stages {
stage('Checkout') {
steps {
checkout scm
sh 'git rev-parse --short HEAD'
}
}
stage('Compile & Test') {
steps {
sh 'mvn -B clean test'
}
post {
always {
junit 'target/surefire-reports/TEST-*.xml'
}
}
}
stage('Build Image') {
when {
anyOf {
branch 'develop'
branch pattern: 'release/.*', comparator: 'REGEXP'
tag pattern: 'v\\d+\\.\\d+\\.\\d+', comparator: 'REGEXP'
}
}
steps {
sh """
docker build -t ${REGISTRY}/${IMAGE_NAME}:${TAG} .
docker push ${REGISTRY}/${IMAGE_NAME}:${TAG}
"""
}
}
stage('Deploy Dev') {
when { branch 'develop' }
steps {
sh "kubectl set image deployment/order-svc order-svc=${REGISTRY}/${IMAGE_NAME}:${TAG} -n dev"
}
}
stage('Deploy Prod') {
when { tag pattern: 'v\\d+\\.\\d+\\.\\d+', comparator: 'REGEXP' }
input {
message "确认部署到生产环境?"
ok "部署"
submitter "admin,release-manager"
}
steps {
sh "kubectl set image deployment/order-svc order-svc=${REGISTRY}/${IMAGE_NAME}:${TAG} -n prod"
}
}
}
post {
failure {
sh 'curl -s -X POST "https://open.feishu.cn/open-apis/bot/v2/hook/${FEISHU_TOKEN}" -H "Content-Type: application/json" -d "{\\"msg_type\\":\\"text\\",\\"content\\":{\\"text\\":\\"构建失败: ${JOB_NAME} #${BUILD_NUMBER}\\n${BUILD_URL}\\"}}"'
}
}
}
submitter限制了只有admin和release-manager能点部署,避免误操作。
五、踩坑与优化:那些配置文档不会告诉你的事
5.1 squash merge后feature分支要删掉
一开始我们合完MR不删feature分支,结果半年后远程分支有200多个,git branch -r翻半天。后来在GitLab的Settings → Merge requests里勾上Delete source branch when merge request is accepted,世界清净了。
5.2 release分支合回develop的时机
这个坑很大。我们早期是release合并到main后,再手动开MR合回develop。结果有两次忘了,导致develop缺少release里的修复。后来改成:release合并到main的MR里,同时在描述里贴一个合回develop的MR链接,reviewer确认两个都合了才approve。
5.3 CI缓存导致的诡异失败
GitLab CI的cache用$CI_COMMIT_REF_SLUG做key,导致每个分支一份缓存,磁盘涨得很快。后来改成:
cache:
key: "shared-cache-v2"
paths:
- .m2/repository/
用共享key,虽然偶尔会有并发写冲突,但GitLab 16.x的cache有重试机制,实际影响不大。磁盘占用从80G降到12G。
5.4 Jenkins的gitlab trigger插件版本问题
Jenkins的GitLab插件我们用4.8.0,配targetBranchRegex时如果写develop|release/.*,/会被当成正则分隔符,必须写成^(develop|release/.*|main)$。这个坑查了半小时。
5.5 tag触发流水线的坑
main分支打tag后,GitLab CI默认会跑一遍pipeline。但如果你的rules里只写了$CI_COMMIT_BRANCH,tag pipeline会全部跳过。必须显式写$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/。
六、效果数据
跑了半年,几个关键指标:
- 发布回滚次数:从每月平均2.3次降到0.4次;
- 平均发布耗时:从42分钟降到11分钟(含CI构建和部署);
- Code Review覆盖率:从约60%提升到100%(因为分支保护强制MR);
- 合并冲突率:从每周3.1次降到0.7次;
- CI流水线平均时长:lint 1分20秒,unit test 4分10秒,build 2分30秒,deploy 40秒,总计约8分40秒。
有一个数据比较意外:feature分支平均生命周期从原来的9天降到3.2天。因为大家都知道超过5天会被点名,主动拆小任务了。
总结
这套工作流不是什么高深的东西,核心就几条:分支职责清晰、合并必须走MR、CI自动跑、生产部署手动确认。难的不是配置,是让团队每个人都遵守。我们靠的是三件事:分支保护强制、CI失败block合并、周会看数据。配置可以照抄,但规矩得自己立。如果你团队现在还在“谁都能push main”,建议先从分支保护开始,这一步就能解决80%的问题。