Module 8: Capstone Project — Production AI Pipeline

3. Full Pipeline Implementation

Overview

This is the module's central lesson — and probably the most important one in the whole guide. Here you implement the complete pipeline in a single YAML file: lint → test → AI checks → Docker build → push → deploy staging → smoke tests → approve → deploy production. It's not a sketch or a diagram — it's the functional workflow you'll run in your repository.

The YAML is long (~200 lines), but every section corresponds to a stage you already know from previous modules. What's new is how they connect: the outputs that flow from one job to another, the conditions that determine what runs and what gets skipped, and the error handling that allows recovery without manual intervention.


The complete pipeline

# .github/workflows/production-pipeline.yml
name: Production AI Pipeline

on:
  push:
    branches: [main]
    paths-ignore:
      - "*.md"
      - "docs/**"
      - ".github/workflows/nightly-*"
  pull_request:
    branches: [main]
  workflow_dispatch:

env:
  PYTHON_VERSION: "3.12"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

# ══════════════════════════════════════════════════════════
# STAGE 1: CI CHECKS (parallel)
# ══════════════════════════════════════════════════════════
jobs:
  lint:
    name: "Lint & Format"
    runs-on: ubuntu-latest
    timeout-minutes: 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install linting tools
        run: pip install ruff

      - name: Check code style
        run: ruff check src/

      - name: Check formatting
        run: ruff format --check src/

  test:
    name: "Tests"
    runs-on: ubuntu-latest
    timeout-minutes: 15
    outputs:
      coverage: ${{ steps.coverage.outputs.pct }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run test suite
        run: pytest tests/ -v --tb=short --cov=src --cov-report=term

      - name: Extract coverage
        id: coverage
        run: |
          COV=$(pytest tests/ --cov=src --cov-report=term 2>/dev/null | \
            grep "TOTAL" | awk '{print $NF}' | tr -d '%' || echo "0")
          echo "pct=$COV" >> $GITHUB_OUTPUT

  ai-checks:
    name: "AI Quality Checks"
    runs-on: ubuntu-latest
    timeout-minutes: 15
    outputs:
      cost-estimate: ${{ steps.cost.outputs.estimate }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Prompt regression check
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: python scripts/prompt_regression.py --mode check

      - name: Cost estimation
        id: cost
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: |
          ESTIMATE=$(python scripts/cost_estimation.py --threshold 0.10 2>&1 | \
            grep "estimated_cost" | cut -d= -f2 || echo "0.00")
          echo "estimate=$ESTIMATE" >> $GITHUB_OUTPUT

# ══════════════════════════════════════════════════════════
# STAGE 2: DOCKER BUILD & PUSH (only on main)
# ══════════════════════════════════════════════════════════
  docker:
    name: "Docker Build & Push"
    needs: [lint, test, ai-checks]
    runs-on: ubuntu-latest
    timeout-minutes: 15
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    outputs:
      image-tag: ${{ steps.meta.outputs.tag }}
      image-full: ${{ steps.meta.outputs.full }}

    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Generate image metadata
        id: meta
        run: |
          SHORT_SHA="${GITHUB_SHA::7}"
          TAG="sha-${SHORT_SHA}"
          FULL="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}"
          echo "tag=$TAG" >> $GITHUB_OUTPUT
          echo "full=$FULL" >> $GITHUB_OUTPUT
          echo "Image: $FULL"

      - name: Build Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ steps.meta.outputs.full }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

# ══════════════════════════════════════════════════════════
# STAGE 3: DEPLOY STAGING
# ══════════════════════════════════════════════════════════
  deploy-staging:
    name: "Deploy Staging"
    needs: docker
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment:
      name: staging
      url: https://staging.your-app.com

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to staging
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          HOST: ${{ vars.STAGING_HOST }}
        run: |
          echo "$SSH_KEY" > /tmp/key && chmod 600 /tmp/key
          ssh -o StrictHostKeyChecking=no -i /tmp/key deploy@$HOST \
            "cd /app && IMAGE_TAG=${{ needs.docker.outputs.image-tag }} docker compose up -d --pull always"
          rm /tmp/key

      - name: Wait for startup
        run: sleep 15

# ══════════════════════════════════════════════════════════
# STAGE 4: SMOKE TESTS
# ══════════════════════════════════════════════════════════
  smoke-tests:
    name: "Smoke Tests"
    needs: deploy-staging
    runs-on: ubuntu-latest
    timeout-minutes: 5

    steps:
      - name: Health check
        run: |
          for i in $(seq 1 20); do
            STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
              https://staging.your-app.com/health 2>/dev/null || echo "000")
            if [ "$STATUS" = "200" ]; then
              echo "Staging healthy after $((i*3))s"
              exit 0
            fi
            echo "Attempt $i/20 — HTTP $STATUS"
            sleep 3
          done
          echo "::error::Staging health check failed"
          exit 1

      - name: Prompt smoke test
        run: |
          RESPONSE=$(curl -sf -X POST https://staging.your-app.com/api/chat \
            -H "Content-Type: application/json" \
            -d '{"message":"ping","max_tokens":10}' \
            --max-time 30 2>/dev/null)

          if [ -z "$RESPONSE" ]; then
            echo "::error::AI endpoint returned empty response"
            exit 1
          fi
          echo "AI endpoint responding: ${RESPONSE:0:100}..."

# ══════════════════════════════════════════════════════════
# STAGE 5: APPROVAL GATE
# ══════════════════════════════════════════════════════════
  approve:
    name: "Production Approval"
    needs: smoke-tests
    runs-on: ubuntu-latest
    environment:
      name: production

    steps:
      - name: Approval confirmed
        run: |
          echo "Production deploy approved by ${{ github.actor }}"
          echo "Image: ${{ needs.docker.outputs.image-tag || 'inherited' }}"

# ══════════════════════════════════════════════════════════
# STAGE 6: DEPLOY PRODUCTION
# ══════════════════════════════════════════════════════════
  deploy-production:
    name: "Deploy Production"
    needs: [approve, docker]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      previous-tag: ${{ steps.current.outputs.tag }}
      deploy-status: ${{ steps.verify.outcome }}

    steps:
      - uses: actions/checkout@v4

      - name: Get current production version
        id: current
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          HOST: ${{ vars.PRODUCTION_HOST }}
        run: |
          echo "$SSH_KEY" > /tmp/key && chmod 600 /tmp/key
          CURRENT=$(ssh -o StrictHostKeyChecking=no -i /tmp/key deploy@$HOST \
            "cd /app && docker compose ps --format '{{.Image}}'" 2>/dev/null | \
            head -1 | cut -d: -f2 || echo "unknown")
          rm /tmp/key
          echo "tag=${CURRENT}" >> $GITHUB_OUTPUT
          echo "Current production: ${CURRENT}"

      - name: Deploy new version
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          HOST: ${{ vars.PRODUCTION_HOST }}
        run: |
          echo "$SSH_KEY" > /tmp/key && chmod 600 /tmp/key
          ssh -o StrictHostKeyChecking=no -i /tmp/key deploy@$HOST \
            "cd /app && IMAGE_TAG=${{ needs.docker.outputs.image-tag }} docker compose up -d --pull always"
          rm /tmp/key

      - name: Verify deployment
        id: verify
        continue-on-error: true
        run: |
          sleep 15
          for i in $(seq 1 30); do
            STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
              https://your-app.com/health 2>/dev/null || echo "000")
            if [ "$STATUS" = "200" ]; then
              echo "Production healthy after $((15 + i*2))s"
              exit 0
            fi
            echo "Attempt $i/30 — HTTP $STATUS"
            sleep 2
          done
          echo "::error::Production health check failed"
          exit 1

      - name: Rollback on failure
        if: steps.verify.outcome == 'failure'
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          HOST: ${{ vars.PRODUCTION_HOST }}
        run: |
          PREV="${{ steps.current.outputs.tag }}"
          if [ "$PREV" = "unknown" ] || [ -z "$PREV" ]; then
            echo "::error::Cannot rollback — no previous version (first deploy?)"
            exit 1
          fi
          echo "ROLLBACK: Reverting to $PREV"
          echo "$SSH_KEY" > /tmp/key && chmod 600 /tmp/key
          ssh -o StrictHostKeyChecking=no -i /tmp/key deploy@$HOST \
            "cd /app && IMAGE_TAG=$PREV docker compose up -d --pull always"
          rm /tmp/key
          sleep 15
          if curl -sf https://your-app.com/health > /dev/null 2>&1; then
            echo "Rollback OK — running $PREV"
          else
            echo "::error::CRITICAL — Rollback also failed!"
          fi
          exit 1

# ══════════════════════════════════════════════════════════
# NOTIFICATIONS
# ══════════════════════════════════════════════════════════
  notify:
    name: "Notifications"
    needs: [lint, test, ai-checks, docker, deploy-production]
    runs-on: ubuntu-latest
    if: always()

    steps:
      - name: Determine final status
        id: status
        run: |
          if [ "${{ needs.deploy-production.result }}" = "success" ]; then
            echo "result=deploy-success" >> $GITHUB_OUTPUT
            echo "message=🚀 Production deploy successful" >> $GITHUB_OUTPUT
          elif [ "${{ needs.deploy-production.result }}" = "failure" ]; then
            echo "result=deploy-failed" >> $GITHUB_OUTPUT
            echo "message=❌ Production deploy failed — rollback executed" >> $GITHUB_OUTPUT
          elif [ "${{ contains(needs.*.result, 'failure') }}" = "true" ]; then
            echo "result=ci-failed" >> $GITHUB_OUTPUT
            echo "message=❌ CI checks failed" >> $GITHUB_OUTPUT
          else
            echo "result=success" >> $GITHUB_OUTPUT
            echo "message=✅ Pipeline completed" >> $GITHUB_OUTPUT
          fi

      - name: Notify Slack
        if: steps.status.outputs.result != 'success'
        uses: slackapi/slack-github-action@v2.0.0
        with:
          webhook-type: incoming-webhook
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          payload: |
            {
              "text": "${{ steps.status.outputs.message }}",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "${{ steps.status.outputs.message }}\n*Branch:* ${{ github.ref_name }}\n*Commit:* `${{ github.sha }}`\n*Author:* ${{ github.actor }}\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"
                  }
                }
              ]
            }

      - name: Pipeline summary
        if: always()
        run: |
          echo "## Production AI Pipeline" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "| Stage | Result |" >> $GITHUB_STEP_SUMMARY
          echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
          echo "| Lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Test | ${{ needs.test.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| AI Checks | ${{ needs.ai-checks.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Docker | ${{ needs.docker.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Deploy | ${{ needs.deploy-production.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY

