Mert Özen Further With Every Line
DevOps CI/CD Fundamentals — #06

Approvals, Gates and Deployment Controls: Adding People and Rules to a Pipeline

Mert Özen Nov 8, 2026 10 min 4 views
Approvals, Gates and Deployment Controls: Adding People and Rules to a Pipeline

Automated gates (smoke tests, health checks, deploy freezes) should answer 'does this change work?', while a manual approval should answer 'now?'; DORA found no evidence that heavyweight external approvals lower the change fail rate. Real output showing that a Jenkins input step's submitter does nothing when everyone is an administrator, GitHub environment protection rules and Azure DevOps checks.

Version note: The examples in this post were run with .NET 10, Jenkins 2.580.2 LTS (Long-Term Support) with the Matrix Authorization Strategy plugin, act 0.2.97, actionlint 1.7.12 and Docker 29.

Guess First

In Bookstore's Jenkins pipeline the production stage waits for an approval, and submitter 'grace' is set so that only Grace can give it. To keep the Jenkins setup simple, it was installed with the "logged-in users can do anything" authorization setting. Ada goes to the approval screen and presses "Deploy". What does Jenkins do: reject it, or go to production on Ada's approval? The real console output is in the middle of the post.

Manual Approvals and Automated Gates

You can add two kinds of checkpoints to a pipeline. With a manual approval, a person says "go on". With an automated gate, a rule or a measurement decides: did the smoke test pass after the deploy, is the health check green, is the error rate in the last 10 minutes below the threshold, is this a deploy freeze period?

The two answer different questions. An automated gate is much better than a person at "does this change work?": it does not get tired, does not skip anything, and checks the same thing every time. A human approval is valuable for "should we do this now?": does the campaign start on Monday, have customers been notified, do we accept this risk? The common mistake is forcing the person to answer the first question. The approver does not read 2,000 lines of diff; they see that the tests are green and press the button. In that case the approval is not a safeguard but a waiting period.

Automated Gates: Smoke Tests and Deploy Freezes

Smoke tests: right after the deploy

We saw it in the previous post: even if a container shows as "running", the application may not be up. We find out whether a deploy really succeeded by asking the application itself:

#!/usr/bin/env bash
# Usage: smoke-test.sh <base-url>
set -euo pipefail
base_url="$1"

for attempt in 1 2 3 4 5; do
    if curl -fsS --max-time 5 "$base_url/health" > /dev/null; then
        echo "health ok (attempt $attempt)"
        curl -fsS --max-time 5 "$base_url/api/books/1" > /dev/null
        echo "smoke test passed"
        exit 0
    fi
    echo "health check failed (attempt $attempt), retrying in 3s"
    sleep 3
done

echo "smoke test failed after 5 attempts" >&2
exit 1

The script first waits for the health endpoint (the application may take a few seconds to start), then sends a request to a real business endpoint. An application whose health check is green but whose business endpoint returns 500 should count as failed too. Against the running staging and an address that does not exist:

$ ./deploy/smoke-test.sh http://localhost:18101
health ok (attempt 1)
smoke test passed
$ ./deploy/smoke-test.sh http://localhost:18199
curl: (7) Failed to connect to localhost port 18199 after 0 ms: Couldn't connect to server
health check failed (attempt 1), retrying in 3s
…
health check failed (attempt 5), retrying in 3s
smoke test failed after 5 attempts
$ echo $?
1

A non-zero exit code means the pipeline stops at that stage. A more mature gate watches the error rate and latency for a few minutes after the deploy and triggers an automatic rollback if a threshold is crossed; I will cover that together with canary releases in the upcoming Deployment Strategies series.

Deploy freezes and change windows

Not deploying during certain periods is a business decision: for Bookstore, Black Friday week and the end of the year. Keeping that decision in a file the pipeline reads, rather than on a wiki page, is more reliable:

# start       end         reason
2026-11-27  2026-11-30  black-friday
2026-12-24  2027-01-02  year-end
#!/usr/bin/env bash
# Usage: freeze-check.sh  (DEPLOY_DATE=YYYY-MM-DD to test a specific day)
set -euo pipefail
today="${DEPLOY_DATE:-$(date -u +%F)}"

