Module 6: Deployment Pipelines

5. Approval Gates

Overview

Your pipeline deploys to staging automatically. Staging works, the smoke tests pass, everything is green. Now comes the crucial question: who decides that this goes to production? The pipeline? You? Nobody?

Approval gates are the human control point between staging and production. A reviewer receives a notification, reviews staging, verifies that the application works correctly, and approves the deploy to production. Only after that approval does the pipeline continue. It isn't bureaucracy — it's deliberate protection.

For AI systems, approval gates are particularly valuable because automated tests can't verify everything: is the chatbot's response useful? Is the tone appropriate? Is the model not hallucinating? A human reviewing staging can detect problems no automated test catches.

This capsule teaches you to configure approval gates on GitHub, understand the complete approval flow, and design the human-in-the-loop process that makes your deployment pipeline professional.


The human-in-the-loop pattern

Why a human in the loop

An automatic pipeline with no approval:
  Push → Test → Build → Deploy to staging → Deploy to production
  
  The problem: If staging has a subtle bug (incorrect AI responses,
  high latency, a broken prompt), it goes straight to production.
  The pipeline can't judge subjective quality.

A pipeline with an approval gate:
  Push → Test → Build → Deploy to staging → ⏸️ A human reviews → Deploy to production
  
  The human can:
  - Test the chatbot manually in staging
  - Verify that the responses are coherent
  - Review the logs for anomalies
  - Confirm that the API costs are reasonable
  - Approve only when they're sure

The complete flow

1. A developer pushes to main
       ↓
2. Pipeline: test → build → push the image to GHCR
       ↓
3. Pipeline: deploy the image to staging
       ↓
4. Pipeline: smoke tests in staging → ✅ pass
       ↓
5. GitHub sends a notification to the reviewer
       ↓
6. The reviewer opens staging.your-app.com
       ↓
7. The reviewer tests the functionality:
   - Does /health respond?
   - Does the chatbot respond correctly?
   - Is the latency acceptable?
   - Do the logs show errors?
       ↓
8. The reviewer approves in GitHub's UI
       ↓
9. Pipeline: deploy the image to production
       ↓
10. Pipeline: smoke tests in production → ✅ pass
       ↓
11. The deploy is complete ✅

This flow takes 5-10 minutes in total. Steps 1-4 are automatic (~3 min). Steps 5-8 depend on the reviewer (~2-5 min). Steps 9-11 are automatic (~2 min).


Configuring approval gates on GitHub

Step 1: Configure required reviewers

GitHub → Settings → Environments → production → Protection rules

☑ Required reviewers
  Add up to 6 users or teams:
  → @your-username
  → @lead-developer
  → @team-backend

☐ Prevent self-review (optional — it stops whoever pushed from approving their own deploy)

Who should be a reviewer:

  • 📋 The tech lead — They understand the change's technical impact
  • 📋 The product owner — They understand the business impact
  • 📋 The team's senior developer — They understand the code's context
  • 📋 You yourself (if you work solo) — The act of pausing and reviewing has value

Step 2: Configure a wait timer (optional)

GitHub → Settings → Environments → production → Protection rules

☑ Wait timer
  Minutes: 5

  The effect: After the approval, it waits 5 minutes before running
  The use: A cooling period — it prevents impulsive deploys

Step 3: Declare the environment in the workflow

deploy-production:
  needs: deploy-staging
  runs-on: ubuntu-latest
  environment:
    name: production
    url: https://your-app.com
  steps:
    - name: Deploy to production
      run: echo "Deploying..."

The key is environment: name: production. When GitHub Actions reaches this job, it verifies the production environment's protection rules. If there are required reviewers, the job pauses.


The reviewer's experience

The notification

When the workflow reaches the job with the approval gate, GitHub sends notifications:

📧 Email:
  Subject: "Deployment review requested: Deploy Pipeline #42"
  Body: "user/ai-api-project needs your review for environment production"
  [Review pending deployments →]

🔔 GitHub notification:
  "Review requested for production deployment in Deploy Pipeline #42"

📱 GitHub mobile:
  A push notification with a direct link

The review UI

The reviewer sees this in the repo's Actions tab:

