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 }}