Manual Approval Gates in GitHub Actions

Most teams discover GitHub Actions can pause a pipeline and wait for a human about two years after they needed it. The feature has been there since late 2020. It's called Environment Protection Rules, and it solves the problem that every deployment pipeline eventually hits: not everything should ship the moment CI goes green.


There's a moment in every team's CI/CD journey where someone asks: "Can we make it stop here and wait for someone to say go?" Maybe it's the first time a deploy to production goes sideways because nobody looked at the staging results. Maybe it's a compliance requirement. Maybe it's just the CTO who wants to eyeball the changelog before it hits customers on a Friday afternoon.

GitHub Actions has three mechanisms for this, each solving a different shape of the problem. Here's how they work, when to use each one, and the YAML to make it happen.


Environment Protection Rules

This is the one you want for mid-pipeline gates. It's native, requires no marketplace actions, and integrates directly with GitHub's notification system.

The model is simple: you create Environments in your repository settings — dev, staging, production, whatever your pipeline needs. You attach protection rules to the environments that should require human approval. When a workflow job targets a protected environment, the pipeline pauses. Reviewers get notified via email, GitHub notifications, and mobile push. They approve or reject. The pipeline continues or stops.

Setting It Up

  1. Settings > Environments > New environment — create one for each deployment target
  2. Enable Required reviewers — add 1 to 6 individuals or teams
  3. Set a wait timer if you want a mandatory delay (0 to 43,200 minutes — 30 days max)
  4. Enable Prevent self-reviews so the engineer who pushed the code can't rubber-stamp their own deploy
  5. Restrict deployment branches to main only if you want to prevent feature branches from hitting production

The YAML

The only change to your workflow is one line: environment: <name> on the job that should be gated.

name: Deploy Pipeline

on:  
  push:
    branches: [main]

jobs:  
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build && npm test

  deploy-staging:
    runs-on: ubuntu-latest
    needs: build
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh staging

  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh production

When the workflow reaches deploy-production, it stops. A yellow banner appears: "Review deployments." The designated reviewers click it, select the environment, optionally leave a comment explaining why they're approving, and hit Approve and deploy or Reject.

This also works from your phone. GitHub Mobile sends push notifications when a deployment is waiting for your review, and you can approve or reject without opening a laptop.

What You Get

  • Environment-scoped secrets — secrets tied to an environment are only available to jobs that target it and pass the protection rules. Production API keys aren't accessible until the deploy is approved.
  • Deployment history — GitHub tracks which commits were deployed to which environment, when, and who approved them. This is your audit trail.
  • Concurrent deployment control — you can prevent multiple deployments to the same environment from running simultaneously.

The Catch

Environment protection rules require GitHub Pro, Team, or Enterprise for private repositories. Public repos get them on all plans. If you're on the free plan with a private repo, skip to Mechanism 3 below.


workflow_dispatch with Inputs

This is not a mid-pipeline gate — it's a pre-pipeline gate. The workflow doesn't start until a human manually triggers it and fills in parameters.

Use this when the deployment itself is an intentional, deliberate act — not something that should happen on every push. Infrastructure changes, database migrations, release cuts to production on a specific schedule.

name: Release Deploy

on:  
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - staging
          - production
      version:
        description: 'Version tag to deploy'
        required: true
        type: string
      dry_run:
        description: 'Dry run only?'
        type: boolean
        default: true
      notes:
        description: 'Deployment notes'
        type: string

jobs:  
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.version }}
      - name: Deploy
        run: |
          echo "Deploying ${{ inputs.version }} to ${{ inputs.environment }}"
          if [ "${{ inputs.dry_run }}" != "true" ]; then
            ./scripts/deploy.sh ${{ inputs.environment }} ${{ inputs.version }}
          fi

This gives you a form in the Actions tab — dropdowns, text fields, checkboxes. The person triggering the workflow selects the environment, types a version, decides if it's a dry run. GitHub records who triggered it and with what parameters.