Deploy Pipeline #42
  ✅ test — completed in 25s
  ✅ build — completed in 1m 30s
  ✅ deploy-staging — completed in 45s
  ⏸️ deploy-production — Waiting for review

  ┌─────────────────────────────────────────────┐
  │  Review pending deployments                   │
  │                                               │
  │  This workflow run requires review before      │
  │  deploying to the following environments:      │
  │                                               │
  │  ☑ production                                 │
  │                                               │
  │  Comment:                                      │
  │  ┌─────────────────────────────────────────┐  │
  │  │ Staging looks good. /health responds,    │  │
  │  │ chatbot answers correctly, latency <2s.  │  │
  │  └─────────────────────────────────────────┘  │
  │                                               │
  │  [Approve and deploy]  [Reject]               │
  └─────────────────────────────────────────────┘

What happens after the approval

The reviewer clicks "Approve and deploy"
    ↓
If there's a wait timer: it waits N minutes
    ↓
The deploy-production job runs
    ↓
The job's steps run normally
    ↓
If everything is OK → ✅ deployment success
If it fails → ❌ deployment failure (rollback)

What happens if the reviewer rejects

The reviewer clicks "Reject"
    ↓
The deploy-production job gets marked as ❌ cancelled
    ↓
The workflow run ends
    ↓
There's no deploy to production
Staging keeps the deployed version (for debugging)

The complete flow in a workflow

A workflow with an integrated approval gate

name: Deploy Pipeline

on:
  push:
    branches: [main]

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

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"
      - run: pip install -r requirements-dev.txt
      - run: pytest tests/ -v --tb=short

  build:
    needs: test
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    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.DEPLOY_HOST }}
        run: |
          echo "$SSH_KEY" > /tmp/key && chmod 600 /tmp/key
          ssh -o StrictHostKeyChecking=no -i /tmp/key deploy@$HOST \
            "cd /app && IMAGE_TAG=sha-${{ github.sha }} docker compose up -d --pull always"
          rm /tmp/key

      - name: Smoke test staging
        run: |
          for i in $(seq 1 30); do
            if curl -sf https://staging.your-app.com/health > /dev/null 2>&1; then
              echo "Staging healthy after ${i}s"
              exit 0
            fi
            sleep 2
          done
          echo "Staging health check failed"
          exit 1

      - name: Staging summary
        run: |
          echo "### Staging Deployment" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "**Image:** \`sha-${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
          echo "**URL:** https://staging.your-app.com" >> $GITHUB_STEP_SUMMARY
          echo "**Status:** ✅ Healthy" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "Review staging and approve for production deployment." >> $GITHUB_STEP_SUMMARY

  # ═══════════════════════════════════════════════
  # APPROVAL GATE — The workflow pauses here
  # ═══════════════════════════════════════════════
  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://your-app.com
    steps:
      - uses: actions/checkout@v4

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

      - name: Smoke test production
        run: |
          for i in $(seq 1 30); do
            if curl -sf https://your-app.com/health > /dev/null 2>&1; then
              echo "Production healthy after ${i}s"
              exit 0
            fi
            sleep 2
          done
          echo "Production health check failed"
          exit 1

      - name: Production summary
        run: |
          echo "### Production Deployment" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "**Image:** \`sha-${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
          echo "**URL:** https://your-app.com" >> $GITHUB_STEP_SUMMARY
          echo "**Status:** ✅ Deployed" >> $GITHUB_STEP_SUMMARY

Approval timeout

How long GitHub waits

By default, a deployment review stays pending for 30 days. If nobody approves within 30 days, the workflow gets cancelled.

You can configure a shorter timeout in the environment:

GitHub → Settings → Environments → production → Protection rules
  ☑ Wait timer: don't confuse it with the approval timeout
  
  The approval timeout is 30 days (not currently configurable)
  The wait timer is post-approval

What happens with multiple pending deploys

The scenario: Push #1 → deploy to staging OK → waiting for approval
              Push #2 → deploy to staging OK → waiting for approval

Two workflow runs waiting for approval for production.

The reviewer can:
  1. Approve #2 and reject #1 (deploy the latest version)
  2. Approve #1 and then #2 (deploy in order)
  3. Reject both
  
The recommendation: Approve only the last push. The previous ones
become irrelevant because staging has the newest version.

Prevent self-review

When to enable it

GitHub → Settings → Environments → production → Protection rules
  ☑ Required reviewers: @dev1, @dev2, @lead
  ☑ Prevent self-review

