13_ci_cd_pipeline.yml

Download
yaml 246 lines 9.5 KB
  1# CI/CD Pipeline — GitHub Actions Workflow
  2#
  3# Demonstrates a complete software engineering CI/CD pipeline with:
  4# - Multi-stage workflow: lint → test → build → deploy-staging → deploy-production
  5# - Matrix strategy for testing across multiple Python versions
  6# - Dependency caching to speed up runs
  7# - Artifact upload for build outputs
  8# - Environment protection rules for production gating
  9# - Manual approval step before production deployment
 10#
 11# Usage: Place this file at .github/workflows/ci-cd.yml in your repository.
 12
 13name: CI/CD Pipeline
 14
 15# Trigger conditions
 16on:
 17  push:
 18    branches: [main, develop]       # Run full pipeline on main; run CI only on develop
 19  pull_request:
 20    branches: [main]                # Run lint + test on every PR targeting main
 21  workflow_dispatch:                # Allow manual runs from the GitHub UI
 22    inputs:
 23      deploy_env:
 24        description: "Target environment (staging | production)"
 25        required: false
 26        default: staging
 27
 28# Cancel in-progress runs when a new commit is pushed to the same branch
 29concurrency:
 30  group: ${{ github.workflow }}-${{ github.ref }}
 31  cancel-in-progress: true
 32
 33# ─────────────────────────────────────────────────────────────────
 34# STAGE 1: Lint — fast static analysis before running tests
 35# ─────────────────────────────────────────────────────────────────
 36jobs:
 37  lint:
 38    name: Lint (ruff + mypy)
 39    runs-on: ubuntu-latest
 40    steps:
 41      - uses: actions/checkout@v4
 42
 43      - name: Set up Python 3.12
 44        uses: actions/setup-python@v5
 45        with:
 46          python-version: "3.12"
 47
 48      # Cache pip packages so subsequent runs skip the download step
 49      - name: Cache pip dependencies
 50        uses: actions/cache@v4
 51        with:
 52          path: ~/.cache/pip
 53          key: lint-${{ runner.os }}-${{ hashFiles('requirements-dev.txt') }}
 54          restore-keys: lint-${{ runner.os }}-
 55
 56      - name: Install linting tools
 57        run: pip install ruff mypy
 58
 59      - name: Run ruff (style + import order)
 60        run: ruff check . --output-format=github
 61
 62      - name: Run mypy (type checking)
 63        run: mypy src/ --ignore-missing-imports
 64
 65  # ─────────────────────────────────────────────────────────────────
 66  # STAGE 2: Test — matrix across Python versions
 67  # ─────────────────────────────────────────────────────────────────
 68  test:
 69    name: Test (Python ${{ matrix.python-version }})
 70    needs: lint                    # Only run tests if lint passes
 71    runs-on: ubuntu-latest
 72
 73    # Test against multiple Python versions in parallel
 74    strategy:
 75      fail-fast: false             # Don't cancel siblings on first failure
 76      matrix:
 77        python-version: ["3.11", "3.12", "3.13"]
 78
 79    services:
 80      # Spin up a PostgreSQL container for integration tests
 81      postgres:
 82        image: postgres:16-alpine
 83        env:
 84          POSTGRES_USER: testuser
 85          POSTGRES_PASSWORD: testpass
 86          POSTGRES_DB: testdb
 87        ports: ["5432:5432"]
 88        options: >-
 89          --health-cmd pg_isready
 90          --health-interval 10s
 91          --health-timeout 5s
 92          --health-retries 5
 93
 94    steps:
 95      - uses: actions/checkout@v4
 96
 97      - name: Set up Python ${{ matrix.python-version }}
 98        uses: actions/setup-python@v5
 99        with:
