
Deployment Pipeline
- 50 installs
- 8 repo stars
- Updated February 6, 2026
- hieutrtr/ai1-skills
Covers CI/CD pipeline configuration and deployment procedures for Python/React projects using GitHub Actions and Docker.
About
Defines pipeline stages (build/test/staging/production), environment promotion, health checks, canary deployment, and rollback for Python/React projects. A developer uses it when deploying to staging or production or building GitHub Actions workflows.
- Build/test/staging/production stages with environment promotion
- Canary deployment and rollback procedures
Deployment Pipeline by the numbers
- 50 all-time installs (skills.sh)
- Ranked #730 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hieutrtr/ai1-skills --skill deployment-pipelineAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 50 |
|---|---|
| repo stars | ★ 8 |
| Last updated | February 6, 2026 |
| Repository | hieutrtr/ai1-skills ↗ |
What it does
Covers CI/CD pipeline configuration and deployment procedures for Python/React projects using GitHub Actions and Docker.
Files
Deployment Pipeline
When to Use
Activate this skill when:
- Setting up or modifying CI/CD pipelines with GitHub Actions
- Deploying application changes to staging or production environments
- Planning environment promotion strategies (dev -> staging -> production)
- Implementing pre-deployment validation gates
- Configuring health checks and smoke tests for deployed services
- Planning or executing rollback procedures after a failed deployment
- Setting up canary or blue-green deployment strategies
- Troubleshooting deployment failures or pipeline errors
Output: Write deployment results to deployment-report.md with status, version deployed, health check results, and rollback instructions if needed.
Do NOT use this skill for:
- Building or optimizing Docker images (use
docker-best-practices) - Responding to production incidents (use
incident-response) - Setting up monitoring or alerting (use
monitoring-setup) - Infrastructure provisioning (Terraform, CloudFormation)
Instructions
Pipeline Stages Overview
Every deployment follows a strict four-stage pipeline. No stage may be skipped.
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
│ BUILD │───>│ TEST │───>│ STAGING │───>│ PRODUCTION │
│ │ │ │ │ │ │ │
│ • Lint │ │ • Unit │ │ • Deploy │ │ • Canary 10% │
│ • Build │ │ • Integ │ │ • Smoke │ │ • Monitor │
│ • Image │ │ • E2E │ │ • QA │ │ • Full 100% │
└──────────┘ └──────────┘ └──────────┘ └──────────────┘
Gate: Gate: Gate: Gate:
Build pass Tests pass Smoke pass Health checks
No lint err Coverage ≥80% Manual approve Error rate <1%Stage 1: Build
Build stage validates code quality and produces deployable artifacts.
Steps: 1. Lint and format check -- Run ruff check and ruff format --check for Python, eslint and prettier --check for React 2. Type check -- Run mypy for Python, tsc --noEmit for TypeScript 3. Build artifacts -- Build Python wheel/sdist, build React production bundle 4. Build Docker images -- Tag with git SHA and branch name
Gate criteria: All checks pass, images build successfully.
# GitHub Actions build stage
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint Python
run: ruff check src/ && ruff format --check src/
- name: Type check Python
run: mypy src/
- name: Build backend image
run: docker build -t app-backend:${{ github.sha }} -f Dockerfile.backend .
- name: Build frontend
run: npm ci && npm run build
- name: Build frontend image
run: docker build -t app-frontend:${{ github.sha }} -f Dockerfile.frontend .Stage 2: Test
Run the full test suite. Never skip tests for "urgent" deployments.
Steps: 1. Unit tests -- pytest tests/unit/ -v --cov=src --cov-report=xml 2. Integration tests -- pytest tests/integration/ -v (requires test database) 3. Frontend tests -- npm test -- --coverage 4. E2E tests -- npx playwright test against a test environment 5. Security scan -- pip-audit for Python, npm audit for Node
Gate criteria: All tests pass, coverage >= 80%, no critical vulnerabilities.
# GitHub Actions test stage
test:
needs: build
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: testpass
ports: ['5432:5432']
redis:
image: redis:7-alpine
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- name: Run unit tests
run: pytest tests/unit/ -v --cov=src --cov-report=xml
- name: Run integration tests
run: pytest tests/integration/ -v
env:
DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
- name: Check coverage threshold
run: coverage report --fail-under=80Stage 3: Staging Deployment
Deploy to staging environment for validation before production.
Pre-deployment checklist:
- [ ] All tests pass in CI
- [ ] Database migrations tested with
scripts/migration-dry-run.sh - [ ] Environment variables verified for staging
- [ ] Feature flags configured appropriately
- [ ] Dependent services verified available
Steps: 1. Run migration dry-run -- Validate Alembic migrations against staging DB clone 2. Deploy to staging -- Push images, apply migrations, restart services 3. Run smoke tests -- Execute scripts/smoke-test.sh against staging URL 4. Run health checks -- Execute scripts/health-check.py for all endpoints 5. Manual QA -- Team verifies critical user flows
Gate criteria: Smoke tests pass, health checks green, QA sign-off.
Stage 4: Production Deployment
Production deployment uses canary strategy to minimize risk.
Canary deployment steps: 1. Deploy canary (10% traffic) -- Route 10% of traffic to new version 2. Monitor for 10 minutes -- Watch error rates, latency, resource usage 3. Evaluate canary -- If error rate < 1% and p99 latency within 20% of baseline, proceed 4. Ramp to 50% -- Increase traffic to 50%, monitor for 5 minutes 5. Full rollout (100%) -- Complete the deployment 6. Post-deployment smoke tests -- Run full smoke test suite
Canary Timeline:
0 min 10 min 15 min 20 min
|--------|--------|--------|
10% Check 50% 100%
Deploy Metrics Ramp Full
OK? Up Rollout
|
No -> Rollback immediatelyAutomatic rollback triggers:
- Error rate exceeds 5% during canary
- p99 latency increases by more than 50%
- Health check failures on canary instances
- Memory usage exceeds 90% threshold
Pre-Deployment Validation
Run these validations before any deployment. Use scripts/deploy.sh --validate-only for a dry run.
Backend validation:
# Verify migrations are consistent
alembic check
# Verify no pending migrations
alembic heads --verbose
# Test migration against staging clone
./skills/deployment-pipeline/scripts/migration-dry-run.sh \
--db-url "$STAGING_DB_URL" \
--output-dir ./deploy-validation/
# Verify all dependencies are pinned
pip-compile --dry-run requirements.inFrontend validation:
# Verify build succeeds
npm run build
# Check bundle size limits
npx bundlesize
# Verify environment variables are set
node -e "const vars = ['REACT_APP_API_URL']; vars.forEach(v => { if(!process.env[v]) throw new Error(v + ' not set') })"Environment Promotion
Strict rules govern how changes move between environments.
| Aspect | Development | Staging | Production |
|---|---|---|---|
| Deploy trigger | Push to main | Manual or auto after tests | Manual approval required |
| Database | Local PostgreSQL | Staging PostgreSQL | Production PostgreSQL (RDS) |
| Secrets | .env file | GitHub Secrets | AWS Secrets Manager |
| Log level | DEBUG | INFO | WARNING |
| Feature flags | All enabled | Per-feature | Gradual rollout |
| SSL | Self-signed | ACM cert | ACM cert |
| Replicas | 1 | 2 | 3+ (auto-scaled) |
Promotion rules: 1. Code must pass ALL gates in the previous stage 2. Database migrations must be backward-compatible (no column drops without migration window) 3. Environment variables must be configured BEFORE deployment 4. Feature flags must be set to correct state BEFORE deployment 5. Rollback plan must be documented BEFORE production deployment
Health Checks
Every service exposes health check endpoints. The deployment pipeline validates these after every deployment.
Required health check endpoints:
# FastAPI health check endpoints
@router.get("/health")
async def health():
"""Basic liveness check -- returns 200 if process is running."""
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
@router.get("/health/ready")
async def readiness(db: AsyncSession = Depends(get_db)):
"""Readiness check -- verifies all dependencies are accessible."""
checks = {}
# Database
try:
await db.execute(text("SELECT 1"))
checks["database"] = "ok"
except Exception as e:
checks["database"] = f"error: {str(e)}"
# Redis
try:
await redis.ping()
checks["redis"] = "ok"
except Exception as e:
checks["redis"] = f"error: {str(e)}"
all_ok = all(v == "ok" for v in checks.values())
return JSONResponse(
status_code=200 if all_ok else 503,
content={"status": "ready" if all_ok else "not_ready", "checks": checks}
)Health check strategy during deployment:
After deploy:
Wait 10s -> Check /health (liveness)
Wait 5s -> Check /health/ready (readiness)
Wait 5s -> Check /health/ready again (stability)
All pass -> Deployment successful
Any fail -> Trigger rollbackUse scripts/health-check.py for automated health validation:
python scripts/health-check.py \
--url https://staging.example.com \
--retries 3 \
--timeout 30 \
--output-dir ./health-results/Rollback Procedure
When a deployment fails, follow this rollback procedure immediately. See references/rollback-runbook.md for the full step-by-step guide.
Automated rollback (preferred):
# Roll back to previous version
./skills/deployment-pipeline/scripts/deploy.sh \
--rollback \
--version "$PREVIOUS_VERSION" \
--output-dir ./rollback-results/Rollback decision matrix:
| Signal | Action | Timeline |
|---|---|---|
| Error rate > 5% | Automatic rollback | Immediate |
| p99 latency > 2x baseline | Automatic rollback | Immediate |
| Health check failures | Automatic rollback | After 2 retries |
| User-reported issues | Manual rollback decision | Within 15 minutes |
| Data inconsistency | Stop traffic, investigate | Immediate |
Database rollback considerations:
- Forward-only migrations are preferred; avoid
alembic downgradein production - If migration must be reversed, use a new forward migration to undo changes
- Never drop columns or tables in the same release that removes code references
- Use a two-phase approach: Phase 1 deploys new code (backward compatible), Phase 2 removes old columns
GitHub Actions CI/CD
The full CI/CD pipeline is defined in .github/workflows/deploy.yml. See references/github-actions-template.yml for the complete template.
Key workflow features:
- Matrix testing -- Test against Python 3.12 and 3.13
- Caching -- Cache pip, npm, and Docker layers for faster builds
- Concurrency -- Cancel in-progress deployments when new commits arrive
- Environment protection -- Require manual approval for production
- Secrets management -- Use GitHub environment secrets per stage
# Key sections of the workflow
on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
build: # Stage 1
test: # Stage 2 (needs: build)
staging: # Stage 3 (needs: test)
production: # Stage 4 (needs: staging, manual approval)Canary Deployment
Canary deployment routes a small percentage of traffic to the new version before full rollout.
Implementation with Docker and Nginx:
# nginx canary configuration
upstream backend {
server backend-stable:8000 weight=9; # 90% to stable
server backend-canary:8000 weight=1; # 10% to canary
}Canary evaluation criteria:
# Canary health evaluation
def evaluate_canary(metrics: dict) -> bool:
"""Return True if canary is healthy enough to proceed."""
checks = [
metrics["error_rate"] < 0.01, # < 1% error rate
metrics["p99_latency_ms"] < 500, # p99 under 500ms
metrics["memory_usage_pct"] < 85, # Memory under 85%
metrics["cpu_usage_pct"] < 75, # CPU under 75%
metrics["successful_health_checks"] >= 3, # 3+ consecutive passes
]
return all(checks)Canary monitoring checklist:
- [ ] Error rate compared to baseline (must be within 1%)
- [ ] Latency percentiles (p50, p95, p99) compared to baseline
- [ ] Resource utilization (CPU, memory) within thresholds
- [ ] No increase in log error volume
- [ ] Health check endpoints responding correctly
- [ ] No degradation in dependent service metrics
Deployment Scripts
The following scripts automate deployment tasks:
| Script | Purpose | Usage |
|---|---|---|
scripts/deploy.sh | Main deployment orchestration | ./scripts/deploy.sh --env staging --output-dir ./results/ |
scripts/smoke-test.sh | Post-deployment smoke tests | ./scripts/smoke-test.sh --url https://staging.example.com --output-dir ./results/ |
scripts/health-check.py | Health endpoint validation | python scripts/health-check.py --url https://staging.example.com --output-dir ./results/ |
scripts/migration-dry-run.sh | Test migrations safely | ./scripts/migration-dry-run.sh --db-url $DB_URL --output-dir ./results/ |
Quick Reference
Deploy to staging:
./skills/deployment-pipeline/scripts/deploy.sh \
--env staging \
--version $(git rev-parse --short HEAD) \
--output-dir ./deploy-results/Deploy to production (with canary):
./skills/deployment-pipeline/scripts/deploy.sh \
--env production \
--version $(git rev-parse --short HEAD) \
--canary \
--output-dir ./deploy-results/Run smoke tests:
./skills/deployment-pipeline/scripts/smoke-test.sh \
--url https://staging.example.com \
--output-dir ./smoke-results/Emergency rollback:
./skills/deployment-pipeline/scripts/deploy.sh \
--rollback \
--env production \
--version $PREVIOUS_SHA \
--output-dir ./rollback-results/Output File
Write deployment results to deployment-report.md:
# Deployment Report
## Summary
- **Environment:** staging | production
- **Version:** abc1234 (git SHA)
- **Status:** SUCCESS | FAILED | ROLLED_BACK
- **Timestamp:** 2024-01-15T14:30:00Z
- **Duration:** 12 minutes
## Pipeline Stages
| Stage | Status | Duration | Notes |
|-------|--------|----------|-------|
| Build | PASS | 3m | Image built: app:abc1234 |
| Test | PASS | 5m | 142 tests, 85% coverage |
| Staging | PASS | 2m | Smoke tests passed |
| Production | PASS | 2m | Canary 10% → 50% → 100% |
## Health Checks
- `/health` — 200 OK (12ms)
- `/health/ready` — 200 OK (45ms)
## Rollback Instructions
If issues occur, run:
\`\`\`bash
./scripts/deploy.sh --rollback --env production --version $PREV_SHA
\`\`\`
Previous version: def5678
## Next Steps
- Run `/monitoring-setup` to verify alerts are configured
- Run `/incident-response` if errors occurEnvironment Configuration Guide
Overview
This document defines the configuration differences between development, staging, and production environments. All environment-specific values are injected via environment variables -- never hardcoded in source.
Environment Comparison Matrix
| Configuration | Development | Staging | Production |
|---|---|---|---|
| Deploy trigger | Local / push to branch | Push to main / manual | Manual approval required |
| Base URL | http://localhost:8000 | https://staging.example.com | https://api.example.com |
| Frontend URL | http://localhost:3000 | https://staging-app.example.com | https://app.example.com |
| Database | Local PostgreSQL 16 | Staging RDS PostgreSQL 16 | Production RDS PostgreSQL 16 |
| Redis | Local Redis 7 | ElastiCache (single node) | ElastiCache (cluster mode) |
| Log level | DEBUG | INFO | WARNING |
| Debug mode | True | False | False |
| SSL/TLS | Self-signed / none | ACM certificate | ACM certificate |
| Replicas | 1 | 2 | 3+ (auto-scaled) |
| Secrets source | .env file | GitHub Secrets | AWS Secrets Manager |
| Feature flags | All enabled | Per-feature | Gradual rollout |
| CORS origins | * | Staging domain only | Production domain only |
| Rate limiting | Disabled | Relaxed (100 req/min) | Strict (30 req/min) |
| Error reporting | Console only | Sentry (staging DSN) | Sentry (production DSN) |
| Backups | None | Daily | Hourly + continuous WAL |
Required Environment Variables
Backend (FastAPI)
# ─── Core ────────────────────────────────────────────────────────────────
APP_ENV=development|staging|production
DEBUG=true|false
SECRET_KEY=<random-64-char-string>
ALLOWED_HOSTS=localhost,staging.example.com,api.example.com
# ─── Database ────────────────────────────────────────────────────────────
DATABASE_URL=postgresql+asyncpg://user:password@host:5432/dbname
DATABASE_POOL_SIZE=5|10|20
DATABASE_MAX_OVERFLOW=10|20|40
# ─── Redis ───────────────────────────────────────────────────────────────
REDIS_URL=redis://localhost:6379/0
REDIS_MAX_CONNECTIONS=10|20|50
# ─── Authentication ──────────────────────────────────────────────────────
JWT_SECRET_KEY=<random-64-char-string>
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30|30|15
# ─── CORS ────────────────────────────────────────────────────────────────
CORS_ORIGINS=*|https://staging-app.example.com|https://app.example.com
# ─── Logging ─────────────────────────────────────────────────────────────
LOG_LEVEL=DEBUG|INFO|WARNING
LOG_FORMAT=console|json|json
SENTRY_DSN=<empty>|<staging-dsn>|<production-dsn>
# ─── External Services ──────────────────────────────────────────────────
SMTP_HOST=mailhog|ses-smtp.region.amazonaws.com|ses-smtp.region.amazonaws.com
S3_BUCKET=local-dev|staging-bucket|production-bucketFrontend (React)
# ─── API ─────────────────────────────────────────────────────────────────
REACT_APP_API_URL=http://localhost:8000|https://staging.example.com|https://api.example.com
# ─── Feature Flags ──────────────────────────────────────────────────────
REACT_APP_ENABLE_DEBUG_PANEL=true|false|false
REACT_APP_ENABLE_ANALYTICS=false|true|true
# ─── Error Tracking ─────────────────────────────────────────────────────
REACT_APP_SENTRY_DSN=<empty>|<staging-dsn>|<production-dsn>
REACT_APP_SENTRY_ENVIRONMENT=development|staging|productionSecrets Management
Development
Store secrets in a .env file (never committed to git):
# .env (gitignored)
SECRET_KEY=dev-secret-key-not-for-production
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/app_dev
REDIS_URL=redis://localhost:6379/0
JWT_SECRET_KEY=dev-jwt-secretStaging
Secrets stored in GitHub Environment Secrets:
Repository Settings -> Environments -> staging -> Environment secretsRequired secrets:
DATABASE_URLREDIS_URLSECRET_KEYJWT_SECRET_KEYSENTRY_DSNDOCKER_PASSWORD
Production
Secrets stored in AWS Secrets Manager:
# Retrieve secrets at startup
aws secretsmanager get-secret-value \
--secret-id prod/app/secrets \
--query SecretString \
--output text | jq -r 'to_entries[] | "\(.key)=\(.value)"' > /tmp/.env
# Or use ECS task definition with secrets referencesDatabase Configuration
Connection Pool Settings
| Setting | Development | Staging | Production |
|---|---|---|---|
pool_size | 5 | 10 | 20 |
max_overflow | 10 | 20 | 40 |
pool_timeout | 30s | 30s | 10s |
pool_recycle | 3600s | 1800s | 900s |
pool_pre_ping | True | True | True |
Migration Strategy Per Environment
| Environment | Migration Method | Approval |
|---|---|---|
| Development | alembic upgrade head (manual) | None |
| Staging | Automated in CI, dry-run first | Automated |
| Production | Automated in CI, dry-run + manual approval | Required |
Health Check Configuration
All environments expose the same health check endpoints but with different thresholds:
# config.py
HEALTH_CHECK_CONFIG = {
"development": {
"timeout_seconds": 10,
"check_database": True,
"check_redis": True,
"check_external_services": False,
},
"staging": {
"timeout_seconds": 5,
"check_database": True,
"check_redis": True,
"check_external_services": True,
},
"production": {
"timeout_seconds": 3,
"check_database": True,
"check_redis": True,
"check_external_services": True,
},
}Deployment Checklist by Environment
Before Deploying to Staging
- [ ] All environment variables set in GitHub Secrets
- [ ] Database migrations tested locally
- [ ] Feature flags configured in staging config
- [ ] Dependent services available in staging
Before Deploying to Production
- [ ] Staging deployment verified and smoke tests passing
- [ ] All production environment variables configured in AWS Secrets Manager
- [ ] Database migration dry-run completed against production clone
- [ ] Rollback plan documented
- [ ] On-call engineer notified
- [ ] Feature flags set for gradual rollout
# GitHub Actions CI/CD Pipeline Template
# Full deployment pipeline: build -> test -> staging -> production
#
# Prerequisites:
# - GitHub Environments: staging, production (with required reviewers)
# - GitHub Secrets per environment: DATABASE_URL, REDIS_URL, API_URL,
# DOCKER_REGISTRY, DOCKER_USERNAME, DOCKER_PASSWORD
# - Branch protection on main: require status checks
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options:
- staging
- production
skip_tests:
description: 'Skip test stage (emergency only)'
required: false
type: boolean
default: false
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
env:
PYTHON_VERSION: '3.12'
NODE_VERSION: '20'
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
jobs:
# ─── Stage 1: Build ──────────────────────────────────────────────────────
build:
name: Build & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install Python dependencies
run: |
pip install -r requirements.txt
pip install ruff mypy
- name: Lint Python
run: |
ruff check src/
ruff format --check src/
- name: Type check Python
run: mypy src/
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Lint frontend
working-directory: frontend
run: |
npx eslint src/
npx prettier --check src/
- name: Type check frontend
working-directory: frontend
run: npx tsc --noEmit
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Upload frontend build
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: frontend/build/
retention-days: 1
- name: Build backend Docker image
run: |
docker build -t app-backend:${{ github.sha }} -f Dockerfile.backend .
docker save app-backend:${{ github.sha }} > /tmp/backend-image.tar
- name: Build frontend Docker image
run: |
docker build -t app-frontend:${{ github.sha }} -f Dockerfile.frontend .
docker save app-frontend:${{ github.sha }} > /tmp/frontend-image.tar
- name: Upload Docker images
uses: actions/upload-artifact@v4
with:
name: docker-images
path: /tmp/*-image.tar
retention-days: 1
# ─── Stage 2: Test ───────────────────────────────────────────────────────
test:
name: Test Suite
needs: build
if: ${{ !inputs.skip_tests }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Run unit tests
run: pytest tests/unit/ -v --cov=src --cov-report=xml --junitxml=test-results/unit.xml
- name: Run integration tests
run: pytest tests/integration/ -v --junitxml=test-results/integration.xml
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
- name: Check coverage threshold
run: coverage report --fail-under=80
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
- name: Security audit (Python)
run: pip-audit --strict
continue-on-error: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Frontend tests
working-directory: frontend
run: |
npm ci
npm test -- --coverage --watchAll=false
- name: Security audit (npm)
working-directory: frontend
run: npm audit --audit-level=high
continue-on-error: true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/
# ─── Stage 3: Deploy to Staging ──────────────────────────────────────────
staging:
name: Deploy to Staging
needs: [build, test]
if: github.ref == 'refs/heads/main' || inputs.environment == 'staging'
runs-on: ubuntu-latest
environment:
name: staging
url: ${{ vars.STAGING_URL }}
steps:
- uses: actions/checkout@v4
- name: Download Docker images
uses: actions/download-artifact@v4
with:
name: docker-images
path: /tmp/
- name: Load Docker images
run: |
docker load < /tmp/backend-image.tar
docker load < /tmp/frontend-image.tar
- name: Push to registry
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login ${{ env.DOCKER_REGISTRY }} -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
docker tag app-backend:${{ github.sha }} ${{ env.DOCKER_REGISTRY }}/app-backend:${{ github.sha }}
docker tag app-frontend:${{ github.sha }} ${{ env.DOCKER_REGISTRY }}/app-frontend:${{ github.sha }}
docker push ${{ env.DOCKER_REGISTRY }}/app-backend:${{ github.sha }}
docker push ${{ env.DOCKER_REGISTRY }}/app-frontend:${{ github.sha }}
- name: Run migration dry-run
run: |
chmod +x skills/deployment-pipeline/scripts/migration-dry-run.sh
./skills/deployment-pipeline/scripts/migration-dry-run.sh \
--db-url "${{ secrets.DATABASE_URL }}" \
--output-dir ./deploy-results/
continue-on-error: false
- name: Deploy to staging
run: |
chmod +x skills/deployment-pipeline/scripts/deploy.sh
./skills/deployment-pipeline/scripts/deploy.sh \
--env staging \
--version ${{ github.sha }} \
--output-dir ./deploy-results/
- name: Smoke tests
run: |
chmod +x skills/deployment-pipeline/scripts/smoke-test.sh
./skills/deployment-pipeline/scripts/smoke-test.sh \
--url "${{ vars.STAGING_URL }}" \
--output-dir ./deploy-results/
- name: Health checks
run: |
python skills/deployment-pipeline/scripts/health-check.py \
--url "${{ vars.STAGING_URL }}" \
--retries 5 \
--output-dir ./deploy-results/
- name: Upload deploy results
if: always()
uses: actions/upload-artifact@v4
with:
name: staging-deploy-results
path: deploy-results/
# ─── Stage 4: Deploy to Production ──────────────────────────────────────
production:
name: Deploy to Production
needs: staging
if: github.ref == 'refs/heads/main' || inputs.environment == 'production'
runs-on: ubuntu-latest
environment:
name: production
url: ${{ vars.PRODUCTION_URL }}
steps:
- uses: actions/checkout@v4
- name: Download Docker images
uses: actions/download-artifact@v4
with:
name: docker-images
path: /tmp/
- name: Load and push Docker images
run: |
docker load < /tmp/backend-image.tar
docker load < /tmp/frontend-image.tar
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login ${{ env.DOCKER_REGISTRY }} -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
docker tag app-backend:${{ github.sha }} ${{ env.DOCKER_REGISTRY }}/app-backend:${{ github.sha }}
docker tag app-backend:${{ github.sha }} ${{ env.DOCKER_REGISTRY }}/app-backend:latest
docker tag app-frontend:${{ github.sha }} ${{ env.DOCKER_REGISTRY }}/app-frontend:${{ github.sha }}
docker tag app-frontend:${{ github.sha }} ${{ env.DOCKER_REGISTRY }}/app-frontend:latest
docker push ${{ env.DOCKER_REGISTRY }}/app-backend:${{ github.sha }}
docker push ${{ env.DOCKER_REGISTRY }}/app-backend:latest
docker push ${{ env.DOCKER_REGISTRY }}/app-frontend:${{ github.sha }}
docker push ${{ env.DOCKER_REGISTRY }}/app-frontend:latest
- name: Deploy to production (canary)
run: |
chmod +x skills/deployment-pipeline/scripts/deploy.sh
./skills/deployment-pipeline/scripts/deploy.sh \
--env production \
--version ${{ github.sha }} \
--canary \
--output-dir ./deploy-results/
- name: Production smoke tests
run: |
chmod +x skills/deployment-pipeline/scripts/smoke-test.sh
./skills/deployment-pipeline/scripts/smoke-test.sh \
--url "${{ vars.PRODUCTION_URL }}" \
--output-dir ./deploy-results/
- name: Production health checks
run: |
python skills/deployment-pipeline/scripts/health-check.py \
--url "${{ vars.PRODUCTION_URL }}" \
--retries 5 \
--timeout 30 \
--output-dir ./deploy-results/
- name: Upload deploy results
if: always()
uses: actions/upload-artifact@v4
with:
name: production-deploy-results
path: deploy-results/
Rollback Runbook
Purpose
Step-by-step procedure for rolling back a failed production deployment. Follow this runbook whenever a deployment causes service degradation, error rate spikes, or health check failures.
Prerequisites
Before starting a rollback:
- Identify the previous stable version (git SHA or tag)
- Confirm you have access to the deployment tooling
- Notify the on-call engineer and incident commander
Rollback Decision Criteria
| Signal | Threshold | Action |
|---|---|---|
| Error rate | > 5% of requests | Immediate rollback |
| p99 latency | > 2x baseline | Immediate rollback |
| Health check failures | 2+ consecutive | Immediate rollback |
| Memory usage | > 90% | Immediate rollback |
| User-reported issues | 3+ unique reports | Evaluate, likely rollback |
Step-by-Step Rollback Procedure
Step 1: Confirm the Issue (2 minutes max)
# Check current health status
python skills/deployment-pipeline/scripts/health-check.py \
--url https://api.example.com \
--output-dir ./rollback-investigation/
# Check error rate in logs
curl -s https://api.example.com/health/ready | jq .
# Verify which version is currently deployed
docker ps --format "table {{.Image}}\t{{.Status}}\t{{.Names}}"Step 2: Announce Rollback (1 minute)
Post in the incident channel:
@channel ROLLBACK IN PROGRESS
Environment: production
Current version: <failing-sha>
Rolling back to: <previous-stable-sha>
Reason: <brief description>
ETA: 5-10 minutesStep 3: Execute Rollback (5 minutes)
Option A: Automated rollback (preferred)
./skills/deployment-pipeline/scripts/deploy.sh \
--rollback \
--env production \
--version <PREVIOUS_STABLE_SHA> \
--output-dir ./rollback-results/Option B: Manual rollback via Docker
# Pull previous images
docker pull registry.example.com/app-backend:<PREVIOUS_SHA>
docker pull registry.example.com/app-frontend:<PREVIOUS_SHA>
# Update backend service
docker service update \
--image registry.example.com/app-backend:<PREVIOUS_SHA> \
app-backend
# Update frontend service
docker service update \
--image registry.example.com/app-frontend:<PREVIOUS_SHA> \
app-frontendOption C: Rollback via GitHub Actions
1. Go to Actions tab in GitHub 2. Select "CI/CD Pipeline" workflow 3. Click "Run workflow" 4. Select environment: production 5. Enter the previous stable version SHA
Step 4: Verify Rollback (3 minutes)
# Run health checks
python skills/deployment-pipeline/scripts/health-check.py \
--url https://api.example.com \
--retries 5 \
--output-dir ./rollback-results/
# Run smoke tests
./skills/deployment-pipeline/scripts/smoke-test.sh \
--url https://api.example.com \
--output-dir ./rollback-results/
# Verify correct version is running
curl -s https://api.example.com/health | jq .versionStep 5: Announce Resolution
@channel ROLLBACK COMPLETE
Environment: production
Rolled back to: <previous-stable-sha>
Status: All health checks passing
Next steps: RCA will be conducted within 24 hoursDatabase Rollback Considerations
Important: Database migrations are forward-only by default.
If the failed deployment included migrations:
1. Do NOT run `alembic downgrade` in production unless the migration was specifically designed to be reversible 2. Instead, create a new forward migration that undoes the changes 3. If data was corrupted, restore from the most recent backup
Safe migration rollback pattern:
# Step 1: Identify the migration that needs reversal
alembic history --verbose
# Step 2: Create a reversal migration
alembic revision --autogenerate -m "Revert: <original migration description>"
# Step 3: Test the reversal migration
./skills/deployment-pipeline/scripts/migration-dry-run.sh \
--db-url "$STAGING_DB_URL" \
--output-dir ./migration-rollback-test/
# Step 4: Apply the reversal migration
alembic upgrade headPost-Rollback Actions
1. Create incident ticket with timeline, impact, and root cause hypothesis 2. Schedule post-mortem within 24-48 hours 3. Document the rollback in the deployment log 4. Investigate root cause before re-attempting deployment 5. Add regression tests for the failure scenario
Emergency Contacts
| Role | Contact | When to Escalate |
|---|---|---|
| On-call engineer | PagerDuty rotation | First responder |
| Incident commander | Engineering manager | SEV1/SEV2 incidents |
| Database admin | DBA on-call | Data corruption or migration issues |
| Platform team | #platform-team Slack | Infrastructure issues |
Rollback Checklist
- [ ] Issue confirmed (health checks, error rates, user reports)
- [ ] Previous stable version identified
- [ ] Team notified in incident channel
- [ ] Rollback executed
- [ ] Health checks passing after rollback
- [ ] Smoke tests passing after rollback
- [ ] Resolution announced
- [ ] Incident ticket created
- [ ] Post-mortem scheduled
#!/usr/bin/env bash
#
# deploy.sh -- Main deployment orchestration script
#
# Orchestrates the deployment of backend and frontend services to the
# specified environment. Supports staging and production deployments,
# canary rollouts, rollback, and pre-deployment validation.
#
# Usage:
# ./deploy.sh --env staging --version abc1234 --output-dir ./results/
# ./deploy.sh --env production --version abc1234 --canary --output-dir ./results/
# ./deploy.sh --rollback --env production --version prev123 --output-dir ./results/
# ./deploy.sh --validate-only --env staging --output-dir ./results/
#
set -euo pipefail
# ─── Defaults ────────────────────────────────────────────────────────────────
ENV=""
VERSION=""
OUTPUT_DIR="./deploy-results"
CANARY=false
ROLLBACK=false
VALIDATE_ONLY=false
CANARY_WEIGHT=10
HEALTH_CHECK_RETRIES=3
HEALTH_CHECK_TIMEOUT=30
# ─── Parse Arguments ─────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--env) ENV="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--canary) CANARY=true; shift ;;
--canary-weight) CANARY_WEIGHT="$2"; shift 2 ;;
--rollback) ROLLBACK=true; shift ;;
--validate-only) VALIDATE_ONLY=true; shift ;;
--retries) HEALTH_CHECK_RETRIES="$2"; shift 2 ;;
--timeout) HEALTH_CHECK_TIMEOUT="$2"; shift 2 ;;
-h|--help)
echo "Usage: $0 --env <staging|production> --version <git-sha> --output-dir <dir>"
echo ""
echo "Options:"
echo " --env Target environment (staging or production)"
echo " --version Git SHA or tag to deploy"
echo " --output-dir Directory for deployment result files"
echo " --canary Enable canary deployment (production only)"
echo " --canary-weight Initial canary traffic percentage (default: 10)"
echo " --rollback Roll back to the specified version"
echo " --validate-only Run pre-deployment validation without deploying"
echo " --retries Health check retry count (default: 3)"
echo " --timeout Health check timeout in seconds (default: 30)"
exit 0
;;
*) echo "ERROR: Unknown argument: $1" >&2; exit 1 ;;
esac
done
# ─── Validation ──────────────────────────────────────────────────────────────
if [[ -z "$ENV" ]]; then
echo "ERROR: --env is required (staging or production)" >&2
exit 1
fi
if [[ "$ENV" != "staging" && "$ENV" != "production" ]]; then
echo "ERROR: --env must be 'staging' or 'production'" >&2
exit 1
fi
if [[ -z "$VERSION" && "$VALIDATE_ONLY" == false ]]; then
echo "ERROR: --version is required unless --validate-only is set" >&2
exit 1
fi
# ─── Setup Output ────────────────────────────────────────────────────────────
mkdir -p "$OUTPUT_DIR"
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
LOG_FILE="${OUTPUT_DIR}/deploy-${ENV}-${TIMESTAMP}.log"
RESULT_FILE="${OUTPUT_DIR}/deploy-${ENV}-${TIMESTAMP}.json"
log() {
local msg="[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] $1"
echo "$msg" | tee -a "$LOG_FILE"
}
write_result() {
local status="$1"
local message="$2"
cat > "$RESULT_FILE" <<EOJSON
{
"environment": "${ENV}",
"version": "${VERSION}",
"status": "${status}",
"message": "${message}",
"timestamp": "${TIMESTAMP}",
"canary": ${CANARY},
"rollback": ${ROLLBACK},
"log_file": "${LOG_FILE}"
}
EOJSON
log "Result written to ${RESULT_FILE}"
}
# ─── Pre-Deployment Validation ───────────────────────────────────────────────
validate() {
log "Running pre-deployment validation for ${ENV}..."
local errors=0
# Check Alembic migration consistency
if command -v alembic &>/dev/null; then
log "Checking Alembic migrations..."
if ! alembic check 2>>"$LOG_FILE"; then
log "WARNING: Alembic check failed -- migrations may be out of sync"
errors=$((errors + 1))
fi
else
log "SKIP: alembic not found in PATH"
fi
# Check Docker images exist
if [[ -n "${VERSION:-}" ]]; then
log "Verifying Docker images for version ${VERSION}..."
for image in "app-backend:${VERSION}" "app-frontend:${VERSION}"; do
if ! docker image inspect "$image" &>/dev/null 2>&1; then
log "WARNING: Docker image ${image} not found locally"
errors=$((errors + 1))
else
log "OK: Image ${image} exists"
fi
done
fi
# Check environment-specific config
log "Validating environment configuration for ${ENV}..."
local required_vars=()
if [[ "$ENV" == "staging" ]]; then
required_vars=(STAGING_DB_URL STAGING_REDIS_URL STAGING_API_URL)
elif [[ "$ENV" == "production" ]]; then
required_vars=(PRODUCTION_DB_URL PRODUCTION_REDIS_URL PRODUCTION_API_URL)
fi
for var in "${required_vars[@]}"; do
if [[ -z "${!var:-}" ]]; then
log "WARNING: Environment variable ${var} is not set"
errors=$((errors + 1))
else
log "OK: ${var} is set"
fi
done
if [[ $errors -gt 0 ]]; then
log "Validation completed with ${errors} warning(s)"
else
log "Validation passed -- all checks OK"
fi
return $errors
}
# ─── Rollback ────────────────────────────────────────────────────────────────
rollback() {
log "=== ROLLBACK: Rolling back ${ENV} to version ${VERSION} ==="
log "Pulling previous images..."
for service in backend frontend; do
log "Deploying app-${service}:${VERSION}..."
# In a real deployment, this would update the running service
# docker service update --image "app-${service}:${VERSION}" "app-${service}"
log "Simulated rollback of app-${service} to ${VERSION}"
done
log "Waiting for services to stabilize..."
sleep 5
log "Running post-rollback health checks..."
local health_ok=true
for endpoint in "/health" "/health/ready"; do
local url="${BASE_URL}${endpoint}"
log "Checking ${url}..."
if curl -sf --max-time "$HEALTH_CHECK_TIMEOUT" "$url" >>"$LOG_FILE" 2>&1; then
log "OK: ${endpoint} is healthy"
else
log "FAIL: ${endpoint} is not responding"
health_ok=false
fi
done
if [[ "$health_ok" == true ]]; then
write_result "rollback_success" "Rolled back to ${VERSION} successfully"
log "=== ROLLBACK COMPLETE ==="
else
write_result "rollback_failed" "Rollback to ${VERSION} completed but health checks failed"
log "=== ROLLBACK COMPLETED WITH WARNINGS ==="
exit 1
fi
}
# ─── Deploy ──────────────────────────────────────────────────────────────────
deploy() {
log "=== DEPLOY: Deploying ${VERSION} to ${ENV} ==="
# Step 1: Pre-deployment validation
log "Step 1: Pre-deployment validation"
if ! validate; then
log "WARNING: Validation had warnings, proceeding with caution"
fi
# Step 2: Deploy images
log "Step 2: Deploying Docker images"
for service in backend frontend; do
log "Deploying app-${service}:${VERSION} to ${ENV}..."
# In a real deployment:
# docker service update --image "app-${service}:${VERSION}" "app-${service}-${ENV}"
log "Simulated deploy of app-${service}:${VERSION}"
done
# Step 3: Run database migrations
log "Step 3: Running database migrations"
# In a real deployment:
# alembic upgrade head
log "Simulated migration run"
# Step 4: Health checks
log "Step 4: Running health checks"
local retries=$HEALTH_CHECK_RETRIES
local healthy=false
while [[ $retries -gt 0 ]]; do
sleep 10
if curl -sf --max-time "$HEALTH_CHECK_TIMEOUT" "${BASE_URL}/health" >>"$LOG_FILE" 2>&1; then
healthy=true
break
fi
retries=$((retries - 1))
log "Health check failed, ${retries} retries remaining..."
done
if [[ "$healthy" == false ]]; then
log "ERROR: Health checks failed after all retries"
write_result "deploy_failed" "Health checks failed after deployment"
log "Initiating automatic rollback..."
return 1
fi
log "Health checks passed"
# Step 5: Smoke tests
log "Step 5: Running smoke tests"
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -x "${script_dir}/smoke-test.sh" ]]; then
if "${script_dir}/smoke-test.sh" --url "$BASE_URL" --output-dir "$OUTPUT_DIR"; then
log "Smoke tests passed"
else
log "WARNING: Smoke tests had failures"
fi
else
log "SKIP: smoke-test.sh not found or not executable"
fi
write_result "deploy_success" "Deployed ${VERSION} to ${ENV} successfully"
log "=== DEPLOY COMPLETE ==="
}
# ─── Canary Deploy ───────────────────────────────────────────────────────────
canary_deploy() {
log "=== CANARY DEPLOY: ${VERSION} to ${ENV} at ${CANARY_WEIGHT}% ==="
# Step 1: Deploy canary instances
log "Step 1: Deploying canary instances (${CANARY_WEIGHT}% traffic)"
# In a real deployment, update nginx/load balancer weights
log "Simulated canary deployment at ${CANARY_WEIGHT}%"
# Step 2: Monitor canary
log "Step 2: Monitoring canary for 60 seconds..."
sleep 10 # Shortened for script execution; real monitoring would be longer
# Step 3: Evaluate canary
log "Step 3: Evaluating canary health"
local canary_healthy=true
if curl -sf --max-time "$HEALTH_CHECK_TIMEOUT" "${BASE_URL}/health" >>"$LOG_FILE" 2>&1; then
log "Canary health check passed"
else
log "Canary health check failed"
canary_healthy=false
fi
if [[ "$canary_healthy" == false ]]; then
log "ERROR: Canary evaluation failed, rolling back"
write_result "canary_failed" "Canary evaluation failed, rollback initiated"
return 1
fi
# Step 4: Ramp to 50%
log "Step 4: Ramping canary to 50%"
sleep 5
# Step 5: Full rollout
log "Step 5: Full rollout to 100%"
deploy
write_result "canary_success" "Canary deployment of ${VERSION} completed successfully"
log "=== CANARY DEPLOY COMPLETE ==="
}
# ─── Determine Base URL ─────────────────────────────────────────────────────
case "$ENV" in
staging) BASE_URL="${STAGING_API_URL:-https://staging.example.com}" ;;
production) BASE_URL="${PRODUCTION_API_URL:-https://api.example.com}" ;;
esac
# ─── Main ────────────────────────────────────────────────────────────────────
log "Deployment script started"
log "Environment: ${ENV}"
log "Version: ${VERSION:-N/A}"
log "Output directory: ${OUTPUT_DIR}"
if [[ "$VALIDATE_ONLY" == true ]]; then
validate
write_result "validation_complete" "Pre-deployment validation finished"
exit $?
fi
if [[ "$ROLLBACK" == true ]]; then
rollback
exit $?
fi
if [[ "$CANARY" == true ]]; then
canary_deploy
exit $?
fi
deploy
#!/usr/bin/env python3
"""
health-check.py -- Validate health endpoints of deployed services.
Checks liveness and readiness endpoints, validates response format,
and writes structured results to a JSON file.
Usage:
python health-check.py --url https://staging.example.com --output-dir ./results/
python health-check.py --url https://api.example.com --retries 5 --timeout 30 --output-dir ./results/
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import URLError
from urllib.request import Request, urlopen
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate health check endpoints for deployed services"
)
parser.add_argument(
"--url",
required=True,
help="Base URL of the service (e.g., https://staging.example.com)",
)
parser.add_argument(
"--output-dir",
default="./health-check-results",
help="Directory for health check result files (default: ./health-check-results)",
)
parser.add_argument(
"--retries",
type=int,
default=3,
help="Number of retries for each health check (default: 3)",
)
parser.add_argument(
"--timeout",
type=int,
default=30,
help="HTTP request timeout in seconds (default: 30)",
)
parser.add_argument(
"--retry-delay",
type=int,
default=5,
help="Delay between retries in seconds (default: 5)",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable verbose output",
)
return parser.parse_args()
def log(message: str, verbose: bool = True) -> None:
if verbose:
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"[{timestamp}] {message}")
def check_endpoint(
base_url: str,
path: str,
timeout: int,
retries: int,
retry_delay: int,
verbose: bool = False,
) -> dict:
"""Check a single health endpoint with retries."""
url = f"{base_url.rstrip('/')}{path}"
result = {
"endpoint": path,
"url": url,
"status": "unknown",
"http_code": None,
"response_time_ms": None,
"response_body": None,
"error": None,
"attempts": 0,
}
for attempt in range(1, retries + 1):
result["attempts"] = attempt
log(f" Checking {url} (attempt {attempt}/{retries})", verbose)
start_time = time.monotonic()
try:
req = Request(url, method="GET")
req.add_header("Accept", "application/json")
req.add_header("User-Agent", "health-check-script/1.0")
with urlopen(req, timeout=timeout) as response:
elapsed_ms = (time.monotonic() - start_time) * 1000
body = response.read().decode("utf-8")
http_code = response.status
result["http_code"] = http_code
result["response_time_ms"] = round(elapsed_ms, 2)
try:
result["response_body"] = json.loads(body)
except json.JSONDecodeError:
result["response_body"] = body[:500]
if http_code == 200:
result["status"] = "healthy"
log(f" OK: {path} returned {http_code} in {elapsed_ms:.0f}ms", verbose)
return result
elif http_code == 503:
result["status"] = "degraded"
log(f" DEGRADED: {path} returned 503", verbose)
else:
result["status"] = "unhealthy"
log(f" WARN: {path} returned {http_code}", verbose)
except URLError as e:
elapsed_ms = (time.monotonic() - start_time) * 1000
result["response_time_ms"] = round(elapsed_ms, 2)
result["error"] = str(e.reason)
result["status"] = "unreachable"
log(f" ERROR: {path} - {e.reason}", verbose)
except Exception as e:
elapsed_ms = (time.monotonic() - start_time) * 1000
result["response_time_ms"] = round(elapsed_ms, 2)
result["error"] = str(e)
result["status"] = "error"
log(f" ERROR: {path} - {e}", verbose)
if attempt < retries:
log(f" Retrying in {retry_delay}s...", verbose)
time.sleep(retry_delay)
return result
def validate_readiness_response(response_body: dict) -> list[str]:
"""Validate the structure of a readiness response."""
issues = []
if not isinstance(response_body, dict):
issues.append("Response is not a JSON object")
return issues
if "status" not in response_body:
issues.append("Missing 'status' field")
if "checks" in response_body:
checks = response_body["checks"]
if isinstance(checks, dict):
for service, status in checks.items():
if status != "ok":
issues.append(f"Dependency '{service}' is not ok: {status}")
else:
issues.append("'checks' field is not a dictionary")
return issues
def main() -> int:
args = parse_args()
# Setup output directory
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
result_file = output_dir / f"health-check-{timestamp}.json"
log_file = output_dir / f"health-check-{timestamp}.log"
log(f"Health check starting for {args.url}", True)
log(f"Retries: {args.retries}, Timeout: {args.timeout}s", args.verbose)
# Define endpoints to check
endpoints = [
{"path": "/health", "name": "Liveness", "required": True},
{"path": "/health/ready", "name": "Readiness", "required": True},
]
results = []
overall_healthy = True
for endpoint in endpoints:
log(f"\nChecking {endpoint['name']} ({endpoint['path']})...", True)
result = check_endpoint(
base_url=args.url,
path=endpoint["path"],
timeout=args.timeout,
retries=args.retries,
retry_delay=args.retry_delay,
verbose=args.verbose,
)
result["name"] = endpoint["name"]
result["required"] = endpoint["required"]
# Validate readiness response structure
if endpoint["path"] == "/health/ready" and result["response_body"]:
if isinstance(result["response_body"], dict):
issues = validate_readiness_response(result["response_body"])
result["validation_issues"] = issues
if issues:
log(f" Validation issues: {issues}", True)
if result["status"] != "healthy" and endpoint["required"]:
overall_healthy = False
results.append(result)
# Summary
healthy_count = sum(1 for r in results if r["status"] == "healthy")
total_count = len(results)
summary = {
"base_url": args.url,
"timestamp": timestamp,
"overall_status": "healthy" if overall_healthy else "unhealthy",
"total_checks": total_count,
"healthy_checks": healthy_count,
"unhealthy_checks": total_count - healthy_count,
"results": results,
}
# Write results
with open(result_file, "w") as f:
json.dump(summary, f, indent=2, default=str)
# Write log
with open(log_file, "w") as f:
f.write(f"Health check results for {args.url}\n")
f.write(f"Timestamp: {timestamp}\n")
f.write(f"Overall: {summary['overall_status']}\n\n")
for r in results:
f.write(f"{r['name']} ({r['endpoint']}): {r['status']}\n")
if r.get("response_time_ms"):
f.write(f" Response time: {r['response_time_ms']}ms\n")
if r.get("error"):
f.write(f" Error: {r['error']}\n")
log(f"\nOverall: {summary['overall_status'].upper()}", True)
log(f"Results written to {result_file}", True)
return 0 if overall_healthy else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env bash
#
# migration-dry-run.sh -- Test Alembic migrations against a staging database
#
# Creates a temporary database clone, runs pending migrations, validates
# the schema, and reports results without affecting the actual database.
#
# Usage:
# ./migration-dry-run.sh --db-url postgresql://user:pass@host:5432/db --output-dir ./results/
#
set -euo pipefail
# ─── Defaults ────────────────────────────────────────────────────────────────
DB_URL=""
OUTPUT_DIR="./migration-dry-run-results"
ALEMBIC_CONFIG="alembic.ini"
CLONE_SUFFIX="_migration_test"
CLEANUP=true
# ─── Parse Arguments ─────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--db-url) DB_URL="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--alembic-config) ALEMBIC_CONFIG="$2"; shift 2 ;;
--no-cleanup) CLEANUP=false; shift ;;
-h|--help)
echo "Usage: $0 --db-url <database-url> --output-dir <dir>"
echo ""
echo "Options:"
echo " --db-url PostgreSQL connection URL for staging database"
echo " --output-dir Directory for migration test result files"
echo " --alembic-config Path to alembic.ini (default: alembic.ini)"
echo " --no-cleanup Do not drop the test database after migration"
exit 0
;;
*) echo "ERROR: Unknown argument: $1" >&2; exit 1 ;;
esac
done
if [[ -z "$DB_URL" ]]; then
echo "ERROR: --db-url is required" >&2
exit 1
fi
# ─── Setup Output ────────────────────────────────────────────────────────────
mkdir -p "$OUTPUT_DIR"
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
LOG_FILE="${OUTPUT_DIR}/migration-dry-run-${TIMESTAMP}.log"
RESULT_FILE="${OUTPUT_DIR}/migration-dry-run-${TIMESTAMP}.json"
log() {
local msg="[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] $1"
echo "$msg" | tee -a "$LOG_FILE"
}
# ─── Parse Database URL ─────────────────────────────────────────────────────
# Extract database name from URL: postgresql://user:pass@host:port/dbname
ORIGINAL_DB=$(echo "$DB_URL" | sed 's|.*/||')
TEST_DB="${ORIGINAL_DB}${CLONE_SUFFIX}"
TEST_DB_URL="${DB_URL%/*}/${TEST_DB}"
# Extract connection details for psql commands (without the database name)
CONN_URL="${DB_URL%/*}/postgres"
log "=== Migration Dry Run ==="
log "Original database: ${ORIGINAL_DB}"
log "Test database: ${TEST_DB}"
log "Alembic config: ${ALEMBIC_CONFIG}"
# ─── Cleanup Function ───────────────────────────────────────────────────────
cleanup() {
if [[ "$CLEANUP" == true ]]; then
log "Cleaning up test database ${TEST_DB}..."
psql "$CONN_URL" -c "DROP DATABASE IF EXISTS \"${TEST_DB}\";" >>"$LOG_FILE" 2>&1 || {
log "WARNING: Failed to drop test database ${TEST_DB}"
}
else
log "Skipping cleanup (--no-cleanup specified). Test database: ${TEST_DB}"
fi
}
trap cleanup EXIT
# ─── Step 1: Create Test Database ────────────────────────────────────────────
log ""
log "Step 1: Creating test database from template..."
# Drop if exists from a previous run
psql "$CONN_URL" -c "DROP DATABASE IF EXISTS \"${TEST_DB}\";" >>"$LOG_FILE" 2>&1
# Create database as a copy of the original
if psql "$CONN_URL" -c "CREATE DATABASE \"${TEST_DB}\" WITH TEMPLATE \"${ORIGINAL_DB}\";" >>"$LOG_FILE" 2>&1; then
log "OK: Test database ${TEST_DB} created from ${ORIGINAL_DB}"
else
log "ERROR: Failed to create test database"
cat > "$RESULT_FILE" <<EOJSON
{
"status": "error",
"error": "Failed to create test database",
"timestamp": "${TIMESTAMP}",
"original_db": "${ORIGINAL_DB}",
"test_db": "${TEST_DB}"
}
EOJSON
exit 1
fi
# ─── Step 2: Check Current Migration State ───────────────────────────────────
log ""
log "Step 2: Checking current migration state..."
CURRENT_HEAD=$(DATABASE_URL="$TEST_DB_URL" alembic -c "$ALEMBIC_CONFIG" current 2>>"$LOG_FILE" || echo "unknown")
log "Current migration head: ${CURRENT_HEAD}"
PENDING_MIGRATIONS=$(DATABASE_URL="$TEST_DB_URL" alembic -c "$ALEMBIC_CONFIG" heads --verbose 2>>"$LOG_FILE" || echo "unknown")
log "Target heads: ${PENDING_MIGRATIONS}"
# ─── Step 3: Run Migrations ─────────────────────────────────────────────────
log ""
log "Step 3: Running pending migrations on test database..."
MIGRATION_OUTPUT=""
MIGRATION_STATUS="success"
MIGRATION_ERROR=""
if MIGRATION_OUTPUT=$(DATABASE_URL="$TEST_DB_URL" alembic -c "$ALEMBIC_CONFIG" upgrade head 2>&1); then
log "OK: Migrations completed successfully"
log "Migration output:"
echo "$MIGRATION_OUTPUT" | tee -a "$LOG_FILE"
else
MIGRATION_STATUS="failed"
MIGRATION_ERROR="$MIGRATION_OUTPUT"
log "ERROR: Migration failed"
log "Error output:"
echo "$MIGRATION_OUTPUT" | tee -a "$LOG_FILE"
fi
# ─── Step 4: Validate Schema ────────────────────────────────────────────────
log ""
log "Step 4: Validating post-migration schema..."
SCHEMA_VALIDATION="unknown"
if [[ "$MIGRATION_STATUS" == "success" ]]; then
# Get list of tables
TABLES=$(psql "$TEST_DB_URL" -t -c "
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
" 2>>"$LOG_FILE" || echo "")
if [[ -n "$TABLES" ]]; then
log "Tables in migrated schema:"
echo "$TABLES" | tee -a "$LOG_FILE"
SCHEMA_VALIDATION="valid"
else
log "WARNING: No tables found after migration"
SCHEMA_VALIDATION="empty"
fi
# Verify alembic version table
ALEMBIC_VERSION=$(psql "$TEST_DB_URL" -t -c "
SELECT version_num FROM alembic_version;
" 2>>"$LOG_FILE" || echo "missing")
log "Alembic version after migration: ${ALEMBIC_VERSION}"
fi
# ─── Step 5: Test Downgrade (Optional) ──────────────────────────────────────
log ""
log "Step 5: Testing migration reversibility..."
DOWNGRADE_STATUS="skipped"
if [[ "$MIGRATION_STATUS" == "success" ]]; then
if DATABASE_URL="$TEST_DB_URL" alembic -c "$ALEMBIC_CONFIG" downgrade -1 >>"$LOG_FILE" 2>&1; then
log "OK: Downgrade by one step succeeded"
DOWNGRADE_STATUS="success"
# Re-upgrade to verify idempotency
if DATABASE_URL="$TEST_DB_URL" alembic -c "$ALEMBIC_CONFIG" upgrade head >>"$LOG_FILE" 2>&1; then
log "OK: Re-upgrade after downgrade succeeded"
else
log "WARNING: Re-upgrade after downgrade failed"
DOWNGRADE_STATUS="re-upgrade-failed"
fi
else
log "WARNING: Downgrade failed (migration may be forward-only)"
DOWNGRADE_STATUS="failed"
fi
fi
# ─── Write Results ───────────────────────────────────────────────────────────
log ""
log "Writing results to ${RESULT_FILE}"
# Escape strings for JSON
MIGRATION_ERROR_ESCAPED=$(echo "$MIGRATION_ERROR" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null | sed 's/^"//;s/"$//' || echo "$MIGRATION_ERROR")
MIGRATION_OUTPUT_ESCAPED=$(echo "$MIGRATION_OUTPUT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null | sed 's/^"//;s/"$//' || echo "$MIGRATION_OUTPUT")
cat > "$RESULT_FILE" <<EOJSON
{
"timestamp": "${TIMESTAMP}",
"original_db": "${ORIGINAL_DB}",
"test_db": "${TEST_DB}",
"migration_status": "${MIGRATION_STATUS}",
"migration_output": "${MIGRATION_OUTPUT_ESCAPED}",
"migration_error": "${MIGRATION_ERROR_ESCAPED}",
"schema_validation": "${SCHEMA_VALIDATION}",
"downgrade_test": "${DOWNGRADE_STATUS}",
"alembic_version": "$(echo "$ALEMBIC_VERSION" | xargs)",
"current_head_before": "${CURRENT_HEAD}",
"log_file": "${LOG_FILE}"
}
EOJSON
# ─── Summary ─────────────────────────────────────────────────────────────────
log ""
log "=== Migration Dry Run Summary ==="
log "Migration: ${MIGRATION_STATUS}"
log "Schema: ${SCHEMA_VALIDATION}"
log "Downgrade: ${DOWNGRADE_STATUS}"
if [[ "$MIGRATION_STATUS" != "success" ]]; then
log "MIGRATION DRY RUN FAILED"
exit 1
fi
log "MIGRATION DRY RUN PASSED"
exit 0
#!/usr/bin/env bash
#
# smoke-test.sh -- Post-deployment smoke tests
#
# Runs a suite of smoke tests against a deployed environment to verify
# critical endpoints are responding correctly after deployment.
#
# Usage:
# ./smoke-test.sh --url https://staging.example.com --output-dir ./results/
#
set -euo pipefail
# ─── Defaults ────────────────────────────────────────────────────────────────
BASE_URL=""
OUTPUT_DIR="./smoke-test-results"
TIMEOUT=10
VERBOSE=false
# ─── Parse Arguments ─────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--url) BASE_URL="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
--verbose) VERBOSE=true; shift ;;
-h|--help)
echo "Usage: $0 --url <base-url> --output-dir <dir>"
echo ""
echo "Options:"
echo " --url Base URL of the deployed service"
echo " --output-dir Directory for smoke test result files"
echo " --timeout HTTP request timeout in seconds (default: 10)"
echo " --verbose Enable verbose output"
exit 0
;;
*) echo "ERROR: Unknown argument: $1" >&2; exit 1 ;;
esac
done
if [[ -z "$BASE_URL" ]]; then
echo "ERROR: --url is required" >&2
exit 1
fi
# ─── Setup Output ────────────────────────────────────────────────────────────
mkdir -p "$OUTPUT_DIR"
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
RESULT_FILE="${OUTPUT_DIR}/smoke-test-${TIMESTAMP}.json"
LOG_FILE="${OUTPUT_DIR}/smoke-test-${TIMESTAMP}.log"
TOTAL=0
PASSED=0
FAILED=0
RESULTS="[]"
log() {
local msg="[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] $1"
echo "$msg" | tee -a "$LOG_FILE"
}
# ─── Test Runner ─────────────────────────────────────────────────────────────
run_test() {
local name="$1"
local method="$2"
local path="$3"
local expected_status="$4"
local expected_body="${5:-}"
TOTAL=$((TOTAL + 1))
local url="${BASE_URL}${path}"
log "TEST: ${name}"
log " ${method} ${url} (expect ${expected_status})"
local http_code
local body
local tmpfile
tmpfile=$(mktemp)
http_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \
-X "$method" \
--max-time "$TIMEOUT" \
"$url" 2>>"$LOG_FILE") || {
log " FAIL: Connection error"
FAILED=$((FAILED + 1))
RESULTS=$(echo "$RESULTS" | python3 -c "
import sys, json
r = json.loads(sys.stdin.read())
r.append({'name': '$name', 'status': 'FAIL', 'reason': 'connection_error'})
print(json.dumps(r))
" 2>/dev/null || echo "$RESULTS")
rm -f "$tmpfile"
return 1
}
body=$(cat "$tmpfile")
rm -f "$tmpfile"
# Check status code
if [[ "$http_code" != "$expected_status" ]]; then
log " FAIL: Expected status ${expected_status}, got ${http_code}"
FAILED=$((FAILED + 1))
RESULTS=$(echo "$RESULTS" | python3 -c "
import sys, json
r = json.loads(sys.stdin.read())
r.append({'name': '$name', 'status': 'FAIL', 'expected': $expected_status, 'actual': $http_code})
print(json.dumps(r))
" 2>/dev/null || echo "$RESULTS")
return 1
fi
# Check body content if specified
if [[ -n "$expected_body" ]]; then
if ! echo "$body" | grep -q "$expected_body"; then
log " FAIL: Response body does not contain '${expected_body}'"
FAILED=$((FAILED + 1))
return 1
fi
fi
log " PASS"
PASSED=$((PASSED + 1))
RESULTS=$(echo "$RESULTS" | python3 -c "
import sys, json
r = json.loads(sys.stdin.read())
r.append({'name': '$name', 'status': 'PASS', 'http_code': $http_code})
print(json.dumps(r))
" 2>/dev/null || echo "$RESULTS")
return 0
}
# ─── Smoke Tests ─────────────────────────────────────────────────────────────
log "=== Smoke Test Suite ==="
log "Base URL: ${BASE_URL}"
log "Timeout: ${TIMEOUT}s"
log ""
# Health endpoints
run_test "Liveness check" GET "/health" 200 "healthy" || true
run_test "Readiness check" GET "/health/ready" 200 "ready" || true
# API endpoints
run_test "API root" GET "/api/v1/" 200 || true
run_test "OpenAPI docs" GET "/docs" 200 || true
run_test "OpenAPI JSON" GET "/openapi.json" 200 || true
# Auth endpoints (expect 401 without credentials)
run_test "Auth required" GET "/api/v1/users/me" 401 || true
# Frontend (if served from same domain)
run_test "Frontend index" GET "/" 200 || true
# Static assets
run_test "Static assets" GET "/static/" 200 || true
# ─── Summary ─────────────────────────────────────────────────────────────────
log ""
log "=== Smoke Test Summary ==="
log "Total: ${TOTAL} Passed: ${PASSED} Failed: ${FAILED}"
# Write results to JSON file
cat > "$RESULT_FILE" <<EOJSON
{
"base_url": "${BASE_URL}",
"timestamp": "${TIMESTAMP}",
"total": ${TOTAL},
"passed": ${PASSED},
"failed": ${FAILED},
"success": $([ "$FAILED" -eq 0 ] && echo "true" || echo "false"),
"results": ${RESULTS}
}
EOJSON
log "Results written to ${RESULT_FILE}"
if [[ $FAILED -gt 0 ]]; then
log "SMOKE TESTS FAILED: ${FAILED} test(s) did not pass"
exit 1
fi
log "ALL SMOKE TESTS PASSED"
exit 0