一、问题背景:为什么"直接推master"撑不住了

我们团队做的是一个SaaS后台产品,后端Spring Boot 3.2 + JDK 17,前端Vue 3.4 + Vite 5,仓库托管在GitHub,CI最初只有一条"推上去就跑测试"的脚本。2023年下半年团队从6人扩到15人后,问题集中爆发:

  1. 冲突常态化。三个人同时改同一个Service,本地各自rebase,最后合并时冲突文件平均每次4.7个。
  2. master长期不可发布。有人把半成品直接push到master,导致主干构建成功率只有82%,发布前临时"挑commit"成了常态。
  3. Code Review形同虚设。没有分支保护,PR可以自己approve自己merge,代码评审覆盖率不到30%。
  4. 回滚靠人肉。出问题后手动revert一串commit,平均耗时40分钟。

我们的目标很明确:主干随时可发布、每次变更都可追溯、评审必须发生、回滚必须一键。下面是我们最终落地的方案。

二、环境与版本

  • Git 2.43.0(服务端GitHub Enterprise,客户端统一要求 ≥ 2.40)
  • GitHub Enterprise Cloud,启用Rulesets(替代旧版Branch protection)
  • GitHub Actions(runner: ubuntu-22.04,自托管runner 4核8G)
  • 备用流水线:GitLab CI 17.4(部分内部项目迁移中)
  • 构建工具:Maven 3.9.6、Node 20.11.1、pnpm 9.1.0
  • 代码质量:SonarQube 10.4、ESLint 8.57、Checkstyle 10.12

三、方案设计:分支策略怎么选

我们评估了三种主流策略:

策略 优点 缺点 是否适合我们
Git Flow(develop/release/hotfix) 发布节奏清晰 分支多、合并链路长,15人团队维护成本高 否
Trunk-Based Development 主干唯一、集成频繁 要求特性开关和强自动化测试 部分适合
短生命周期特性分支 + 保护主干 折中,PR即评审单元 需要严格控制分支存活时间 是

最终定稿的规则:

  1. main 为唯一长期分支,永远可发布,禁止直接push。
  2. 特性分支 feat/-,从main切出,存活时间不超过3个工作日(超过则拆分)。
  3. 修复分支 fix/-,hotfix同样走PR,但允许priority/hotfix标签跳过部分非关键检查(见第五节)。
  4. 发布用tag:v1.8.0,语义化版本,tag由CI在main上自动打。
  5. 禁止develop分支。这是我们踩坑后砍掉的(见第六节)。

分支命名用正则强校验,在CI第一步执行:

# scripts/check-branch-name.sh
#!/usr/bin/env bash
set -euo pipefail

BRANCH="${GITHUB_HEAD_REF:-$(git rev-parse --abbrev-ref HEAD)}"
# 允许 main 与 release tag 触发
if [[ "$BRANCH" == "main" ]]; then
  echo "branch ok: $BRANCH"; exit 0
fi

PATTERN='^(feat|fix|chore|refactor)/[A-Z]+-[0-9]+-[a-z0-9-]{3,40}$'
if [[ ! "$BRANCH" =~ $PATTERN ]]; then
  echo "::error::分支名不合规: $BRANCH"
  echo "期望格式: feat/PROJ-123-add-order-export"
  exit 1
fi
echo "branch ok: $BRANCH"

配套的main分支保护(GitHub Rulesets JSON,通过API导入):

{
  "name": "protect-main",
  "target": "branch",
  "enforcement": "active",
  "conditions": { "ref_name": { "include": ["refs/heads/main"], "exclude": [] } },
  "rules": [
    { "type": "deletion" },
    { "type": "non_fast_forward" },
    { "type": "required_linear_history" },
    { "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 1,
        "dismiss_stale_reviews_on_push": true,
        "require_code_owner_review": true,
        "require_last_push_approval": true
      }
    },
    { "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": true,
        "required_status_checks": [
          { "context": "build-and-test" },
          { "context": "sonarqube-quality-gate" },
          { "context": "branch-name-check" }
        ]
      }
    }
  ]
}

关键参数说明:required_approving_review_count: 1(不是2,因为15人团队2个approve会显著拖慢小改动)、require_last_push_approval: true(防止approve后再偷偷push)、strict_required_status_checks_policy: true(分支落后main时必须先更新再合并)。

四、核心实现:PR流程 + CI/CD流水线

4.1 PR模板与评审SLA

.github/pull_request_template.md:

## 变更说明


Closes PROJ-

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

## 自测清单
- [ ] 本地 `mvn -q verify` 通过
- [ ] 新增/修改逻辑有对应单测,覆盖率不低于70%
- [ ] 涉及接口变更已更新 OpenAPI 文档
- [ ] 无敏感信息硬编码(密钥、内网地址)

## 风险与回滚