With "Prevent self-review," if @dev1 made the push, @dev1 can't approve their own deploy. It must be @dev2 or @lead.

Without prevent self-review:
  The developer pushes → The developer approves → Deploy
  (A complete bypass of the review — "I approve myself")

With prevent self-review:
  The developer pushes → Another developer approves → Deploy
  (The four-eyes principle — someone else verifies)

When NOT to enable it

If you work alone on the project, enabling "prevent self-review" blocks all your deploys because nobody else can approve. In that case:

You work alone:
  ☐ Prevent self-review (disabled)
  ☑ Required reviewers: @your-username
  
  The effect: You approve your own deploys.
  The value is the forced pause — you must go to Actions, review staging,
  and consciously click "Approve."

The act of pausing and consciously approving already has value, even if you're the only reviewer.


Approval via the GitHub CLI

Approving from the terminal

You don't need to go to GitHub's UI. You can approve from the CLI:

# List the workflow runs waiting for review
gh run list --status waiting

# See the details of a specific run
gh run view 12345678

# Approve a deployment
gh run review 12345678 --approve

# Approve with a comment
gh run review 12345678 --approve --body "Staging verified, latency OK, prompts correct"

# Reject a deployment
gh run review 12345678 --reject --body "Staging has incorrect responses on /chat endpoint"

A script for a quick review

#!/bin/bash
# scripts/review-deploy.sh

REPO="${1:?"Usage: review-deploy.sh <owner/repo>"}"

echo "=== Pending deployments for $REPO ==="
gh run list --repo "$REPO" --status waiting --json databaseId,displayTitle,createdAt \
  --template '{{range .}}#{{.databaseId}} — {{.displayTitle}} — {{.createdAt}}{{"\n"}}{{end}}'

echo ""
read -p "Run ID to review: " RUN_ID
read -p "Approve or Reject? (a/r): " ACTION

if [ "$ACTION" = "a" ]; then
  read -p "Comment: " COMMENT
  gh run review "$RUN_ID" --repo "$REPO" --approve --body "$COMMENT"
  echo "✅ Approved"
elif [ "$ACTION" = "r" ]; then
  read -p "Reason: " REASON
  gh run review "$RUN_ID" --repo "$REPO" --reject --body "$REASON"
  echo "❌ Rejected"
fi

The reviewer's checklist

What to verify before approving

When you receive an approval request, follow this checklist:

Before approving the deploy to production:

□ The staging URL responds (/health → 200)
□ The main features work:
  □ The /chat endpoint responds with coherent text
  □ Latency < 5 seconds for AI responses
  □ There are no 500 errors on normal requests
□ Staging's logs show no unusual errors or warnings
□ The commit message describes the change correctly
□ If the change affects prompts: verify the response quality manually
□ If the change affects costs: verify the cost estimate in the CI logs
□ The smoke test in staging passed (verify it in the Actions logs)

How to document the approval

The comment field in the approval is your record:

A good approval comment:
  "Staging verified: /health OK, /chat responds correctly with new prompt format,
   latency ~1.5s, no errors in logs. Ready for production."

A bad approval comment:
  "ok"
  "lgtm"
  "" (empty)

The comment stays in the workflow run's history. When something fails in production, the first place you check is: "who approved and what did they verify?"


Comparisons

Without approval vs with approval

AspectWithout approvalWith approval
SpeedFaster (no waiting)+2-5 min per review
RiskHigher (an automatic deploy)Lower (a human verifies)
Responsibility"The pipeline did it""The reviewer approved it"
Cost of an errorThe bug reaches productionThe bug is caught in staging
OverheadZero~5 min per deploy

One reviewer vs multiple reviewers

AspectOne reviewerMultiple reviewers
SpeedFast (one person)Slower (any of N)
BottleneckIf the reviewer isn't around, it blocksAnyone can approve
CoverageOne perspectiveDifferent expertises
For teams of1-3 people4+ people

Approval in staging vs approval in production

