Module 6: Project — Complete CI/CD Pipeline with Claude Code

Implementation: Stages 4-6 (Deployment)

Implementation: Stages 4-6 (Deployment)

Overview

This capsule builds the post-merge half of the pipeline: from merge to main to production served to users. You implement automatic changelog, readiness validation, deploy to staging with validation, a human approval gate, and deploy to production with a notification to the team.

It's the highest-risk phase (the changes affect real infrastructure) and therefore the one that requires the most discipline. Each step has a check before advancing to the next. The human gate before production is the automatic flow's only manual intervention.

By the end, you'll have Phases 2-3 working: the code merged to main automatically reaches staging, and with a human click, production.


The Workflow Structure

# .github/workflows/staged-deployment.yml
name: Staged Deployment (Phases 2-3)

on:
  push:
    branches: [main]

permissions:
  contents: write       # for tags
  pull-requests: write  # for comments
  deployments: write    # to register deployments
  issues: write         # to create a changelog issue if applicable

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false  # NEVER cancel deploys

env:
  PYTHON_VERSION: '3.11'

jobs:
  # ============================================
  # PHASE 2: Pre-Deployment
  # ============================================
  
  generate-changelog:
    runs-on: ubuntu-latest
    outputs:
      changelog_file: ${{ steps.gen.outputs.file }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      
      - run: pip install anthropic requests
      
      - name: Generate changelog
        id: gen
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          NEW_TAG: ${{ github.sha }}
        run: |
          python scripts/generate_changelog.py
          FILE=$(ls CHANGELOG_*.md | head -1)
          echo "file=$FILE" >> $GITHUB_OUTPUT
      
      - uses: actions/upload-artifact@v4
        with:
          name: changelog
          path: CHANGELOG_*.md
          retention-days: 30

  readiness-validation:
    runs-on: ubuntu-latest
    outputs:
      status: ${{ steps.validate.outputs.status }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      
      - run: pip install anthropic requests
      
      - name: Run readiness validation
        id: validate
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          TARGET_ENV: production
        run: |
          python scripts/validate_readiness.py
          if [ $? -eq 0 ]; then
            echo "status=ready" >> $GITHUB_OUTPUT
          else
            echo "status=blocked" >> $GITHUB_OUTPUT
            exit 1
          fi
      
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: readiness-report
          path: readiness_report.json

  # ============================================
  # PHASE 3: Deployment
  # ============================================

  deploy-staging:
    needs: [generate-changelog, readiness-validation]
    runs-on: ubuntu-latest
    environment: staging
    outputs:
      previous_release: ${{ steps.capture.outputs.previous }}
      new_release: ${{ steps.deploy.outputs.new }}
      deploy_url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v4
      
      - name: Capture previous release
        id: capture
        run: |
          PREV=$(./scripts/get_current_release.sh staging)
          echo "previous=$PREV" >> $GITHUB_OUTPUT
      
      - name: Deploy to staging
        id: deploy
        env:
          DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
        run: |
          NEW=$(./scripts/deploy.sh staging)
          echo "new=$NEW" >> $GITHUB_OUTPUT
          echo "url=https://staging.example.com" >> $GITHUB_OUTPUT
      
      - name: Smoke tests on staging
        run: ./scripts/smoke_tests.sh https://staging.example.com

  validate-staging:
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      
      - run: pip install anthropic requests
      
      - uses: actions/download-artifact@v4
        with: { name: changelog }
      
      - name: Validate staging with Claude
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          METRICS_API_URL: ${{ secrets.METRICS_API_URL }}
          METRICS_API_TOKEN: ${{ secrets.METRICS_API_TOKEN }}
        run: python scripts/validate_staging.py

  deploy-production:
    needs: validate-staging
    runs-on: ubuntu-latest
    environment: production  # ← required reviewers
    outputs:
      previous_release: ${{ steps.capture.outputs.previous }}
      new_release: ${{ steps.deploy.outputs.new }}
    steps:
      - uses: actions/checkout@v4
      
      - name: Capture previous release
        id: capture
        run: |
          PREV=$(./scripts/get_current_release.sh production)
          echo "previous=$PREV" >> $GITHUB_OUTPUT
      
      - name: Deploy to production
        id: deploy
        env:
          DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
        run: |
          NEW=$(./scripts/deploy.sh production)
          echo "new=$NEW" >> $GITHUB_OUTPUT
      
      - name: Smoke tests on production
        run: ./scripts/smoke_tests.sh https://app.example.com
      
      - name: Notify team
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        run: python scripts/post_deploy_notification.py

What matters:

  • concurrency: cancel-in-progress: false — deploys are never canceled halfway
  • Phase 2 is parallel (changelog + readiness simultaneously)
  • Phase 3 is sequential: staging → validate → approval → production
  • environment: production adds the human gate
  • Capture of previous_release before the deploy (for rollback in Phase 4)

Reused Scripts

Most scripts come from previous modules:

generate_changelog.py     ← Module 4 capsule 02
validate_readiness.py     ← Module 4 capsule 03
validate_staging.py       ← Module 4 capsule 04
post_deploy_notification.py ← Module 4 capsule 05
deploy.sh                 ← Module 4 capsule 05 (mock)
smoke_tests.sh            ← Module 4 capsule 05 (mock)
get_current_release.sh    ← Module 5 capsule 04

Important point: don't rewrite anything. Reuse what you already built. If the scripts are good in previous modules, this pipeline is assembly, not new construction.


The Missing Script: get_current_release.sh

This script is new in this module. Adapt it to your platform:

#!/bin/bash
# scripts/get_current_release.sh
# Returns the release currently deployed in an environment

set -euo pipefail

ENV="${1:?'Usage: get_current_release.sh <env>'}"

# Adapt according to your platform:

# Kubernetes
# CURRENT=$(kubectl get deployment app -n $ENV -o jsonpath='{.spec.template.metadata.labels.version}')

# AWS ECS
# CURRENT=$(aws ecs describe-services --cluster $ENV --services app \
#   --query 'services[0].taskDefinition' --output text | rev | cut -d':' -f1 | rev)

# Heroku
# CURRENT=$(heroku releases --app "app-$ENV" --json | jq -r '.[0].version')

# Docker Compose / simple
# CURRENT=$(docker inspect app-$ENV --format '{{ index .Config.Labels "version" }}')

# Mock for demo (do NOT use in real production)
CURRENT="v$(date +%s)"

echo "$CURRENT"

Replace the mock implementation with the real logic of your platform. It's the only infrastructure-specific place you have to adapt.


Configuring the Environments in GitHub

For the pipeline to work, configure the environments before pushing the workflow:

Environment: staging

Settings → Environments → New environment → "staging"

Configuration:
- Required reviewers: (empty — automatic)
- Wait timer: 0 minutes
- Deployment branches: main only
- Variables:
  - STAGING_URL: https://staging.example.com
- Secrets:
  - STAGING_DATABASE_URL: [value]
  - STAGING_API_TOKENS: [values]

Environment: production

Settings → Environments → New environment → "production"

Configuration:
- Required reviewers: [tech_lead, sre_lead] ← CRITICAL
- Wait timer: 0 minutes (or 5 if you want cooling-off)
- Deployment branches: main only
- Variables:
  - PROD_URL: https://app.example.com
- Secrets:
  - PROD_DATABASE_URL: [value different from staging's]
  - PROD_API_TOKENS: [real prod values]

Without these configurations, the workflow fails or (worse) deploys to production without a human gate.


Branch Protection for main

Settings → Branches → Branch protection rules → main

Configuration:
☑ Require a pull request before merging
   ☑ Require approvals: 1
   ☑ Dismiss stale pull request approvals when new commits are pushed
   ☑ Require review from Code Owners (optional)

☑ Require status checks to pass before merging
   ☑ Require branches to be up to date before merging
   Required status checks:
     ☑ tests-and-linting
     ☑ security-scan
     ☑ pr-review-summary

☑ Require conversation resolution before merging

☑ Restrict who can push to matching branches
   - Only CI bots or emergency-team

Without branch protection, anyone with write access can push directly to main, skipping the whole pipeline. Branch protection is the basic line of defense.


Complete Flow: What Happens on Merge

T+0:    Developer merges PR → push to main
T+0:    The staged-deployment workflow triggers

T+0-3min:  Phase 2 parallel:
           - generate-changelog (~1 min)
           - readiness-validation (~2 min)

T+3min:    If both passed, Phase 3 begins:
T+3-7min:  deploy-staging
T+7-9min:  smoke-tests-staging
T+9-10min: validate-staging (Claude analyzes metrics + changelog)

T+10min:   GitHub shows "Waiting for approval to deploy: production"
           Notification to reviewers

T+10min - variable:  Approval wait
                     - Reviewer sees the PR with the staging analysis
                     - Click "Approve"
                     - Or click "Reject" + comment

T+approval+0:  deploy-production begins
T+approval+4min: Deploy complete
T+approval+6min: Smoke tests in production
T+approval+7min: Notification to the team

TOTAL HAPPY PATH TIME: ~17 min + approval wait

The Pre-Promotion Analysis

When the human reviewer is about to approve, they already have the comment from the validate-staging job on the PR. That gives them context:

What the reviewer sees BEFORE approving:

1. The original PR with its description
2. Phase 1 comments (code review, security)
3. Metrics and status of the workflow run
4. The comment from the validate-staging job:
   - Claude's recommendation (promote/hold/investigate)
   - Identified concerns
   - A specific checklist before approving
5. The "Approve and deploy" button

The reviewer doesn't approve blindly. They have rich context. Capsule 04 of Module 4 details validate_staging.py.


Common Pitfalls

1. cancel-in-progress: true for deploys

Symptom: You merge 2 PRs in a row. The second deploy cancels the first halfway. Inconsistent state in production.

Why it happens: Configuration inherited from Phase 1 (where we do want to cancel).

How to fix it: cancel-in-progress: false for deploys always.

2. Forgetting to capture previous_release

Symptom: Phase 4 (capsule 05) needs to revert, but you don't know to which version.

Why it happens: The capture step isn't before the deploy.

How to fix it: An explicit Capture previous release step BEFORE the deploy. The job output propagated for later use.

3. Same secret in staging and production

Symptom: A bug in staging uses a production API key and affects real systems.

Why it happens: For simplicity they used the same secret.

How to fix it: Different secrets per environment. Stripe test keys in staging, live keys in production. Same principle for everything.

4. Approval gate without notifying reviewers

Symptom: The production job waits 2 hours because no reviewer saw the notification.

Why it happens: Reviewers not configured to receive GitHub notifs.

How to fix it: In Settings → Notifications → enable "Workflow runs". Also add a Slack notification if the team prefers.

5. Not validating that the deploy actually happened

Symptom: The deploy.sh step returns 0 but the deploy actually failed.

Why it happens: The script doesn't validate with a post-deploy health check.

How to fix it: smoke_tests.sh after the deploy, before continuing. If the smoke tests fail, the job fails and the approval isn't triggered.


Diagnosis

Question 1: Does your workflow have `concurrency: cancel-in-progress: false` for deploys?

If not, consecutive deploys can cancel each other halfway — dangerous.

Question 2: Do you capture previous_release before the deploy?

Without this, you can't revert programmatically from Phase 4.

Question 3: Are your secrets separated between staging and production?

If they're the same, a bug in staging can touch real production.

Question 4: Did you configure required reviewers in the "production" environment?

Without this, there's no human gate — the deploy continues automatically.

Question 5: Do branch protection rules prevent direct pushes to main?

Without this, someone can skip the whole pipeline by pushing directly. Loss of control.


Exercises

Exercise 1: Configure environments and branch protection (Easy)

Before implementing the workflow:

  1. Create staging and production environments with the configuration shown
  2. Configure branch protection for main
  3. Verify you can only merge via a PR

Exercise 2: Implement Phase 2 + 3 (Medium)

Implement the 5 jobs (generate-changelog, readiness, deploy-staging, validate-staging, deploy-production). Verify:

  1. Phase 2 runs in parallel (changelog + readiness)
  2. Phase 3 is sequential
  3. The approval gate activates before production

Test it end-to-end by merging a test PR.

Exercise 3: Implement a real get_current_release.sh (Hard)

Replace the mock with the real logic of your platform:

  • If you use Kubernetes: kubectl + labels
  • If you use Heroku: the heroku releases API
  • If you use another: the corresponding API/CLI

Verify that after a deploy, get_current_release.sh production returns the new release.


Summary

  • Phase 2 parallel: changelog + readiness simultaneously
  • Phase 3 sequential: staging → validate → approval → production
  • cancel-in-progress: false for deploys (never cancel)
  • Capture the previous release BEFORE the deploy — necessary for rollback (Phase 4)
  • Environments configured: staging without reviewers, production with required reviewers
  • Separate secrets per environment
  • Branch protection on main prevents skipping the pipeline
  • Reuse of scripts from Modules 4 and 5 — don't rewrite

Next capsule: 05 — Failure paths and observability. Phases 2-3 work on the happy path. But you have to cover the cases where something fails: deploy to staging fails, metrics degrade in production, approval rejected. Capsule 05 covers all the failure modes with the monitoring + auto-rollback system from Module 5.


Additional Resources

  1. GitHub Environments — Complete configuration
  2. GitHub Branch Protection — Protection rules
  3. GitHub Actions: concurrency — cancel-in-progress
  4. GitHub Actions: needs — Dependencies between jobs
  5. The Twelve-Factor App: Backing services — Why staging and prod should differ
  6. Argo Rollouts — For advanced deployments (canary, blue-green)