Skip to content

持续集成与持续部署

持续集成(Continuous Integration, CI)和持续部署(Continuous Deployment/Delivery, CD)是现代软件开发的核心实践。CI 确保每次代码提交都经过自动化的构建、测试和检查;CD 确保通过检查的代码能够自动部署到目标环境。本节将介绍 GitHub Actions、GitLab CI、Jenkins 流水线配置以及自动测试和自动部署策略。

前置知识

阅读本节前,建议先了解:部署流程代码风格工具

基础概念

CI/CD 流水线阶段

text
代码提交 → Lint → 单元测试 → 集成测试 → 代码质量检查 → 构建 → 部署 staging → E2E 测试 → 部署生产
   │        │      │         │            │            │         │            │            │
   CI 阶段 ──────────────────────────────────────────────────────────────────────────────────→
                                                                          CD 阶段 ─────────────→

GitHub Actions

完整的 PHP CI 配置

yaml
# .github/workflows/ci.yml
name: PHP CI

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

jobs:
  # ==================== 代码质量检查 ====================
  quality:
    name: Code Quality
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php-version: ['8.1', '8.2', '8.3']
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP ${{ matrix.php-version }}
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          coverage: xdebug
          tools: composer:v2

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-php-${{ matrix.php-version }}-

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress --no-interaction

      - name: PHP CodeSniffer
        run: vendor/bin/phpcs --standard=phpcs.xml --report=full src/ tests/

      - name: PHP-CS-Fixer
        run: vendor/bin/php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php

      - name: PHPStan
        run: vendor/bin/phpstan analyse --no-progress --error-format=github

  # ==================== 单元测试 ====================
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: quality
    strategy:
      matrix:
        php-version: ['8.1', '8.2', '8.3']
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: test
          MYSQL_DATABASE: test_db
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd="redis-cli ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          coverage: xdebug
          tools: composer:v2, phpunit

      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress --no-interaction

      - name: Run PHPUnit
        env:
          DB_HOST: 127.0.0.1
          DB_DATABASE: test_db
          DB_USERNAME: root
          DB_PASSWORD: test
          REDIS_HOST: 127.0.0.1
        run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml

      - name: Upload coverage
        if: matrix.php-version == '8.2'
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage.xml

  # ==================== 部署到 Staging ====================
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/develop'
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Staging
        uses: easingthemes/ssh-deploy@v4
        with:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          REMOTE_HOST: ${{ secrets.STAGING_HOST }}
          REMOTE_USER: ${{ secrets.STAGING_USER }}
          ARGS: "-avz --delete --exclude='.git' --exclude='node_modules'"
          SOURCE: "./"
          TARGET: "/var/www/staging/"

      - name: Post-deploy commands
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/staging
            composer install --no-dev --optimize-autoloader --no-interaction
            php artisan migrate --force
            php artisan config:cache && php artisan route:cache && php artisan view:cache
            sudo systemctl reload php-fpm

  # ==================== 部署到 Production ====================
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Production
        uses: easingthemes/ssh-deploy@v4
        with:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          REMOTE_HOST: ${{ secrets.PRODUCTION_HOST }}
          REMOTE_USER: ${{ secrets.PRODUCTION_USER }}
          SOURCE: "./"
          TARGET: "/var/www/app/releases/$(date +%Y%m%d%H%M%S)/"

      - name: Post-deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/app
            RELEASE_DIR=$(ls -1dt releases/ | head -1)
            ln -sfn "${RELEASE_DIR}" current
            cd current
            composer install --no-dev --optimize-autoloader --classmap-authoritative
            php artisan migrate --force
            php artisan config:cache && php artisan route:cache && php artisan view:cache
            sudo systemctl reload php-fpm
            # 清理旧版本
            ls -1dt releases/*/ | tail -n +6 | xargs rm -rf
            # 健康检查
            for i in {1..10}; do
              if curl -sf http://localhost/health > /dev/null; then
                echo "Health check passed!"
                exit 0
              fi
              sleep 3
            done
            echo "Health check failed!"
            exit 1

GitLab CI

yaml
# .gitlab-ci.yml
stages:
  - quality
  - test
  - deploy-staging
  - deploy-production

variables:
  COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
  DOCKER_DRIVER: overlay2

.cache: &cache
  cache:
    paths:
      - vendor/
      - .composer-cache/

# 代码质量检查
code-quality:
  stage: quality
  image: composer:latest
  <<: *cache
  script:
    - composer install --prefer-dist --no-progress --no-interaction
    - vendor/bin/phpcs --standard=phpcs.xml --report=full src/ tests/
    - vendor/bin/php-cs-fixer fix --dry-run --diff
    - vendor/bin/phpstan analyse --no-progress
  artifacts:
    reports:
      codequality: phpstan-report.json
    when: on_failure

# 单元测试
phpunit:
  stage: test
  image: composer:latest
  services:
    - name: mysql:8.0
      alias: mysql
    - name: redis:7-alpine
      alias: redis
  <<: *cache
  variables:
    DB_HOST: mysql
    DB_DATABASE: test_db
    DB_USERNAME: root
    DB_PASSWORD: ""
    REDIS_HOST: redis
  before_script:
    - composer install --prefer-dist --no-progress --no-interaction
  script:
    - vendor/bin/phpunit --coverage-text
  coverage: /^\s*Lines:\s*\d+.\d+\%/

# 部署到 Staging
deploy-staging:
  stage: deploy-staging
  image: alpine:latest
  only:
    - develop
  before_script:
    - apk add --no-cache rsync openssh-client
  script:
    - ssh-keyscan $STAGING_HOST >> ~/.ssh/known_hosts
    - rsync -avz --delete --exclude='.git' --exclude='node_modules' ./ deploy@$STAGING_HOST:/var/www/staging/
    - ssh deploy@$STAGING_HOST "cd /var/www/staging && composer install --no-dev --optimize-autoloader && php artisan migrate --force && php artisan config:cache && sudo systemctl reload php-fpm"

# 部署到 Production
deploy-production:
  stage: deploy-production
  image: alpine:latest
  only:
    - main
  when: manual
  before_script:
    - apk add --no-cache rsync openssh-client
  script:
    - ssh-keyscan $PRODUCTION_HOST >> ~/.ssh/known_hosts
    - rsync -avz --delete --exclude='.git' --exclude='node_modules' ./ deploy@$PRODUCTION_HOST:/var/www/app/
    - ssh deploy@$PRODUCTION_HOST "cd /var/www/app && composer install --no-dev --optimize-autoloader && php artisan migrate --force && php artisan config:cache && sudo systemctl reload php-fpm"

Jenkins 流水线

groovy
// Jenkinsfile
pipeline {
    agent any

    environment {
        PHP_VERSION = '8.2'
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Install Dependencies') {
            steps {
                sh 'composer install --prefer-dist --no-progress --no-interaction'
            }
        }

        stage('Code Quality') {
            parallel {
                stage('PHP CodeSniffer') {
                    steps {
                        sh 'vendor/bin/phpcs --standard=phpcs.xml src/ tests/'
                    }
                }
                stage('PHP-CS-Fixer') {
                    steps {
                        sh 'vendor/bin/php-cs-fixer fix --dry-run --diff'
                    }
                }
                stage('PHPStan') {
                    steps {
                        sh 'vendor/bin/phpstan analyse --no-progress'
                    }
                }
            }
        }

        stage('Unit Tests') {
            steps {
                sh 'vendor/bin/phpunit --coverage-text'
            }
        }

        stage('Deploy') {
            when {
                branch 'main'
            }
            steps {
                sh './deploy/deploy.sh production'
            }
        }
    }

    post {
        always {
            junit '**/test-results.xml'
        }
        success {
            echo 'Pipeline succeeded!'
        }
        failure {
            echo 'Pipeline failed!'
        }
    }
}