100          python-version: ${{ matrix.python-version }}
101
102      - name: Cache pip dependencies
103        uses: actions/cache@v4
104        with:
105          path: ~/.cache/pip
106          key: test-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('requirements*.txt') }}
107          restore-keys: test-${{ runner.os }}-${{ matrix.python-version }}-
108
109      - name: Install dependencies
110        run: |
111          pip install --upgrade pip
112          pip install -r requirements.txt -r requirements-dev.txt
113
114      - name: Run tests with coverage
115        env:
116          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
117          APP_ENV: test
118        run: |
119          pytest tests/ \
120            --cov=src \
121            --cov-report=xml \
122            --cov-report=term-missing \
123            --junitxml=junit-${{ matrix.python-version }}.xml \
124            -v
125
126      # Upload coverage report as an artifact for later inspection
127      - name: Upload coverage report
128        uses: actions/upload-artifact@v4
129        if: matrix.python-version == '3.12'   # Only upload once
130        with:
131          name: coverage-report
132          path: coverage.xml
133          retention-days: 7
134
135  # ─────────────────────────────────────────────────────────────────
136  # STAGE 3: Build — create Docker image and push to registry
137  # ─────────────────────────────────────────────────────────────────
138  build:
139    name: Build Docker Image
140    needs: test                    # Only build if all tests pass
141    runs-on: ubuntu-latest
142    # Only build on pushes to main (not on PRs)
143    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
144
145    outputs:
146      image_tag: ${{ steps.meta.outputs.tags }}    # Pass image tag to deploy jobs
147
148    steps:
149      - uses: actions/checkout@v4
150
151      - name: Extract metadata for Docker tags
152        id: meta
153        uses: docker/metadata-action@v5
154        with:
155          images: ghcr.io/${{ github.repository }}
156          tags: |
157            type=sha,prefix=sha-
158            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
159
160      - name: Log in to GitHub Container Registry
161        uses: docker/login-action@v3
162        with:
163          registry: ghcr.io
164          username: ${{ github.actor }}
165          password: ${{ secrets.GITHUB_TOKEN }}
166
167      - name: Build and push Docker image
168        uses: docker/build-push-action@v6
169        with:
170          context: .
171          push: true
172          tags: ${{ steps.meta.outputs.tags }}
173          cache-from: type=gha      # Reuse GitHub Actions layer cache
174          cache-to: type=gha,mode=max
175
176  # ─────────────────────────────────────────────────────────────────
177  # STAGE 4: Deploy to Staging
178  # ─────────────────────────────────────────────────────────────────
179  deploy-staging:
180    name: Deploy → Staging
181    needs: build
182    runs-on: ubuntu-latest
183    environment:
184      name: staging
185      url: https://staging.example.com   # Shown as a link on the GitHub UI
186
187    steps:
188      - uses: actions/checkout@v4
189
190      - name: Deploy to staging via SSH
191        uses: appleboy/ssh-action@v1
192        with:
193          host: ${{ secrets.STAGING_HOST }}
194          username: deploy
195          key: ${{ secrets.STAGING_SSH_KEY }}
196          script: |
197            docker pull ${{ needs.build.outputs.image_tag }}
198            docker compose -f /opt/app/docker-compose.staging.yml up -d --no-deps app
199            docker system prune -f
200
201      - name: Run smoke tests against staging
202        run: |
203          sleep 10   # Wait for container to start
204          curl --fail https://staging.example.com/health
205
206  # ─────────────────────────────────────────────────────────────────
207  # STAGE 5: Deploy to Production (requires manual approval)
208  # ─────────────────────────────────────────────────────────────────
209  deploy-production:
210    name: Deploy → Production
211    needs: deploy-staging
212    runs-on: ubuntu-latest
213    environment:
214      name: production              # This environment has "Required reviewers" set in
215      url: https://example.com      # GitHub Settings → Environments → production
216
217    steps:
218      - uses: actions/checkout@v4
219
220      - name: Deploy to production via SSH
221        uses: appleboy/ssh-action@v1
222        with:
223          host: ${{ secrets.PROD_HOST }}
224          username: deploy
225          key: ${{ secrets.PROD_SSH_KEY }}
226          script: |
227            docker pull ${{ needs.build.outputs.image_tag }}
228            # Rolling update: replace containers one by one (zero downtime)
229            docker compose -f /opt/app/docker-compose.prod.yml up -d \
230              --no-deps --scale app=2 app
231            sleep 15
232            docker compose -f /opt/app/docker-compose.prod.yml up -d \
233              --no-deps --scale app=1 app
234            docker system prune -f
235
236      - name: Notify Slack on successful deployment
237        if: success()
238        uses: slackapi/slack-github-action@v1
239        with:
240          payload: |
241            {
242              "text": "Deployment to production succeeded for `${{ github.repository }}`\nCommit: ${{ github.sha }}\nActor: ${{ github.actor }}"
243            }
244        env:
245          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}