while read -r start end reason; do
    [[ -z "$start" || "$start" == \#* ]] && continue
    if [[ ! "$today" < "$start" && ! "$today" > "$end" ]]; then
        echo "deploy freeze: $reason ($start..$end), today is $today" >&2
        exit 1
    fi
done < "$(dirname "$0")/freeze-windows.txt"

echo "no freeze on $today"
$ DEPLOY_DATE=2026-11-08 ./deploy/freeze-check.sh
no freeze on 2026-11-08
$ DEPLOY_DATE=2026-11-28 ./deploy/freeze-check.sh
deploy freeze: black-friday (2026-11-27..2026-11-30), today is 2026-11-28
$ DEPLOY_DATE=2026-12-31 ./deploy/freeze-check.sh
deploy freeze: year-end (2026-12-24..2027-01-02), today is 2026-12-31

Because the freeze file lives in the repository, changes to it also go through a PR (pull request); the answer to "who lifted the freeze, and why?" is in git log. A freeze also needs an exception path: what to do if a critical vulnerability turns up during a freeze should be written down in advance.

Approvals in Jenkins

Bookstore's deploy pipeline chains these two gates and a human approval in order:

pipeline {
    agent any
    options {
        disableConcurrentBuilds()
    }
    environment {
        IMAGE = "bookstore/api:${env.GIT_COMMIT.take(7)}"
    }
    stages {
        stage('Deploy to staging') {
            steps {
                echo "deploying ${IMAGE} to staging"
            }
        }
        stage('Smoke test') {
            steps {
                sh './deploy/smoke-test.sh http://cicdwork-api-staging:8080'
            }
        }
        stage('Freeze check') {
            steps {
                sh './deploy/freeze-check.sh'
            }
        }
        stage('Deploy to production') {
            input {
                message 'Deploy to production?'
                ok 'Deploy'
                submitter 'grace'
                submitterParameter 'APPROVER'
            }
            steps {
                echo "${APPROVER} approved ${IMAGE} for production"
            }
        }
    }
}

disableConcurrentBuilds() keeps two deploys from racing each other. The staging deploy here is just an echo; the real deploy command depends on the platform. The smoke test, however, is real: I put the Jenkins container and the staging API (Application Programming Interface) container on the same Docker network. With the setup from the opening question, the authorization setting where everyone can do everything, when Ada approves:

+ ./deploy/smoke-test.sh http://cicdwork-api-staging:8080
health ok (attempt 1)
smoke test passed
+ ./deploy/freeze-check.sh
no freeze on 2026-11-08
[Pipeline] { (Deploy to production)
[Pipeline] input
Deploy to production?
Deploy or Abort
Approved by ada
ada approved bookstore/api:5a8dda7 for production
Finished: SUCCESS

Ada approved, and it went to production. submitter only restricts ordinary users; Jenkins administrators can approve any input. With "logged-in users can do anything", everyone is an administrator, so the submitter line did nothing. I separated permissions with the Matrix Authorization Strategy plugin: only admin is an administrator, everyone else has read and build permissions. When I ran the same pipeline again and tried to approve as Ada, Jenkins answered:

Error You need to be grace to submit this.

When Grace approved:

Deploy to production?
Deploy or Abort
Approved by grace
grace approved bookstore/api:5a8dda7 for production
Finished: SUCCESS

Thanks to submitterParameter, the approver's name is both in the log and in a variable; it can be written to a deploy record or a notification. That is what you need for segregation of duties and an audit trail: who approved what, and when.

One more detail: in this Jenkinsfile agent is defined at pipeline level, and in the console the input waited inside the node block. So an executor is busy while the approval is pending. The Jenkins documentation says a stage-level input waits before entering that stage's agent; to avoid holding an executor for nothing, you need agent none at pipeline level and agents on the stages.

GitHub Actions: Environment Protection Rules

In GitHub Actions the approval is not defined in the workflow file but on the environment in the repository settings. The workflow only says which environment it deploys to:

name: deploy

on:
  push:
    branches: [main]

jobs:
  staging:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v7
      - run: echo "deploying ${GITHUB_SHA::7} to staging"
      - run: ./deploy/smoke-test.sh "${{ vars.STAGING_URL }}"

  production:
    needs: staging
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v7
      - run: ./deploy/freeze-check.sh
      - run: echo "deploying ${GITHUB_SHA::7} to production"

On GitHub you can add these rules to the production environment: required reviewers, up to six people or teams (one approval is enough, and the person who started the deploy can be prevented from approving their own), a wait timer between 1 and 43,200 minutes (30 days), which branches and tags may deploy to the environment, and custom deployment protection rules based on GitHub Apps. Environment secrets are not handed to the job until the protection rules pass. One limit: on the GitHub Free, Pro and Team plans, required reviewers and wait timers are only available for public repositories.

I ran this workflow with act:

$ act push -W .github/workflows/deploy.yml --var STAGING_URL=http://cicdwork-api-staging:8080 --network cicdwork-net
[deploy/staging]   | deploying 4e50775 to staging
[deploy/staging]   | health ok (attempt 1)
[deploy/staging]   | smoke test passed
[deploy/staging] 🏁  Job succeeded
[deploy/production]   | no freeze on 2026-11-08
[deploy/production]   | deploying 4e50775 to production
[deploy/production] 🏁  Job succeeded

The production job did not wait at all, because act does not apply environment protection rules; they live on GitHub's server side. The smoke test and freeze check ran locally with act; the required reviewers behaviour was not run on GitHub. That is worth remembering when testing approval mechanisms: local tools verify the pipeline's logic, not the platform's permission rules.

Tool Mapping and Azure DevOps Checks

In Azure DevOps, approvals and checks are not defined in the YAML (YAML Ain't Markup Language) file but on resources such as environments, service connections and agent pools. That has a nice consequence: someone who can change the pipeline YAML cannot remove those checks. Checks are evaluated in order: first static checks such as branch control and required template, then approvals and dynamic checks such as business hours or calling an Azure Function or a REST (Representational State Transfer) API, and finally the exclusive lock.

NeedGitHub ActionsJenkinsAzure DevOps
Human approvalEnvironment required reviewersinput + submitterApprovals check
Waiting time or time windowWait timerScript or a when conditionBusiness hours check
Deploy only from certain branchesDeployment branches and tagswhen { branch } in multibranchBranch control check
One deploy at a timeconcurrencydisableConcurrentBuilds()Exclusive lock
Decide by asking an external systemCustom deployment protection ruleScript or pluginInvoke REST API check

Tool-specific details are in the upcoming GitHub Actions, Jenkins and Azure DevOps Pipelines series.

Who May Deploy, and from Which Branch

There should be a single road to production, and it should pass through a protected branch. That has three layers. First, branch protection or rulesets: no direct pushes to main; merging needs a PR, at least one review and green required checks. I covered this in the Git & GitHub series post "Teamwork with Git: Branching Strategies, Rulesets and Conventional Commits". Second, the environment's deployment branch rule: production may only be deployed from main and v* tags. Third, permissions: the list of people who may approve, and those people not being administrators, for the reason you saw in the opening question.

Segregation of duties is often read as "the person who wrote the code cannot ship it to production". A separate approval step is not the only way to achieve that. A PR review already means a second person examines the change; the review record and the pipeline logs together form an audit trail showing who changed and approved what, and when.

What DORA Says About Heavyweight Approvals

DORA (DevOps Research and Assessment) research looked at heavyweight processes that require external approval for each change, such as a CAB (Change Advisory Board) meeting for every deploy, alongside delivery performance. The finding is clear: no evidence was found that such formal external approvals lower the change fail rate. On the contrary, because the approval process slows delivery, teams batch up changes, releases become bigger and less frequent, and those are riskier. What DORA recommends: peer review during the change, automation such as continuous testing and monitoring, and a CAB that focuses on coordination between teams and process improvement rather than approving individual changes.

For Bookstore that means: instead of an approval on every environment, everything up to staging is automatic; there is a single approval before production, and it is for "now?", not "is it correct?". As the team matures and the automated gates earn trust, that approval can go too; that is the move to continuous deployment.

In Production

Put a timeout on approvals. An unanswered approval keeps the pipeline waiting forever; use timeout on the stage in Jenkins and the check's timeout in Azure DevOps, while on GitHub jobs waiting for approval fail automatically after 30 days. An expired approval should mean the deploy is cancelled; an old build approved a week later going to production is a surprise.

Do not make the emergency path a separate path. A "skip approvals" button for hotfixes means using the least checked road at the most stressful moment. DORA recommends the same: the standard process should be fast enough to carry emergency changes too.

Send approval notifications to the right place. If the approver does not see the notification in a pile of emails, the approval waits for hours and inflates lead time.

Common Pitfalls

Making everyone an administrator: the answer to the opening question. submitter, required reviewers or approvals are decoration in a setup where approvers can bypass them with administrator rights. On GitHub, too, administrators can bypass environment rules by default; that has to be turned off separately.

Rubber-stamp approvals: if the approver does not know what they are approving, the approval only adds waiting time. The approval screen should show which commits, which image and which test results are going out.

Putting the approval before the tests: a person should not spend time approving a bug that automated checks would catch. Gates first, then the human.

Keeping the approval inside the pipeline file: anyone who can change the pipeline can also remove the approval. Put the approval rule on the platform's environment or resource settings.

A Small Experiment

★☆☆ In your own CI (Continuous Integration) tool, list who can approve a production deploy and how many of those people are administrators.

★★☆ Adapt smoke-test.sh to your own API and add it right after the staging deploy. On the next deploy, break the application on purpose (for example, remove a required setting) and see at which step the pipeline stops.

★★★ For your team's last ten production deploys, work out the time from approval request to approval. What percentage of total lead time is it?

In the next post we will protect the pipeline itself: the pipeline as the most powerful user holding production keys, secrets leaking into logs, OIDC (OpenID Connect) instead of long-lived cloud keys, and pinning third-party actions.