AspectApproval in stagingApproval in production
When it pausesBefore deploying to stagingAfter staging, before production
What they can reviewNothing (staging isn't deployed)Staging running
ValueLow (there's nothing to verify)High (the reviewer can test staging)
RecommendationDon't use itAlways use it

Troubleshooting

1. "No one reviewed this deployment"

Symptom: The workflow shows "No one has reviewed this deployment yet" after days.

Cause: The reviewers didn't get the notification or didn't see it.

Solution:

# Send a manual reminder
gh run view 12345678

# Or approve directly from the CLI
gh run review 12345678 --approve --body "Self-approving after staging verification"

Configure GitHub's notifications correctly: Settings → Notifications → Actions → "Only for reviews requested of you."

2. Self-review is blocked when you work alone

Symptom: "You cannot approve your own deployment" and there's no other reviewer.

Cause: "Prevent self-review" is enabled and you're the only contributor.

Solution:

GitHub → Settings → Environments → production → Protection rules
  ☐ Prevent self-review (disable it)

Or add another user as a collaborator on the repo with write access.

3. The approval expires before the reviewer acts

Symptom: The workflow gets cancelled after 30 days with no approval.

Cause: The default timeout for deployment reviews is 30 days.

Solution: Re-run the workflow:

gh run rerun 12345678

This generates a new workflow run that goes through the whole pipeline again. If the code didn't change, it'll be fast thanks to the cache.

4. Multiple workflow runs waiting for approval

Symptom: 5 workflow runs pending approval because you made 5 pushes in a row.

Cause: Each push to main triggers the pipeline and each one reaches the approval gate.

Solution: Approve only the last one (the most recent) and reject the earlier ones:

# List the pending runs
gh run list --status waiting

# Reject the earlier ones
gh run review 111 --reject --body "Superseded by newer push"
gh run review 222 --reject --body "Superseded by newer push"

# Approve the last one
gh run review 333 --approve --body "Latest version, staging verified"

Exercises

Exercise 1: Configure a complete approval gate

Describe the exact steps for configuring an approval gate on GitHub that: (1) requires 2 reviewers, (2) has a 3-minute wait timer, (3) only allows deploys from main, (4) prevents self-review.

See solution
Step 1: Go to Settings → Environments → production → Protection rules

Step 2: Required reviewers
  ☑ Required reviewers
  Add reviewers:
    → @reviewer-1
    → @reviewer-2
  ☑ Prevent self-review

Step 3: Wait timer
  ☑ Wait timer
  Minutes: 3

Step 4: Deployment branches
  ● Selected branches
  Add branch: main

Step 5: In the workflow YAML
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://your-app.com
    steps:
      - run: echo "Deploy"

The result:

  • The job pauses until one of the two reviewers approves
  • The pusher can't self-approve
  • After the approval, it waits 3 more minutes
  • Only pushes to main can trigger the deploy

Exercise 2: Write a staging summary for the reviewer

Create a GitHub Actions step that generates a job summary with: the deployed image, the staging URL, and instructions for the reviewer about what to verify.

See solution
- name: Staging review summary
  run: |
    echo "### 🚀 Staging Deployment Ready for Review" >> $GITHUB_STEP_SUMMARY
    echo "" >> $GITHUB_STEP_SUMMARY
    echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
    echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
    echo "| **Image** | \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY
    echo "| **Commit** | [\`$(echo ${{ github.sha }} | cut -c1-7)\`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) |" >> $GITHUB_STEP_SUMMARY
    echo "| **Staging URL** | [staging.your-app.com](https://staging.your-app.com) |" >> $GITHUB_STEP_SUMMARY
    echo "| **Health** | ✅ Passing |" >> $GITHUB_STEP_SUMMARY
    echo "" >> $GITHUB_STEP_SUMMARY
    echo "### Reviewer Checklist" >> $GITHUB_STEP_SUMMARY
    echo "" >> $GITHUB_STEP_SUMMARY
    echo "- [ ] \`/health\` endpoint responds 200" >> $GITHUB_STEP_SUMMARY
    echo "- [ ] \`/chat\` endpoint returns coherent responses" >> $GITHUB_STEP_SUMMARY
    echo "- [ ] Latency < 5 seconds" >> $GITHUB_STEP_SUMMARY
    echo "- [ ] No errors in staging logs" >> $GITHUB_STEP_SUMMARY
    echo "- [ ] Commit message matches the change" >> $GITHUB_STEP_SUMMARY
    echo "" >> $GITHUB_STEP_SUMMARY
    echo "**Approve the production deployment when staging is verified.**" >> $GITHUB_STEP_SUMMARY

This summary appears in the workflow run's tab. The reviewer sees it when they open the notification's link and has all the information necessary to review.

Exercise 3: Approval via a CLI script

Write a bash script that: (1) lists the workflow runs pending approval for your repo, (2) shows each one's commit message, (3) lets you approve or reject with a comment.

See solution
#!/bin/bash
# scripts/approve-deploy.sh

REPO="${GITHUB_REPOSITORY:-user/ai-api}"

echo "=== Pending deployment reviews ==="
echo ""

RUNS=$(gh run list --repo "$REPO" --status waiting \
  --json databaseId,displayTitle,headSha,createdAt \
  --jq '.[] | "\(.databaseId)\t\(.displayTitle)\t\(.headSha | .[:7])\t\(.createdAt)"')

if [ -z "$RUNS" ]; then
  echo "No pending reviews."
  exit 0
fi

echo "ID          Title                           SHA      Created"
echo "----------- ------------------------------- -------- --------"
echo "$RUNS"
echo ""

read -p "Enter Run ID to review (or 'q' to quit): " RUN_ID
[ "$RUN_ID" = "q" ] && exit 0

echo ""
echo "Run details:"
gh run view "$RUN_ID" --repo "$REPO" 2>/dev/null | head -20
echo ""

read -p "[A]pprove or [R]eject? " ACTION

case "$ACTION" in
  [Aa])
    read -p "Approval comment: " COMMENT
    gh run review "$RUN_ID" --repo "$REPO" --approve \
      --body "${COMMENT:-Approved after staging review}"
    echo "✅ Deployment approved"
    ;;
  [Rr])
    read -p "Rejection reason: " REASON
    gh run review "$RUN_ID" --repo "$REPO" --reject \
      --body "${REASON:-Rejected — issues found in staging}"
    echo "❌ Deployment rejected"
    ;;
  *)
    echo "Invalid option. Use A or R."
    exit 1
    ;;
