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:

  1. Required reviewers — add 1+ people
  2. Wait timer — optional (e.g. 5 minutes for "cooling off")
  3. Deployment branches — restrict to main only
  4. 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:

  1. Validation runs first (readiness check)
  2. Deploy to staging is automatic after successful validation
  3. Smoke tests + metrics validation on staging
  4. Deploy to production waits for human approval (because the environment requires reviewers)
  5. 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:

  1. validate runs first
  2. deploy-staging runs after successful validate, with no approval
  3. 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:

  1. Gets mock metrics from staging
  2. Passes the changelog + metrics to Claude
  3. Generates a recommendation with concerns and a checklist
  4. Posts the analysis as a comment on the PR
  5. 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: false for 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

  1. GitHub Environments — Complete setup
  2. GitHub: Required reviewers — Gate configuration
  3. Canary Deployments — Martin Fowler on the pattern
  4. Blue-Green Deployments — An alternative to canary
  5. 12-Factor App: Backing services — Why staging and prod should be identical
  6. SRE Workbook: Release Engineering — A chapter from Google's book