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: productionadds 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:
- Create
stagingandproductionenvironments with the configuration shown - Configure branch protection for
main - 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:
- Phase 2 runs in parallel (changelog + readiness)
- Phase 3 is sequential
- 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: falsefor 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
- GitHub Environments — Complete configuration
- GitHub Branch Protection — Protection rules
- GitHub Actions: concurrency — cancel-in-progress
- GitHub Actions: needs — Dependencies between jobs
- The Twelve-Factor App: Backing services — Why staging and prod should differ
- Argo Rollouts — For advanced deployments (canary, blue-green)