A complete CI/CD pipeline runs automatically on every commit, giving the team immediate feedback on code quality. The pipeline has distinct stages with different speed profiles: linting and type-checking run in under 30 seconds, tests in 2-5 minutes, building Docker images in 3-5 minutes, and deployment in under 2 minutes. The fastest checks run first — a lint error should fail the pipeline in 10 seconds, not after waiting 10 minutes for tests to run.
Complete CI/CD Pipeline
# .github/workflows/cicd.yml
name: CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# ── 1. Lint + type check (fastest — fail early) ────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: pip install ruff mypy
- run: ruff check app/ # Python linting
- run: mypy app/ --ignore-missing-imports
- run: npm ci
working-directory: frontend
- run: npm run lint
working-directory: frontend
# ── 2. Backend tests ────────────────────────────────────────────────────────
test-backend:
name: Backend Tests
runs-on: ubuntu-latest
needs: [lint]
services:
postgres:
image: postgres:16
env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: test }
ports: ["5432:5432"]
options: --health-cmd pg_isready --health-interval 10s --health-retries 5
env:
DATABASE_URL: postgresql://test:test@localhost/test
SECRET_KEY: test-secret-key-32-chars-minimum!
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt
- run: pytest --cov=app --cov-report=xml --cov-fail-under=80
# ── 3. Frontend tests ────────────────────────────────────────────────────────
test-frontend:
name: Frontend Tests
runs-on: ubuntu-latest
needs: [lint]
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npx vitest run --coverage
# ── 4. Build Docker images ─────────────────────────────────────────────────
build:
name: Build Images
runs-on: ubuntu-latest
needs: [test-backend, test-frontend]
if: github.ref == 'refs/heads/main' # only on main branch
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.backend
push: true
tags: ghcr.io/${{ github.repository }}/backend:${{ github.sha }}
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile.frontend
push: true
tags: ghcr.io/${{ github.repository }}/frontend:${{ github.sha }}
# ── 5. Deploy ──────────────────────────────────────────────────────────────
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [build]
environment: production
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: deploy
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /home/deploy/blog-app
export IMAGE_TAG=${{ github.sha }}
docker compose pull
docker compose up -d --no-deps backend frontend
sleep 10
curl -f http://localhost/api/health || exit 1
Note: The pipeline uses
needs to create a dependency graph: lint runs first (fastest); backend and frontend tests run in parallel after lint passes; build only runs if both test jobs pass; deploy only runs after build. This fail-fast design means a lint error is caught in 15 seconds rather than after waiting 10 minutes for tests. The total pipeline duration is dominated by the longest parallel track, not the sum of all stages.Tip: Cache dependencies between CI runs to avoid re-downloading packages on every commit. GitHub Actions has a built-in cache action:
uses: actions/cache@v4 with the key ${{ hashFiles('requirements.txt') }}. If requirements.txt hasn’t changed, the pip install step completes in ~2 seconds instead of ~60 seconds. Apply the same to node_modules keyed by package-lock.json. This alone can reduce CI time by 50-70% for typical commits.Warning: Never embed secrets directly in GitHub Actions workflow files — they appear in the repository’s git history. Use GitHub Secrets (
${{ secrets.MY_SECRET }}) which are encrypted at rest, masked in logs, and not accessible to forked pull requests. For the SSH private key used for deployment, generate a dedicated deploy key (a key pair used only for CI deployment) rather than using a developer’s personal SSH key — if the developer leaves the team, revoking the deploy key does not affect their other access.Ruff Configuration
# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"
select = ["E", "F", "W", "I", "N", "UP", "B", "S"]
ignore = ["S101"] # allow assert statements (used in tests)
[tool.mypy]
python_version = "3.12"
strict = false
ignore_missing_imports = true
disallow_untyped_defs = true