The pipeline's anatomy

Why paths-ignore?

paths-ignore:
  - "*.md"
  - "docs/**"

If you only change documentation, you don't need to run the complete pipeline. paths-ignore avoids unnecessary runs that burn compute and API calls.

Why timeout-minutes on every job?

timeout-minutes: 15

Without a timeout, a job can keep running indefinitely (an API call that doesn't respond, an infinite loop). For AI pipelines this is especially important because LLM API calls can hang with no timeout.

Why cache: pip?

uses: actions/setup-python@v5
with:
  cache: pip

Without a cache, every job downloads and installs all the dependencies from scratch. With a cache, the dependencies get stored between runs. The difference: ~30s with a cache vs ~2min without one.

Why continue-on-error: true on the health check?

- name: Verify deployment
  id: verify
  continue-on-error: true

Without continue-on-error, if the health check fails, the job stops immediately. With continue-on-error, the step can fail but the job continues to the next step — where you can do the rollback.


The execution flow

The happy path (everything works)

1. lint ✅        (22s)
2. test ✅        (1m 45s)     ← Parallel with lint
3. ai-checks ✅   (2m 15s)     ← Parallel with lint and test
4. docker ✅      (3m 10s)     ← Waits for 1-2-3
5. deploy-staging ✅ (45s)     ← Waits for 4
6. smoke-tests ✅  (30s)       ← Waits for 5
7. approve ✅      (manual)    ← Waits for 6
8. deploy-prod ✅  (1m 30s)    ← Waits for 7
9. notify: "🚀 Success"       ← Waits for 8

Total (without the approval): ~8 minutes

The failure path (CI fails)

1. lint ✅        (22s)
2. test ❌        (45s, failure)  ← The tests fail
3. ai-checks ✅   (2m 15s)
4. docker ⚪      (skipped)       ← It doesn't run because test failed
5-8. ⚪           (skipped)
9. notify: "❌ CI failed"

Total: ~2 minutes

The rollback path (the deploy fails)

1-7. ✅           (everything ok up to the approval)
8. deploy-prod:
   - Deploy new version ✅
   - Health check ❌ (the app doesn't respond)
   - Rollback to previous ✅
   - exit 1 (the job fails)
9. notify: "❌ Deploy failed — rollback executed"

Total: ~10 minutes

Comparisons

A minimal vs a production-grade pipeline

AspectMinimalProduction-grade
CI ChecksTests onlyLint + test + AI checks
DockerA local buildBuild + push + cache + GHCR
DeployStaging onlyStaging → approval → production
Error handlingNoneAutomatic rollback
NotificationsThe default emailSlack with context
YAML lines~30~200
Time~2 min~8 min (without the approval)

Troubleshooting

"The Docker job gets skipped on every run"

Cause: The condition if: github.ref == 'refs/heads/main' && github.event_name == 'push' isn't met. You're probably running from a PR or a workflow_dispatch.

Solution: The Docker build only runs on a push to main. For testing, use workflow_dispatch with an adjusted condition or push to main.

"The deploy-production job doesn't have access to the image-tag"

Cause: needs doesn't include the docker job that produces the output.

Solution: Verify that deploy-production has needs: [approve, docker] — it needs docker to access needs.docker.outputs.image-tag.

"The approval gate doesn't show up"

Cause: The production environment doesn't have required reviewers configured.

Solution: In your repo → Settings → Environments → production → Required reviewers → add at least one reviewer.

"The pipeline takes more than 20 minutes"

Cause: The AI checks or the Docker build are slow.

Solution:

  • AI checks: Reduce the number of prompt regression test cases
  • Docker build: Verify that the layer cache is working (cache-from: type=gha)
  • Tests: Use markers to separate the fast tests from the slow ones

Exercises

Exercise 1: Identify the pipeline's outputs

Read the complete YAML and list every output each job produces.

See solution
JobOutputValue
testcoverageThe coverage percentage (e.g. "85")
ai-checkscost-estimateThe estimated cost (e.g. "0.05")
dockerimage-tagThe image's tag (e.g. "sha-abc1234")
dockerimage-fullThe complete image (e.g. "ghcr.io/org/app:sha-abc1234")
deploy-productionprevious-tagThe previous version's tag
deploy-productiondeploy-statusThe health check's result

Exercise 2: Add a security step

Add a trivy image scanning step to the Docker job, before the push.

See solution
  docker:
    steps:
      # ... (the build steps)

      - name: Security scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ steps.meta.outputs.full }}
          format: table
          exit-code: 1
          severity: CRITICAL,HIGH

      # the push goes after the scan
      - name: Push image
        if: success()
        run: docker push ${{ steps.meta.outputs.full }}