实战示例

自动化质量门禁

yaml
# .github/workflows/quality-gate.yml
name: Quality Gate

on:
  pull_request:
    branches: [main]

jobs:
  quality-gate:
    name: Quality Gate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          coverage: xdebug
          tools: composer:v2

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      # 1. 代码风格
      - name: Code Style Check
        run: vendor/bin/phpcs --standard=phpcs.xml --report=checkstyle --report-file=checkstyle.xml src/ tests/

      # 2. 静态分析
      - name: Static Analysis
        run: vendor/bin/phpstan analyse --no-progress --error-format=github

      # 3. 单元测试 + 覆盖率
      - name: Unit Tests with Coverage
        run: vendor/bin/phpunit --coverage-clover=coverage.xml --coverage-text

      # 4. 覆盖率检查
      - name: Coverage Check
        run: |
          COVERAGE=$(vendor/bin/phpunit --coverage-text 2>&1 | grep 'Lines:' | grep -oP '\d+(?=\.\d+%)')
          if [ "$COVERAGE" -lt 80 ]; then
            echo "Coverage is ${COVERAGE}%, minimum is 80%"
            exit 1
          fi
          echo "Coverage is ${COVERAGE}%, passed!"

最佳实践

  1. 快速失败:代码质量检查应在最早阶段运行
  2. 并行执行:独立任务(测试、静态分析)并行运行
  3. 缓存依赖:缓存 Composer 和 Docker 镜像加速构建
  4. 自动部署:CD 流水线实现一键发布
  5. 环境隔离:每个 Job 使用独立环境,避免状态干扰
  6. 通知集成:构建失败时通知团队(Slack、邮件等)

下一节

继续学习:PHP 8.0 新特性

参考链接