Complete CI/CD Pipeline — Lint, Test, Build, Deploy

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

🧠 Test Yourself

The pipeline has: Lint (15s) → [Backend Tests (90s) + Frontend Tests (30s) in parallel] → Build (180s) → Deploy (60s). What is the total pipeline duration?