Approval Gates and Manual Deployment
An approval gate is a point in a deployment pipeline where automatic progress stops and a human (or a policy engine standing in for one) must explicitly authorize the next step before it runs. Fowler and the deployment-pipeline model make room for exactly this: pipeline “stages can be automatic, or require human authorization to proceed” (Fowler, DeploymentPipeline), and it is precisely the distinction between continuous delivery (the pipeline makes every change releasable, but a human presses the button) and continuous deployment (every green change ships automatically) — the deploy decision “may [be] choose[n] not to” (Fowler, ContinuousDelivery). A manual deployment is the concrete implementation: a pipeline job that will not execute until an authorized person triggers it. This note is about the machinery of that gate — GitHub Actions environments with required reviewers, GitLab manual jobs behind protected environments, and change-advisory/policy gates — and about the harder question of when a human belongs in the pipe at all.
This is the gate; the ladder it guards is Environment Promotion; the merge-time sibling that gates code into the mainline (rather than out to an environment) is Pull Request Gates and Required Checks. The two are complementary: PR gates protect the trunk, approval gates protect production.
Mental Model — the pawl on the ratchet
If Environment Promotion is a ratchet advancing an artifact through environments, an approval gate is the pawl that a human has to release for the most consequential notch. The gate is not a build step and not a test — it is a decision node that consumes evidence (test results, a changelog, a policy verdict) and emits a single bit: proceed or not. Its whole value is that it inserts human judgment at the boundary where automated confidence runs out and business/operational risk begins.
flowchart TD START["Deploy job reaches<br/>a gated environment"] CHECK{"Automated gates<br/>already green?<br/>(tests, scans, policy)"} START --> CHECK CHECK -->|"no"| BLOCK["Blocked — never<br/>reaches human"] CHECK -->|"yes"| WAIT["Job PAUSES<br/>waiting for approval"] WAIT --> REVIEW{"Authorized reviewer<br/>decides"} REVIEW -->|"approve"| DEPLOY["Job runs →<br/>artifact deployed"] REVIEW -->|"reject"| FAIL["Workflow fails,<br/>nothing deployed"] REVIEW -->|"timeout / ignore"| IDLE["Deployment sits<br/>un-run (see GitLab trap)"] style DEPLOY fill:#1f6f43,color:#fff style FAIL fill:#7a1f1f,color:#fff style BLOCK fill:#7a1f1f,color:#fff
What it shows and the insight to take: a well-placed approval gate is the last line, not the first — automated gates (tests, scans, policy) should already be green before a human is asked, so the human is deciding “should we release this now?” not “is this correct?” (machines answer correctness faster and more reliably). Approval is a timing and accountability decision, not a quality check. If your reviewers are eyeballing diffs at the deploy gate, the quality gates upstream are too weak — push that work left into Pull Request Gates and Required Checks and Test Automation Tiers.
When a human belongs in the pipe
The honest default in modern delivery is fewer manual gates, not more — every manual gate adds lead time and, worse, invites rubber-stamping that provides the illusion of control without the substance. A human gate earns its place only when a genuine judgment call sits at that boundary:
- Production deploys with real blast radius — the canonical case; the rightmost hop of the ladder where mistakes reach real users and real data.
- Business-timing decisions — “not during the Black Friday freeze,” “wait for marketing,” “coordinate with a downstream team.” No test can encode these; they are genuinely human.
- Regulatory / change-management requirements — regulated industries mandate a recorded, attributable approval (a change-advisory sign-off) for auditability, independent of technical readiness.
- Irreversible or high-cost actions — a schema migration that cannot be rolled back, a data-destroying operation, an expensive infra change.
Everywhere else, prefer to decouple deploy from release — deploy automatically, then let a feature flag gate exposure — so the “should we?” decision moves off the deploy path entirely (owned by Site Reliability Engineering MOC). A manual gate that exists “because we’ve always had one” is pure lead-time tax.
GitHub Actions — environments with required reviewers
GitHub implements approval gates as deployment protection rules attached to an environment. A job that names environment: production is subject to whatever rules that environment carries, and the job pauses — it does not run — until they are all satisfied.
The rules, configured in repo Settings → Environments (GitHub, Manage environments):
- Required reviewers — “Enter up to 6 people or teams. Only one of the required reviewers needs to approve the job for it to proceed.” So it is an any-of-N gate (contrast GitLab’s count-of-N below), and the cap is six.
- Prevent self-review — an optional toggle that stops the person who triggered the run from approving their own deployment — the machinery form of separation-of-duties.
- Wait timer — a fixed delay (in minutes) before the job proceeds after other rules pass, e.g. a soak/cool-off window.
- Deployment branch policies — restrict which branches/tags may deploy to this environment, so only
main(orv*tags) can even reach the production gate. - Allow administrators to bypass — when deselected, even admins cannot skip the rules; when selected, an admin can “Start all waiting jobs,” but must pick the environment, leave a comment, and confirm — so the bypass is recorded.
- Custom deployment protection rules — a GitHub App (Datadog, Honeycomb, ServiceNow, etc.) can register as an external gate that programmatically approves or rejects based on its own signal (error budget, monitor health, change ticket status). This is how a third-party policy becomes a pipeline gate.
Environment secrets are only exposed to a job after its protection rules pass — so a compromised or unapproved run never even sees the production credentials. Deleting an environment fails “any jobs currently waiting because of protection rules” (GitHub, Manage environments).
The reviewer’s flow
sequenceDiagram participant Dev as Trigger (push to main) participant GH as GitHub Actions participant Rev as Required reviewer participant Env as production environment Dev->>GH: workflow run starts GH->>GH: deploy-prod job hits environment: production GH->>GH: automated rules pass (branch policy, wait timer) GH->>Rev: notify — deployment pending review Note over GH: job is PAUSED, prod secrets withheld Rev->>GH: click "Review deployments" alt Approve Rev->>GH: "Approve and deploy" (+ optional comment) GH->>Env: run job, expose env secrets, deploy Env-->>Dev: deployment succeeds else Reject Rev->>GH: "Reject" (+ optional comment) GH-->>Dev: workflow fails, nothing deployed end
What it shows and the insight to take: the job is suspended server-side with production secrets withheld until a reviewer clicks through — approval and rejection both accept a comment, giving the audit trail a “why.” One approval among the required reviewers is enough to release; rejection fails the whole run (GitHub, Review deployments). The gate is identity-and-evidence, not a code re-review.
jobs:
deploy-prod:
runs-on: ubuntu-latest
environment: production # ← subject to 'production' env protection rules
steps:
- run: ./deploy.sh "${{ needs.build.outputs.digest }}"That one environment: production line is the entire gate at the workflow level — everything else (who reviews, wait timer, branch policy, self-review ban) lives in the environment’s settings, deliberately outside the YAML so a PR author cannot weaken the gate by editing the workflow file.
GitLab — manual jobs behind protected environments
GitLab composes the gate from two independent primitives that are strongest together: a manual job (the pause) and a protected environment with approvals (the authorization).
Manual jobs — the pause
Adding when: manual to a job makes it wait for a human to press play (GitLab, Job control):
deploy_prod:
stage: deploy
script: ./deploy.sh "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
environment: production
when: manual
allow_failure: false # makes it a BLOCKING manual jobThe critical subtlety is blocking vs optional, governed by allow_failure:
- Blocking (
allow_failure: false, the default whenwhen: manualis set insiderules): “the pipeline stops at the stage where the job is defined” and shows a blocked status; a project with Pipelines must succeed enabled cannot merge a blocked pipeline until the job is run (GitLab, Job control). This is what you want for a real deploy gate — the pipeline genuinely halts awaiting the human. - Optional (
allow_failure: true, the default for a barewhen: manualoutsiderules): the pipeline succeeds whether or not the manual job runs — useful for an optional action (a one-click rollback, a manual smoke test) but wrong for a production gate, because the pipeline reports success without the deploy having happened. This default-flip betweenrulesand non-rulescontexts is a classic GitLab footgun.
A related timing primitive is when: delayed with start_in (e.g. start_in: 30 minutes) — a scheduled rather than human gate, whose timer “starts immediately after the previous stage completes” (GitLab, Job control).
Protected environments — the authorization
when: manual alone only means “someone with merge rights can press play.” To restrict who, wrap the environment in a protected environment: “only people with the appropriate privileges can deploy to it” (GitLab, Protected environments). You set an Allowed to deploy access level (Maintainer, or Developer+Maintainer) and can name specific users/groups. GitLab notes you can “authorize only the users associated with a protected environment to run manual jobs” — this is what closes the gap between “anyone with merge rights” and “only operators.”
Deployment approvals — count-of-N
On top of protection, deployment approvals (Premium/Ultimate) require a number of approvals before the deploy can run (GitLab, Deployment approvals):
- Configure multiple approval rules — “Add multiple approval rules to control who can approve and execute deployment jobs” — each naming roles/users/groups and a required count. The count must be ≤ the number of members in the rule.
- One approval per user — “A user can give only one approval per deployment, even if the user is a member of multiple approver groups,” preventing a single person from satisfying a rule twice.
- Self-approval is off by default — an admin can enable “Allow pipeline triggerer to approve deployment,” otherwise the person who kicked off the pipeline cannot approve it.
- Approval ≠ execution. The sharpest operational gotcha: “Deployment approval doesn’t automatically start the corresponding deployment job. You must manually run the job.” So the full sequence for a gated GitLab deploy is approve, then run — approval unblocks, a human still presses play.
stateDiagram-v2 [*] --> Created: pipeline runs Created --> Blocked: reaches protected env<br/>deploy job (approvals required) Blocked --> Approved: N approvals collected<br/>(≤ member count, 1/user) Approved --> Running: authorized user<br/>manually runs the job Running --> Deployed: success Running --> Failed: error Blocked --> Rejected: approval withheld Deployed --> [*] Failed --> [*] Rejected --> [*] note right of Approved Approval unblocks but does NOT auto-run — human still presses play end note
What it shows and the insight to take: GitLab’s gate is a two-key affair — collecting the required approvals only moves the deployment from Blocked to Approved; a separate manual run moves it to Running. Miss the second step and the deployment silently never happens despite being approved.
GitHub vs GitLab — the same gate, different shape
| Aspect | GitHub Actions | GitLab CI/CD |
|---|---|---|
| Gate primitive | Environment deployment protection rules | Manual job (when: manual) + protected environment |
| Approval model | Any-one-of up to 6 required reviewers | Count-of-N via approval rules (Premium/Ultimate) |
| Pause mechanism | Job auto-pauses on environment: until rules pass | Blocking manual job halts the stage |
| Who may approve | Named reviewers/teams on the environment | Roles/users/groups in protected-env approval rules |
| Self-approval | “Prevent self-review” toggle | Off by default; opt-in “allow triggerer to approve” |
| Approve → deploy | Approval runs the job automatically | Approval unblocks; you must still run the job |
| Time gate | Wait timer (minutes) | when: delayed + start_in |
| External policy gate | Custom deployment protection rules (GitHub App) | External integration / API-driven approval |
| Admin bypass | Optional; recorded with a mandatory comment | Governed by protected-environment settings |
The load-bearing difference to remember: GitHub approval implies deploy; GitLab approval merely unblocks — the deploy is a second, separate human action. Confusing the two is the most common cross-platform mistake.
Change-advisory and policy gates
The traditional heavyweight form of a manual gate is the Change Advisory Board (CAB) — a committee that reviews and authorizes changes. In a modern pipeline this need not be a meeting; it becomes a policy gate wired into the pipe:
- Ticket-linked gates. The deploy job checks that an approved change ticket (ServiceNow, Jira) exists and is in the right state — implemented on GitHub as a custom deployment protection rule (a GitHub App that approves the deployment only when the ticket is green), or on GitLab via an API-driven approval.
- Automated policy gates. A policy engine (Open Policy Agent, or a monitoring service like Datadog/Honeycomb) evaluates rules — “error budget not exhausted,” “no active incident,” “image is signed and provenance-verified” — and is the approver. This replaces human judgment with codified judgment where the rule is expressible, which is faster and more consistent than a human for the mechanical checks.
The research consensus (notably the Accelerate/DORA body of work, owned by Site Reliability Engineering MOC) is that heavyweight, external change-approval boards correlate with worse delivery performance and no improvement in stability — they add latency without reducing failure. The productive move is to replace the CAB meeting with peer review at the PR (Pull Request Gates and Required Checks) plus automated policy gates, reserving a human deploy approval for the genuine business-timing and high-blast-radius cases above.
Uncertain
Verify: the specific DORA finding that external change-approval processes correlate with worse performance and no stability gain. Reason: this is attributed from memory of the Accelerate/State of DevOps research and was not re-fetched from a primary source during this task. To resolve: confirm against the DORA capability catalog / Accelerate (Forsgren, Humble, Kim) and cite it in DORA Metrics and Delivery Performance.
#uncertain
Failure Modes
- Rubber-stamping. A gate everyone approves without looking is worse than no gate — it manufactures a false audit trail and slows delivery for zero risk reduction. If approvals are reflexive, the gate is misplaced; move the real checks upstream or automate the gate.
- Approval ≠ deployment (GitLab). Approving a GitLab deployment does not run it. Teams routinely approve and then wonder why prod didn’t change — the job still needs a manual run (GitLab, Deployment approvals).
allow_failuredefault flip (GitLab). Awhen: manualjob that ends upallow_failure: truelets the pipeline report success without deploying. For a real gate, setallow_failure: falseexplicitly — do not rely on defaults that differ inside vs outsiderules.- Self-approval. Without “prevent self-review” (GitHub) or with triggerer-approval enabled (GitLab), the author approves their own deploy, defeating separation of duties. Turn self-approval off for production.
- Gate weakened in the YAML. If the gate is expressed in the pipeline file, a PR can edit it away. GitHub deliberately keeps required reviewers in environment settings (outside the workflow) for this reason; GitLab keeps protection in project settings. Never let the thing being gated configure its own gate.
- Admin bypass without a trail. An unrecorded bypass is an unaudited production change. Prefer configurations where bypass is either impossible or forced to leave a comment (GitHub records both) — the audit trail is the point.
- Gate fatigue → routing around. Too many manual gates train engineers to batch changes and rush approvals, which increases batch size and risk — the opposite of the gate’s intent. Fewer, better-placed gates beat many reflexive ones.
Alternatives and When to Choose Them
- No gate — continuous deployment. If the automated suite is trustworthy and changes are decoupled from release by feature flags, remove the human gate entirely and ship every green change (Fowler, ContinuousDelivery). The “should we?” decision moves to a flag flip, off the deploy path — see Feature Flags and Decoupling Deploy from Release.
- Time gate instead of human gate. A soak/wait window (GitHub wait timer, GitLab
when: delayed) inserts a cool-off without demanding a person — useful for “let a canary bake for 30 minutes” when the decision is really “watch the metrics for a while.” - Automated policy gate instead of human. Where the judgment is expressible (error budget, signature verification, ticket state), codify it as a custom protection rule / OPA policy — faster and more consistent than a human, and it never rubber-stamps.
- Peer review at the PR instead of a deploy CAB. Move the correctness review to Pull Request Gates and Required Checks and reserve the deploy approval for genuine timing/blast-radius calls — the DORA-aligned pattern.
Production Notes
The design principle running through both platforms is separation of the gate from the gated: GitHub keeps required reviewers in environment settings and GitLab keeps them in protected-environment settings, both outside the pipeline YAML a contributor can edit — so a pull request cannot quietly disarm the production gate. Both platforms also withhold environment secrets until the gate passes (GitHub explicitly), meaning an unapproved run cannot even touch production credentials — the gate is a credential boundary, not just a workflow pause.
The most valuable habit is treating the approval comment and deployment history as a first-class audit artifact — Fowler names “a thorough audit trail” as a core purpose of the pipeline (Fowler, DeploymentPipeline), and both GitHub’s “who approved this deployment, with what comment” and GitLab’s per-environment deployment history answer the post-incident question “who authorized the change that broke prod, and why did they think it was safe?” A gate that produces no such record is providing friction without accountability — the worst of both worlds.
See Also
- Environment Promotion — the promotion ladder whose consequential hops these gates guard
- Pull Request Gates and Required Checks — the merge-time sibling gate: protects the trunk, where approval gates protect the environment
- Build Once Promote the Artifact — the gate authorizes advancing the same artifact, never a rebuild
- Feature Flags and Decoupling Deploy from Release — the alternative to a deploy gate: ship automatically, gate exposure instead
- The Deployment Pipeline — Fowler’s model in which “stages may require human authorization to proceed”
- Pipeline DAGs Stages and Gates — gates and approvals as nodes in the pipeline graph
- Least-Privilege Pipeline Runners · Secret Injection in Pipelines — why the gate is also a credential boundary
- Site Reliability Engineering MOC — owns the DORA delivery metrics and the deploy-vs-release decoupling this note cross-links
- Continuous Integration and Delivery MOC — parent MOC (§6 Delivery and Deployment)