一、问题背景:当"直接push master"撞上18人团队

我们是一个做SaaS后台的团队,2022年只有5个人时,Git用法极其朴素:所有人clone master,改完git push origin master。那时候一周也就十几次提交,冲突靠git pull --rebase顺手解决,没人觉得有问题。

2023年团队扩到18人,同时维护3条产品线(web端、移动端API、数据同步服务),问题集中爆发:

  • 每周平均3.2次合并冲突,其中至少1次是"改同一个配置文件的不同段落",解决冲突要拉两个人开会;
  • 线上回滚频率升到每周2次,事后复盘发现60%是"某人本地没跑通就push了";
  • 代码评审形同虚设,大家习惯性在群里发一句"帮我看看",然后直接merge;
  • CI形同摆设——我们有个Jenkins,但只在master上跑,跑挂了也没人管,因为"反正已经合进去了"。

真正的转折点是一次事故:某同事把一个调试用的console.log(JSON.stringify(req.body))推到了master,包含用户手机号,被安全扫描抓到,罚了内部通报。老板拍板:必须把工作流规范化。

二、环境与版本

先把家底交代清楚,避免读者对号入座时踩版本坑:

  • Git:2.43.0(服务端GitLab 16.9.1自带)
  • GitLab CE:16.9.1,Runner为docker executor,镜像docker:24.0.7
  • Node.js:20.11.1(前端与BFF),Go 1.22.1(数据同步服务)
  • 代码评审:GitLab Merge Request + 自建规则(后面细说),未引入Gerrit
  • CI:GitLab CI/CD,.gitlab-ci.yml,缓存用S3兼容的MinIO

选GitLab而非GitHub Actions,纯粹因为公司内网自建、合规要求代码不出内网,且GitLab的MR+Protected Branch+CI三者集成最顺。

三、方案设计:双轨分支策略

我们没有教条地照搬Git Flow,也没有全盘Trunk-Based,而是按"发布节奏"分了两条轨:

轨道A:主产品线(web + BFF),采用改良Git Flow

  • master:永远可发布,受保护,只接受来自release/*和hotfix/*的MR;
  • develop:集成分支,受保护,只接受feature/*的MR,且必须CI绿+1个Approve;
  • feature/-:生命周期不超过5个工作日,超过就拆;
  • release/:从develop切出,只修bug不加功能,验收后合master并打tag;
  • hotfix/:从master切出,修完同时合master和develop。

轨道B:数据同步服务,采用Trunk-Based

这个服务迭代快、部署独立,我们让它直接在main上开发,但要求:每个提交必须走MR(哪怕只有一行),feature通过Feature Flag控制,分支存活不超过2天。原因很简单——它没有"版本发布"概念,容器镜像每次CI都构建,回滚靠镜像tag。

关键约束写进了CONTRIBUTING.md,并用GitLab的Push Rules强制:禁止直接push到master/develop/main,禁止force push,提交信息必须匹配^(feat|fix|docs|refactor|test|chore)(\(.+\))?: .+。

四、核心实现:分支保护、MR模板与CI配置

4.1 分支保护与Push Rules(GitLab UI/API配置)

用API一次性配好,避免手点漏项。核心参数:

# 保护 develop 分支:仅Maintainer可merge,开发者可push新分支但不能push到develop
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  "https://gitlab.internal/api/v4/projects/123/protected_branches" \
  --data "name=develop&push_access_level=0&merge_access_level=40&allow_force_push=false"

# master 更严格:push_access_level=0 表示没人能直接push
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  "https://gitlab.internal/api/v4/projects/123/protected_branches" \
  --data "name=master&push_access_level=0&merge_access_level=40&allow_force_push=false"

# Push Rules:提交信息正则 + 禁止未签名提交(可选)
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  "https://gitlab.internal/api/v4/projects/123/push_rules" \
  --data "commit_message_regex=^(feat|fix|docs|refactor|test|chore)(\\(.+\\))?: .+&deny_delete_tag=true&reject_unsigned_commits=false"

push_access_level=0是重点:它意味着连Maintainer都不能直接push master,必须走MR。这一条把"顺手push"的路彻底堵死。

4.2 MR模板:把Code Review流程写进模板

在仓库根目录放.gitlab/merge_request_templates/default.md,让每个MR自动带上检查清单:

## 变更说明


## 变更类型
- [ ] feat 新功能
- [ ] fix 缺陷修复
- [ ] refactor 重构(无行为变更)
- [ ] chore 构建/依赖

## 自测清单(提交者必填)
- [ ] 本地 `npm run lint && npm run test` 通过
- [ ] 涉及接口变更已在 MR 描述中列出 request/response 示例
- [ ] 涉及数据库变更已附 migration 文件并说明回滚方式
- [ ] 新增/修改的逻辑有对应单测,覆盖率不低于 70%

## 评审者关注点
- [ ] 是否有硬编码密钥/调试日志
- [ ] 异常路径是否有兜底
- [ ] 是否影响已有接口的向后兼容

评审规则我们定得很具体,避免"LGTM"式糊弄:

  1. 每个MR至少1个Approve,涉及src/core/或数据库migration的必须2个;
  2. 评审必须在4个工作小时内给出首次反馈(我们在GitLab里配了Slack提醒机器人,超时@评审人);
  3. 提交者不能自己Approve自己的MR(GitLab默认行为,但要在项目设置里确认Prevent approval by author开启);
  4. Approve后如果又有新commit,approval自动失效——这个靠GitLab 16.x的Reset approvals on push选项,在Settings → Merge requests里勾选。

4.3 CI/CD流水线:.gitlab-ci.yml真实配置

这是全文最硬的部分,直接贴我们线上跑的配置(敏感信息已替换为变量):

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

variables:
  NODE_VERSION: "20.11.1"
  DOCKER_DRIVER: overlay2
  GIT_DEPTH: "20"          # 浅克隆,加快CI,20够算变更集

default:
  image: node:20.11.1
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
    policy: pull-push
  before_script:
    - npm ci --prefer-offline --no-audit

# 1. 提交信息与MR规范校验(只对MR触发)
commit-lint:
  stage: validate
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - npx --yes @commitlint/cli@18.6.0 --from $CI_MERGE_REQUEST_DIFF_BASE_SHA --to HEAD

# 2. 静态检查 + 单测 + 覆盖率门禁
test:
  stage: test
  script:
    - npm run lint
    - npm run test -- --coverage --ci
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  artifacts:
    when: always
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "develop"
    - if: $CI_COMMIT_BRANCH == "master"

# 3. 构建镜像(仅受保护分支/打tag时)
build-image:
  stage: build
  image: docker:24.0.7
  services:
    - docker:24.0.7-dind
  variables:
    IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
    - docker build -t $IMAGE_TAG .
    - docker push $IMAGE_TAG
  rules:
    - if: $CI_COMMIT_BRANCH == "master"
    - if: $CI_COMMIT_TAG

# 4. 部署:develop自动部署到staging,master打tag后手动部署生产
deploy-staging:
  stage: deploy
  image: bitnami/kubectl:1.29.2
  environment:
    name: staging
    url: https://staging.example.internal
  script:
    - kubectl set image deployment/web web=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n staging
    - kubectl rollout status deployment/web -n staging --timeout=120s
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

deploy-prod:
  stage: deploy
  image: bitnami/kubectl:1.29.2
  environment:
    name: production
    url: https://app.example.com
  script:
    - kubectl set image deployment/web web=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n prod
    - kubectl rollout status deployment/web -n prod --timeout=180s
  when: manual
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/

几点设计说明:

  • GIT_DEPTH: "20"把克隆深度压到20,配合commit-lint只校验MR区间,避免浅克隆导致--from找不到base SHA的坑(这个坑我们踩过,后面讲);
  • 覆盖率门禁没用GitLab的coverage关键字自动卡,而是在test脚本里用nyc check-coverage --lines 70硬卡,失败就exit 1;
  • deploy-prod用when: manual,必须在GitLab UI点按钮,且只有Maintainer角色能点(靠environment的protected设置)。

4.4 合并队列与Squash

GitLab 16.x的Merge Queue(合并队列)我们开了,参数:merge_method=squash,squash_option=always。原因:feature分支动辄十几个"wip"提交,合进develop后历史全是噪音。强制squash后,develop上每个提交对应一个MR,git log --oneline可读性大幅提升。

代价是丢失分支内提交历史——我们接受,因为MR本身保留了完整diff和讨论。

五、踩坑与优化

坑1:浅克隆 + commitlint 找不到 base SHA

最初GIT_DEPTH设成1,commit-lint报fatal: bad object $CI_MERGE_REQUEST_DIFF_BASE_SHA。原因是base commit不在浅克隆范围内。改成20并加git fetch --deepen=20兜底后解决。教训:任何依赖MR diff的job,GIT_DEPTH不能设太小。

坑2:Reset approvals on push 导致评审反复

开启后,评审者Approve了,提交者改了个typo再push,approval清零,评审者要重新点。团队抱怨"改个注释也要重新审"。优化:约定"Approve后只允许在讨论区追加说明,不再push",需要改代码就重新走一轮;同时在MR模板里加一句"请确认代码已定稿再请求评审"。两周后大家养成习惯,反而减少了无效评审。

坑3:hotfix 分支的合并方向搞反

有次hotfix直接合了master忘了合develop,导致下个release把bug又带回来。解决:写了个GitLab CI job,检测到hotfix/*合入master后,自动创建"backport to develop"的MR(用GitLab API + CI_JOB_TOKEN),并在Slack通知。虽然土,但管用。

坑4:CI 缓存污染

node_modules缓存key只用了package-lock.json的hash,但我们的postinstall会编译native模块,不同Runner架构(amd64 vs arm64)缓存串了,导致Cannot find module。改成key: "$CI_RUNNER_EXECUTABLE_ARCH-$CI_COMMIT_REF_SLUG"才干净。

六、效果数据

跑了3个月(2024年1月—3月),对比改革前3个月:

  • 合并冲突:每周3.2次 → 0.8次(feature分支短生命周期 + squash减少交叉改动);
  • 主干构建失败率:18% → 2.1%(MR必须CI绿才能合,且develop上每次合并都跑全量test);
  • MR平均首次评审响应:26小时 → 4.5小时(Slack超时提醒 + 4小时SLA);
  • 线上回滚:每周2次 → 每月1次;
  • 平均feature分支存活:从"没统计(很多挂一个月)"降到3.7天。

代价也有:CI分钟数涨了约40%(因为每个MR都跑test),但换来的是回滚减少,运维同事说"值"。

七、总结

回头看,真正起作用的不是某个工具,而是三条硬约束:master不可直接push、MR必须CI绿+人工Approve、feature分支活不过5天。Git Flow还是Trunk-Based只是形式,关键是让"绕过流程"的成本高于"走流程"的成本。配置层面,GitLab的push_access_level=0、Reset approvals on push、merge_method=squash这三个开关,是我们这套流程能落地的技术底座。

如果让我给同样规模的团队建议:先别急着上复杂工具链,把分支保护和MR模板配好,跑两周,再看CI要不要加门禁。流程是长出来的,不是一次配出来的。