一、问题背景:为什么“会用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

几个关键决策:

  1. main分支只接受release和hotfix的合并,且必须打tag。main上的每次提交都对应一个线上版本。
  2. feature分支强制短生命周期,超过5天没合并的,我会在周会上点名。长分支是合并冲突的根源,我们统计过,超过7天的feature分支合并冲突率是短分支的3.2倍。
  3. release分支只做bug修复,不加新功能。这个规矩救过我们好几次。

3.2 分支保护规则

在GitLab的Settings → Repository → Protected branches里配置:

  • main:Maintainer才能merge,不允许直接push,允许force push关闭
  • develop:Developer可以merge,不允许直接push
  • release/*:Maintainer才能merge,不允许直接push
  • hotfix/*:Maintainer才能merge

同时开启Merge request approvals,要求至少1个approval才能合并。这个在Settings → Merge requests里配。

3.3 Code Review流程

我们的MR流程是这样的:

  1. 开发者在feature分支完成后,push到远程,创建MR到develop;
  2. MR描述必须填:变更内容、影响范围、测试情况、关联issue;
  3. CI自动跑lint + unit test,失败直接block;
  4. 至少1个reviewer approve,且不能是作者自己;
  5. reviewer重点看:边界条件、日志、异常处理、SQL索引;
  6. 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%的问题。