Module 4: Deployment Automation
Staging → Production Flow with Approval Gates
Staging → Production Flow with Approval Gates
Overview
The two previous capsules covered pre-deploy: changelog (capsule 02) and readiness validation (capsule 03). This capsule covers the promotion flow of code from staging to production with appropriate approval gates. It's where the pipeline goes from "tested code" to "code serving real traffic".
The central question: which parts of the deployment are safe to automate and which require human intervention? The answer isn't uniform — it depends on the risk of each step. You'll learn to design the flow, where to place approval gates, how to automate staging completely, and why production almost always should have a human click.
By the end, you'll have a workflow that automatically deploys to staging after merge, validates the staging environment, and waits for human approval before promoting to production.
The Mental Model
┌──────────────────────────────────────────────────────┐
│ FULL AUTOMATION (no humans) │
│ ✅ Build, lint, tests, security scan │
│ ✅ Deploy to staging │
│ ✅ Post-deploy smoke tests on staging │
│ ✅ Staging health check metrics │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ HUMAN GATE │
│ 🔒 Click "Approve" on GitHub │
│ Human reviewer confirms: │
│ - Saw the changelog │
│ - Confirmed that staging is healthy │
│ - Takes responsibility for the deploy │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ FULL AUTOMATION (post-approval) │
│ ✅ Deploy to production │
│ ✅ Post-deploy smoke tests on production │
│ ✅ Notification to the team │
│ ✅ Active monitoring (with automatic rollback │
│ covered by a Module 5 capsule) │
└──────────────────────────────────────────────────────┘
The human is the only gate. Everything else is automatic. The human gate is fast (one click) but explicit — someone takes responsibility for the deploy.
Why Production Needs a Human
WITHOUT A HUMAN GATE:
→ A bug passes all the automated tests
→ It passes staging (because staging has less traffic/data)
→ It reaches production
→ It starts failing with real traffic
→ Automatic rollback fixes it (if configured)
→ But users already saw errors
→ Damage limited but not zero
WITH A HUMAN GATE:
→ Same path up to staging
→ Before production, the human reviews:
- Risky changes?
- Staging metrics healthy?
- Is it a good time to deploy? (not a weekend, not
before a critical event, etc.)
→ If all OK: click → production
→ If something isn't convincing: pause, investigate, decide
→ Damage before the deploy
The human adds what automation can't provide: timing context, judgment about risk, authority to take responsibility.
Configuring Environments in GitHub
GitHub Environments is the key primitive for approval gates.
Step-by-step setup
Settings → Environments → New environment → "production"
Important configuration:
- Required reviewers — add 1+ people
- Wait timer — optional (e.g. 5 minutes for "cooling off")
- Deployment branches — restrict to
mainonly - Environment secrets — secrets exclusive to prod
production
├── Required reviewers: [tech_lead, sre_lead]
├── Wait timer: 0 minutes
├── Deployment branches: main
├── Variables:
│ └── DEPLOYMENT_TARGET: prod-cluster
└── Secrets:
├── PROD_DATABASE_URL: xxx
└── PROD_STRIPE_KEY: xxx
Parallel setup: staging
staging
├── Required reviewers: (none — automatic)
├── Deployment branches: main
├── Variables:
│ └── DEPLOYMENT_TARGET: staging-cluster
└── Secrets:
├── STAGING_DATABASE_URL: xxx
└── STAGING_STRIPE_KEY: xxx (test key)
Key difference: staging without required reviewers (automatic deploy), production with reviewers (human gate).
The Complete Workflow
# .github/workflows/staged-deployment.yml
name: Staged Deployment
on:
push:
branches: [main]
permissions:
contents: read
deployments: write
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false # do NOT cancel in-progress deploys
jobs:
# ============================================
# STAGE 1: Pre-deploy validation
# ============================================
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- run: pip install anthropic requests
- name: Readiness validation
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: python scripts/validate_readiness.py
- uses: actions/upload-artifact@v4
with:
name: readiness-report
path: readiness_report.json
# ============================================
# STAGE 2: Deploy to staging (automatic)
# ============================================
deploy-staging:
needs: validate
runs-on: ubuntu-latest
environment: staging # ← staging env, no required reviewers
outputs:
deploy_url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
id: deploy
run: |
# Replace this script with your real deploy mechanism
# (kubectl, terraform, your own deploy script, etc.)
./scripts/deploy.sh staging
echo "url=https://staging.example.com" >> $GITHUB_OUTPUT
- name: Smoke tests on staging
run: ./scripts/smoke_tests.sh https://staging.example.com
- name: Validate staging metrics
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: python scripts/validate_staging.py
# ============================================
# STAGE 3: Deploy to production (with approval gate)
# ============================================
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # ← production env with required reviewers
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: ./scripts/deploy.sh production
- 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
The flow:
- Validation runs first (readiness check)
- Deploy to staging is automatic after successful validation
- Smoke tests + metrics validation on staging
- Deploy to production waits for human approval (because the environment requires reviewers)
- After the approval, automatic deploy + smoke tests + notification
The approval "click"
When the deploy-production job waits for approval, GitHub shows:
⏸️ Waiting for approval to deploy
Environment: production
Reviewers needed: 1 of 2
[Approve and deploy] [Reject]
Any configured reviewer can approve. Click → the deploy continues.
Automatic Staging Validation
Before the human approves production, the system should give them clear information about the state of staging. Here Claude Code helps:
"""scripts/validate_staging.py
Post-deploy analysis of staging to inform the promotion decision.
"""
import json
import os
import subprocess
import sys
import requests
from anthropic import Anthropic
def get_staging_metrics() -> dict:
"""Get health metrics from staging."""
# Adapt to your observability stack
metrics = {
"error_rate": fetch_metric("error_rate", env="staging"),
"latency_p95": fetch_metric("latency_p95", env="staging"),
"throughput": fetch_metric("throughput", env="staging"),
"deployment_success": True, # from the previous step
}
return metrics
def fetch_metric(name: str, env: str) -> float | None:
"""Wrapper for your monitoring system."""
# Example: Prometheus, Datadog, CloudWatch
# Stack-specific implementation
return None
def smoke_test_results() -> dict:
"""Read the smoke test results."""
try:
with open("smoke_results.json") as f:
return json.load(f)
except FileNotFoundError:
return {"passed": 0, "failed": 0, "skipped": 0}
def analyze_with_claude(metrics: dict, smoke: dict, changelog: str) -> str:
"""Analysis: is it safe to promote to production?"""
prompt = f"""Analyze whether it's safe to promote this staging deploy to production.
CHANGELOG (what's being deployed):
{changelog}
STAGING METRICS (post-deploy):
{json.dumps(metrics, indent=2)}
SMOKE TESTS:
{json.dumps(smoke, indent=2)}
Evaluate:
1. Are the staging metrics healthy?
2. Did the smoke tests pass?
3. Are there signals in the changelog that require extra care in prod?
4. Do you recommend promoting to production now?
Return a JSON:
{{
"recommendation": "promote|hold|investigate",
"summary": "1-2 paragraphs with your reasoning",
"concerns": ["concern 1", "concern 2"] or [],
"checklist_for_human": ["verify X before approving", "..."]
}}
"""
client = Anthropic()
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text.strip()
if text.startswith("```"):
text = "\n".join(text.split("\n")[1:-1])
return json.loads(text)
def post_summary_to_pr(analysis: dict, repo: str, token: str):
"""Post the analysis to the PR so the reviewer sees it before approving."""
# Find the PR associated with the merge commit
sha = os.environ["GITHUB_SHA"]
url = f"https://api.github.com/repos/{repo}/commits/{sha}/pulls"
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
r = requests.get(url, headers=headers)
if not r.ok or not r.json():
return
pr_number = r.json()[0]["number"]
body = format_summary_markdown(analysis)
comment_url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
requests.post(comment_url, headers=headers, json={"body": body})
def format_summary_markdown(analysis: dict) -> str:
rec_emoji = {"promote": "✅", "hold": "⏸️", "investigate": "🚨"}
body = f"""# 🤖 Pre-Production Promotion Analysis
## Recommendation
{rec_emoji.get(analysis['recommendation'], '💬')} **{analysis['recommendation'].upper()}**
## Summary
{analysis['summary']}
"""
if analysis.get("concerns"):
body += "## Concerns\n"
for c in analysis["concerns"]:
body += f"- ⚠️ {c}\n"
body += "\n"
if analysis.get("checklist_for_human"):
body += "## Before approving production, verify:\n"
for c in analysis["checklist_for_human"]:
body += f"- [ ] {c}\n"
return body
def main() -> int:
metrics = get_staging_metrics()
smoke = smoke_test_results()
# Read the changelog from the last release
changelog_files = list(Path(".").glob("CHANGELOG_v*.md"))
changelog = changelog_files[0].read_text() if changelog_files else "No changelog found"
analysis = analyze_with_claude(metrics, smoke, changelog)
# Post to the PR (informative, not blocking)
repo = os.environ.get("GITHUB_REPOSITORY")
token = os.environ.get("GITHUB_TOKEN")
if repo and token:
post_summary_to_pr(analysis, repo, token)
# Print to the logs
print(json.dumps(analysis, indent=2))
# If the recommendation is investigate, fail the job (don't promote)
if analysis["recommendation"] == "investigate":
print("Recommendation: investigate. Pipeline paused.")
return 1
return 0
if __name__ == "__main__":
from pathlib import Path
sys.exit(main())
Result: when the human is about to approve the deploy to production, they already have a comment on the PR with an analysis of staging's state and a specific checklist of what to verify.
Advanced Pattern: Wait Timer
For low-risk changes, you can add a "cooling off period" instead of an explicit approval:
production environment
├── Required reviewers: 0
├── Wait timer: 30 minutes
├── Deployment branches: main
Result: the deploy to production waits 30 minutes automatically. If in that time there are no alerts or manual cancellation, it continues. If someone detects something in staging, they can cancel.
When to use it:
- Small teams without 24/7 SRE
- Frequent low-risk deploys
- Companies where iteration speed matters more than explicit approval
When NOT to use it:
- Production with critical traffic
- Deploys of risky changes (DB migrations, breaking changes)
- Regulatory compliance that requires explicit approval
Pattern: Canary Deploy
More sophisticated: instead of promoting 100% of the traffic to production at once, promote gradually:
deploy-canary:
needs: deploy-staging
environment: production-canary # ← 5% of the traffic
steps:
- run: ./scripts/deploy.sh canary
deploy-full:
needs: deploy-canary
environment: production-full # ← 100% of the traffic
# required reviewers here
steps:
- run: ./scripts/deploy.sh full
The canary receives 5% of the traffic for a while. If the metrics are good, it's promoted to 100%. If not, automatic rollback affects only the 5%.
Trade-off: more infrastructure complexity vs less blast radius in incidents.
Common Pitfalls
Error 1: Approval gate without context
Symptom: The reviewer receives the "approve production deploy" notification with no info. They approve blindly.
Why it happens: The approval gate doesn't include the prior analysis.
How to fix it: Post the staging analysis to the PR before the approval (the validate_staging.py script above). The reviewer has the context in their feed.
Error 2: Wait timer without active alerts
Symptom: You configured 30 min of wait timer, but the team doesn't watch metrics in that time. The deploy goes to prod with a bug.
Why it happens: The wait timer assumes someone is monitoring. If nobody watches, it doesn't help.
How to fix it: Configure appropriate alerts in staging that notify the team. Wait timer + active alerts = combo.
Error 3: Same set of secrets for staging and prod
Symptom: A bug in staging uses a production API key and affects real systems.
Why it happens: For convenience, the same STRIPE_KEY is used in both.
How to fix it: Always separate secrets per environment. Stripe has test keys vs live keys — use them. Same principle for everything else.
Error 4: Auto-rollback not configured
Symptom: Deploy to production fails, the team improvising a rollback under pressure.
Why it happens: Auto-rollback is separate work they left "for later".
How to fix it: Configure auto-rollback before enabling automatic deploys to production. It's what the Module 5 capsule covers.
Error 5: cancel-in-progress: true on deploys
Symptom: You merge two PRs in a row. The second cancels the first's deploy halfway. Inconsistent state.
Why it happens: cancel-in-progress: true is fine for CI checks, wrong for deploys.
How to fix it: For deploys, always cancel-in-progress: false. Deploys must complete serially.
Diagnosis
Question 1: Does your deploy to production have an explicit approval gate?
If not, you depend 100% on automation. For critical systems, the human adds value.
Question 2: Does the reviewer receive context before approving?
If they only see "approve deploy", they approve blindly. A post-staging analysis + checklist gives them the context.
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: Does your workflow have `concurrency: cancel-in-progress: false` for deploys?
If it's true, consecutive deploys cancel each other halfway. A bad pattern.
Question 5: Do you have automatic rollback configured, or is the rollback manual?
Manual = under pressure, error-prone. Automatic with appropriate triggers is the robust one. The Module 5 capsule develops it.
Exercises
Exercise 1: Configure environments (Easy)
Create the staging and production environments in GitHub Settings:
- staging: no reviewers, deployment branches main
- production: with reviewers (at least yourself in testing), deployment branches main
Configure different test secrets in each one.
Exercise 2: 3-stage pipeline (Medium)
Implement the workflow with the 3 jobs (validate, deploy-staging, deploy-production). Verify that:
- validate runs first
- deploy-staging runs after successful validate, with no approval
- deploy-production waits for approval before running
Use mock scripts (echo "Deploying to ...") to test the flow without real infrastructure.
Exercise 3: Pre-promotion analysis with Claude (Hard)
Implement validate_staging.py that:
- Gets mock metrics from staging
- Passes the changelog + metrics to Claude
- Generates a recommendation with concerns and a checklist
- Posts the analysis as a comment on the PR
- Fails the job if the recommendation is "investigate"
Summary
- The human is the only gate between staging and production for critical changes
- GitHub Environments is the primitive: required reviewers, wait timers, environment secrets
- Automatic pre-approval analysis gives the human context (recommendation, concerns, checklist)
- Separate secrets per environment avoid cross-contamination
cancel-in-progress: falsefor deploys (unlike CI checks)- Wait timer is an alternative for low-risk deploys, but only with active monitoring
- Canary deploys reduce blast radius on risky changes
Next capsule: 05 — Project: Complete deployment workflow. The last technical capsule of module 4. You combine changelog + readiness + staged deployment into an end-to-end workflow you'll be able to adapt to your project.
Additional Resources
- GitHub Environments — Complete setup
- GitHub: Required reviewers — Gate configuration
- Canary Deployments — Martin Fowler on the pattern
- Blue-Green Deployments — An alternative to canary
- 12-Factor App: Backing services — Why staging and prod should be identical
- SRE Workbook: Release Engineering — A chapter from Google's book