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
- Settings > Environments > New environment — create one for each deployment target
- Enable Required reviewers — add 1 to 6 individuals or teams
- Set a wait timer if you want a mandatory delay (0 to 43,200 minutes — 30 days max)
- Enable Prevent self-reviews so the engineer who pushed the code can't rubber-stamp their own deploy
- Restrict deployment branches to
mainonly 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
mainbranch 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