评审SLA:工作日4小时内首次响应,24小时内给出明确结论。我们用GitHub Action定时提醒超时PR(cron每2小时跑一次,超过8小时未评审的在群里@reviewer)。

4.2 GitHub Actions 主流水线

.github/workflows/ci.yml:

name: ci

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

env:
  MAVEN_OPTS: "-Xmx1g -XX:+UseG1GC"
  NODE_VERSION: "20.11.1"

jobs:
  branch-name-check:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - run: bash scripts/check-branch-name.sh

  build-and-test:
    runs-on: ubuntu-22.04
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # Sonar 需要完整历史做 blame
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
          cache: maven
      - name: Backend verify
        run: mvn -B -q verify -Dspring.profiles.active=test
      - name: Upload surefire reports
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: surefire-reports
          path: target/surefire-reports/
          retention-days: 7

  frontend:
    runs-on: ubuntu-22.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9.1.0
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint && pnpm test -- --run
      - run: pnpm build

  sonarqube-quality-gate:
    needs: [build-and-test]
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
          cache: maven
      - name: Sonar scan
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
        run: |
          mvn -B sonar:sonar \
            -Dsonar.projectKey=saas-backend \
            -Dsonar.qualitygate.wait=true \
            -Dsonar.qualitygate.timeout=300 \
            -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml

注意几个真实调过的参数:timeout-minutes: 25(最初默认360分钟,一次卡住的跑占用runner两小时);concurrency.cancel-in-progress: true(同一PR多次push自动取消旧任务,runner占用下降约35%);sonar.qualitygate.wait=true + timeout=300(让质量门禁真正阻塞合并,而不是只出报告)。

4.3 GitLab CI 等价配置(内部迁移项目)

.gitlab-ci.yml:

stages: [validate, test, quality, build]

variables:
  MAVEN_OPTS: "-Xmx1g"
  GIT_DEPTH: "50"

workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'

validate-branch:
  stage: validate
  image: alpine/git:2.43.0
  script:
    - apk add --no-cache bash
    - bash scripts/check-branch-name.sh
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

unit-test:
  stage: test
  image: maven:3.9.6-eclipse-temurin-17
  cache:
    key: "m2-$CI_COMMIT_REF_SLUG"
    paths: [.m2/repository]
  script:
    - mvn -B -q verify -Dspring.profiles.active=test
  artifacts:
    when: on_failure
    paths: [target/surefire-reports/]
    expire_in: 1 week

quality-gate:
  stage: quality
  image: maven:3.9.6-eclipse-temurin-17
  needs: [unit-test]
  script:
    - mvn -B sonar:sonar -Dsonar.qualitygate.wait=true
  allow_failure: false

合并请求的"Merge when pipeline succeeds"配合Settings → Merge requests → "Pipelines must succeed" + "All threads must be resolved",效果等同GitHub的branch protection。

五、踩坑与优化

坑1:develop分支成了"第二主干"。 初期照搬Git Flow,结果develop上的代码和main分叉越来越远,两次合并花了整整一天。结论:15人以内、按周发布的团队不要开develop。砍掉后每周集成成本从约6人时降到1人时以内。

坑2:require_last_push_approval 上线第一周引发投诉。 开发者approve后改个typo,review又失效,觉得"形式主义"。我们的处理是:保留该规则,但在PR模板里加"reviewer approve后仅允许注释/文档类改动免重新评审"的人工约定——规则管代码,约定管噪音。

坑3:hotfix绕检查导致质量门禁被架空。 一开始给priority/hotfix开了allow_failure: true,结果两周内出现4个带标签的"紧急"PR。改进方案是:hotfix不允许跳过单测与质量门禁,但允许并行运行且interruptible: false保证不被取消,同时把hotfix的SLA写进值班流程。之后"紧急"PR回落到每月1~2个。

优化点:自托管runner + Maven缓存。 把~/.m2/repository用actions/cache按pom.xml哈希缓存,后端构建从6分12秒降到2分40秒;前端pnpm store缓存后install从75秒降到18秒。

六、效果数据与总结

落地三个月后的对比(数据来自GitHub Insights + Sonar + 内部周报):

指标 改造前 改造后
main构建成功率 82% 99.1%
PR平均首次评审时长 26h 4.5h
代码评审覆盖率 <30% 100%(规则强制)
每周合并冲突事故 2.3次 0.4次
单次CI时长(后端) 6m12s 2m40s
发布回滚耗时 ~40min <3min(revert PR + 自动流水线)

几点总结给同样规模的团队:分支策略不要迷信教科书,分支数量与团队规模成反比;保护规则要"少而硬",只保留真正阻塞合并的检查,其余降级为提醒;CI配置里最值钱的三个参数是concurrency、timeout-minutes和qualitygate.wait;最后,工作流是给人用的,规则上线前先在团队里跑一周"影子模式"(只告警不阻塞),阻力会小很多。