With exit-code: 1, the step fails if it finds CRITICAL or HIGH vulnerabilities, blocking the push.

Exercise 3: Modify the pipeline for PRs

The current pipeline only builds Docker on a push to main. Modify it so that on PRs, the CI checks run but it also generates a summary with the results.

See solution

Add a summary job that runs on PRs:

  pr-summary:
    name: "PR Summary"
    needs: [lint, test, ai-checks]
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - name: Generate PR summary
        run: |
          echo "## PR Check Results" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "| Check | Result |" >> $GITHUB_STEP_SUMMARY
          echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
          echo "| Lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Tests | ${{ needs.test.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| AI Checks | ${{ needs.ai-checks.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Coverage | ${{ needs.test.outputs.coverage }}% |" >> $GITHUB_STEP_SUMMARY
          echo "| Est. Cost | \$${{ needs.ai-checks.outputs.cost-estimate }} |" >> $GITHUB_STEP_SUMMARY

This job only runs on PRs and generates a summary visible in the Actions UI.


Summary

  • The complete pipeline in one YAML: lint → test → AI checks → Docker → staging → smoke → approve → production
  • Parallel CI: lint, test, and ai-checks run at the same time (~3 min vs ~7 min sequentially)
  • A conditional Docker build: it only builds on a push to main, not on PRs
  • The data flow with outputs: image-tag flows from docker → deploy-staging → deploy-production
  • An integrated rollback: the health check fails → redeploy the previous version → notify
  • Smart notifications: only failures + a production deploy success
  • The step summary: a report visible in the Actions UI without downloading artifacts
  • ~200 lines of YAML that cover the complete commit-to-production cycle

Additional resources

  1. GitHub Actions — Complete Workflow Reference - The syntax reference
  2. docker/build-push-action - The official action for a Docker build
  3. GitHub Actions — Environments - Approval gates
  4. GitHub Actions — Job Outputs - The data flow between jobs
  5. aquasecurity/trivy-action - Image security scanning
  6. GitHub Actions — Step Summary - Generating summaries