esac

Key points:

  • gh run list --status waiting filters only the runs waiting for review
  • --jq formats the output as a table
  • gh run review --approve/--reject performs the action
  • The script is interactive — it runs locally, not in CI

Exercise 4: Design the flow for a team of 4

Your team has: 2 backend devs, 1 ML engineer, 1 tech lead. Design the approval configuration for the production environment, considering: who can approve, whether there should be self-review, and what happens if the tech lead isn't available.

See solution
The configuration:
  Environment: production
  Required reviewers: @backend-dev-1, @backend-dev-2, @ml-engineer, @tech-lead
  Prevent self-review: ☑ Enabled
  Wait timer: 5 minutes
  Deployment branches: main

How it works:
  - Any of the 4 can approve (OR, not AND)
  - If @backend-dev-1 made the push, they can't self-approve
  - The other 3 can approve
  - If the tech lead isn't around, any other dev can approve

The flow per scenario:

  A backend dev pushes an API change:
    → The ML engineer or the tech lead approves (they can verify it doesn't break the AI integration)

  The ML engineer pushes a prompt change:
    → The tech lead or a backend dev approves (they verify that staging works)

  The tech lead pushes an urgent fix:
    → Any dev approves (prevent self-review guarantees four eyes)

  The tech lead isn't available:
    → The other 3 can approve — no bottleneck

An additional recommendation:
  - Create a "@backend-team" team and add the team as a reviewer
  - That way, if people join or leave, you don't need to reconfigure the environment

Summary

  • Approval gates pause the pipeline until a human approves the deploy to production
  • Required reviewers define who can approve — configurable in the environment's protection rules
  • The complete flow: deploy to staging → the reviewer verifies → approves → deploy to production
  • Prevent self-review guarantees that someone other than the pusher reviews the deploy
  • A wait timer adds a post-approval cooling period to prevent impulsive deploys
  • The GitHub CLI lets you approve/reject from the terminal with gh run review
  • A staging summary gives the reviewer all the information necessary to make the decision
  • The value of the approval isn't just the review — it's the forced pause that prevents impulsive deploys
  • For solo teams: it still has value — the conscious act of approving prevents mistakes

Additional resources

  1. GitHub — Reviewing deployments — Official documentation on reviewing deployments
  2. GitHub — Environment protection rules — Configuring protection rules
  3. GitHub CLI — gh run review — Approving/rejecting from the CLI
  4. GitHub — Managing deployment environments — Managing environments
  5. Four-Eyes Principle — The security principle behind required reviewers
  6. GitHub Actions — Workflow run approval — Approving workflows from forks