You can combine both mechanisms: workflow_dispatch to require a human to start the pipeline, plus environment: production with required reviewers so a second human approves the final step. Two-person integrity for production deploys.

Trigger it from the CLI too: gh workflow run deploy.yml -f environment=production -f version=v2.4.1 -f dry_run=false


Issue-Based Approval (Free Tier)

If you're on GitHub Free with a private repo and can't use environment protection rules, there's a well-maintained open-source action that implements approval via GitHub Issues: trstringer/manual-approval.

When the workflow hits the approval step, it creates an Issue, tags the designated approvers, and polls for a comment containing "approved" or "denied." It's not as polished as the native environment UI — there's no yellow banner, no one-click approve button — but it works and it's free.

jobs:  
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  approval:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: trstringer/manual-approval@v1
        with:
          secret: ${{ github.TOKEN }}
          approvers: andy,eric
          minimum-approvals: 1
          issue-title: "Deploy ${{ github.sha }} to production?"
          issue-body: |
            Commit: ${{ github.sha }}
            Branch: ${{ github.ref_name }}

            **Approve** by commenting: `approved`
            **Deny** by commenting: `denied`

  deploy:
    runs-on: ubuntu-latest
    needs: approval
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh production

The Full Pattern

Here's a complete pipeline that builds, auto-deploys to dev, gates staging, runs smoke tests, then gates production with a wait timer. This is the pattern we use for services that have real users and real consequences.

name: Full Pipeline

on:  
  push:
    branches: [main]

jobs:  
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build
          path: dist/

  deploy-dev:
    needs: build-and-test
    runs-on: ubuntu-latest
    environment: dev
    steps:
      - uses: actions/download-artifact@v4
        with: { name: build }
      - run: ./deploy.sh dev

  integration-tests:
    needs: deploy-dev
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:integration

  deploy-staging:
    needs: integration-tests
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - uses: actions/download-artifact@v4
        with: { name: build }
      - run: ./deploy.sh staging

  smoke-tests:
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:smoke

  deploy-production:
    needs: smoke-tests
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: actions/download-artifact@v4
        with: { name: build }
      - run: ./deploy.sh production

  post-deploy-verify:
    needs: deploy-production
    runs-on: ubuntu-latest
    steps:
      - run: curl -f https://example.com/health || exit 1

Configure the environments:

  • dev — no protection rules. Auto-deploys on every push to main.
  • staging — 1 required reviewer from the dev team.
  • production — 2 required reviewers from team leads. 15-minute wait timer. Self-approval prevented. Only main branch allowed.

The pipeline is visible in the Actions tab as a graph. You can see exactly where it paused, who approved, when, and what they said. That's your audit trail for SOC 2, ISO 27001, or whoever is asking.


Things That Bite You

A few gotchas from running this in production:

Token expiration. When a workflow is paused waiting for approval, it holds a runner slot. GitHub Actions workflows have a maximum run time of 35 days. If nobody approves within 35 days, the workflow is cancelled. In practice this is rarely a problem, but set up Slack notifications for pending approvals so they don't rot.

Environment secrets scope. Secrets defined at the environment level are only available to jobs targeting that environment — and only after protection rules pass. This is a feature, not a bug. It means your production database credentials are literally inaccessible to any job that hasn't been approved. But it also means you can't reference production secrets in a build step that runs before the approval gate.

Concurrency. By default, multiple workflow runs can target the same environment simultaneously. If you're deploying to production, add concurrency: production to the job to ensure only one deploy runs at a time. Queued runs will wait.

Re-runs. If a deployment fails after approval, you can re-run the failed job without needing re-approval — the original approval carries forward. This is usually what you want. If you need re-approval on retry, create a fresh workflow run instead.


Links: - GitHub Docs: Managing environments - GitHub Docs: Reviewing deployments - trstringer/manual-approval - william-liebenberg/github-gated-deployments