Deploy gates & approvals
Change control without a ticket queue.
A nightclub door has two kinds of bouncer. One reads your ID, checks the photo, checks the date. The other holds the rope and waves through anyone who looks confident. From across the street they look identical. Only one of them is security. Now look at your own door. The main pipeline has gone green: SAST (Static Application Security Testing, which reads your source code looking for flaws), dependency scanning, container scanning, and signing with cosign (a tool that cryptographically signs container images) all passed. The deploy-prod job sits there with a play button on it. An engineer clicks. The release ships. Stop on that click and ask what it verified. If the honest answer is 'nothing, the person saw green checkmarks and assumed the image was fine,' you have the second bouncer. A real deploy gate refuses to move an artifact toward production until the evidence is present and holds up under checking. Only then does it ask a human to own the timing.
A deploy gate is a deliberate checkpoint standing between a pipeline that passed and a release that ships. GitLab gives you two of them, and they do genuinely different work. The first is a pipeline job that re-checks the evidence itself: is this image signed by us, and did the scan come back clean? If either answer is no, the job fails, and an unsigned or vulnerable image cannot physically reach the deploy step. The second is a protected environment with approval rules, which parks the deployment until named approvers sign off through the API (Application Programming Interface, the machine-to-machine way of talking to GitLab) or the web interface. One gate proves the facts. The other records a person accepting them. Pipelines you would trust with real money run both.
include:- template: Jobs/Container-Scanning.gitlab-ci.ymlstages: [build, test, scan, verify, deploy]container_scanning:stage: scan # run the template's scan job in our scan stageverify-evidence:stage: verifyimage: registry.acme.internal/ci-security:2.4 # cosign + jq on a shell (alpine) baseneeds:- job: container_scanningartifacts: true # pull gl-container-scanning-report.jsonscript:- |cosign verify \--certificate-identity-regexp "^${CI_SERVER_URL}/${CI_PROJECT_PATH}/" \--certificate-oidc-issuer "${CI_SERVER_URL}" \"${IMAGE}@${DIGEST}"- |CRIT=$(jq '[.vulnerabilities[] | select(.severity=="Critical")] | length' \gl-container-scanning-report.json)echo "Critical vulnerabilities: ${CRIT}"test "${CRIT}" -eq 0 # non-zero exit fails the gaterules:- if: '$CI_COMMIT_BRANCH == "main"'deploy-prod:stage: deployimage: registry.acme.internal/deploy-tools:1.8environment:name: production # protected env + approval rulesurl: https://acme.example.comneeds: [verify-evidence] # gate must pass before deploy existsscript:- ./deploy.sh "${IMAGE}@${DIGEST}"rules:- if: '$CI_COMMIT_BRANCH == "main"'when: manual
verify-evidence is the gate that enforces. It re-runs cosign verify against the digest, the immutable fingerprint of the image contents, which cannot be quietly repointed at something else the way a tag can. Then it pins --certificate-identity-regexp to this project's own CI identity (CI is continuous integration, the automated system that builds and tests every push). That pin carries more weight than it looks. A passport with a real hologram and a valid expiry date is still the wrong passport if it belongs to somebody else. An image can carry a technically perfect signature produced by a completely different pipeline, and without the pin it walks straight through. 'Is it signed' is not the question. 'Is it signed by us' is. The job then opens gl-container-scanning-report.json, the report the scan stage left behind, and uses jq (a small command-line tool for pulling values out of JSON, the text format the report is written in) to count findings marked Critical. Anything other than zero and test exits non-zero, so the job goes red. Because deploy-prod declares needs: [verify-evidence], a red gate means the deploy job never comes into existence at all. None of this depends on an approver remembering to look.
$ cosign verify --certificate-identity-regexp "^https://gitlab.acme.internal/acme/payments/" ...Verification for registry.acme.internal/payments@sha256:9f2a3c8e... --The following checks were performed on each of these signatures:- The cosign claims were validated- Existence of the claims in the transparency log was verified offline- The code-signing certificate was verified using trusted certificate authority certificates$ CRIT=$(jq '[.vulnerabilities[] | select(.severity=="Critical")] | length' gl-container-scanning-report.json)$ echo "Critical vulnerabilities: ${CRIT}"Critical vulnerabilities: 0$ test "${CRIT}" -eq 0Job succeeded
Read that log for what it actually claims, line by line. The signing certificate chains back to a trusted CA (certificate authority, an organisation whose job is vouching for certificates). An entry for this signature exists in the Rekor transparency log, a public append-only ledger of signatures that nobody can quietly edit later. And the image carries zero Critical vulnerabilities. Swap the digest for a tampered image in the registry and cosign verify exits 1 with 'no matching signatures.' Let one Critical CVE (Common Vulnerabilities and Exposures, the public catalogue of known software flaws) slip in and the test line goes red. The verdict is deterministic. The same evidence produces the same answer on every single run, and there is nobody in the loop who can have a bad morning and skip a step.
The human half: approvals on a protected environment
Evidence settles the questions that have a right answer. Is it signed by us? Are there Critical findings? A machine handles those better than you do. The other question has no computable answer: should we push this out right now, on a Friday afternoon, in the middle of a payments freeze, while the on-call engineer is already fighting something else? That call belongs to people, and GitLab gives it a home in protected environments. A protected environment works like the two-key drawer at a bank. Marking production protected narrows who is even permitted to deploy there. Attaching approval rules on top means a set number of named users or groups must sign off before any deployment to that environment moves an inch. Two separate levers. One decides who may pull the trigger, the other decides who has to say yes. You configure both once, through the API.
$ curl --request POST \--header "PRIVATE-TOKEN: $ADMIN_TOKEN" \--header "Content-Type: application/json" \--data '{"name": "production","deploy_access_levels": [{ "access_level": 40 }],"approval_rules": [{ "group_id": 42, "required_approvals": 1 }]}' \"https://gitlab.acme.internal/api/v4/projects/812/protected_environments"{"name": "production","deploy_access_levels": [{ "access_level": 40, "access_level_description": "Maintainers" }],"approval_rules": [{ "group_id": 42, "required_approvals": 1, "group_inheritance_type": 0 }]}
With that rule live, pressing play on the when: manual deploy job releases nothing. It creates a deployment that lands straight in the blocked state, holding for one approval from the security group. That split is the whole design. when: manual governs who is allowed to trigger the job. The approval rule governs who must consent before the trigger means anything. In the gap between the two, the deployment sits on the record: visible, attributed to whoever started it, and completely idle until an approver acts through the deployment approval API.
# 1. Find the deployment waiting on approval$ curl --header "PRIVATE-TOKEN: $TOKEN" \"https://gitlab.acme.internal/api/v4/projects/812/deployments?environment=production&status=blocked"[{"id": 45231,"iid": 204,"status": "blocked","pending_approval_count": 1,"ref": "main","sha": "b83f9c1a...","deployable": { "pipeline": { "id": 90312 } }}]# 2. Approve it, on the record$ curl --request POST --header "PRIVATE-TOKEN: $TOKEN" \--data "status=approved" \--data "comment=cosign identity + zero Critical CVEs verified" \"https://gitlab.acme.internal/api/v4/projects/812/deployments/45231/approval"{"user": { "id": 57, "username": "priya.nair", "name": "Priya Nair" },"status": "approved","created_at": "2025-06-18T14:22:07.512Z","comment": "cosign identity + zero Critical CVEs verified"}
That approval response is the change record an auditor will ask you for, and it printed itself like a receipt. A named user. An explicit approved status. A timestamp down to the millisecond. A free-text comment tying the sign-off to the evidence that was actually checked. All of it bound to one deployment, one commit, one pipeline. No separate ticketing tool to keep in sync. No screenshot of a thumbs-up in a Slack thread. Rejection behaves the same way with status=rejected, and the deployment stays blocked until it expires or a newer pipeline replaces it.
You end up with change management and no second system bolted onto the side of it. Every production deploy already carries who approved it, who triggered it, at which commit, on which pipeline, and why. That is the exact trail a compliance review reconstructs six months later, and the same one you walk backwards at 3 a.m. during a post-mortem. The two controls also hold each other up. The machine gate guarantees the evidence exists in the first place. The human gate records that a named person looked at it and accepted the risk. Wire it once, and the audit trail falls out of shipping as a by-product instead of being assembled by hand every quarter.
Gate on evidence, not on a click
The failure mode this whole design kills is the rubber stamp. An approval step where the approver clicks yes every time, without opening anything, buys you the look of a control and none of the effect. It is the signature box on a form nobody reads. So move the objective checks into a job that cannot be skipped: signature identity, scan status, and, as the supply-chain course covers, SLSA provenance (Supply-chain Levels for Software Artifacts, a signed statement describing how and where a build was produced). The machine then applies the same policy the same way on every run, and the person is left holding the one decision that genuinely needs a person. A gate that verifies is a control. A gate that only prompts is paperwork.
deploy-prod job is when: manual and targets a protected production environment that requires one approval. A maintainer presses play. What happens next?when: manual decides who may trigger the job, and the protected-environment approval rule still demands consent before the deployment moves.verify-evidence runs cosign verify with --certificate-identity-regexp pinned to this project's own CI identity. What does that pin catch that a plain 'is this image signed at all?' check would wave through?jq and test check on the scan report, not by the signature-identity check.main pipeline, verify-evidence runs jq plus test "${CRIT}" -eq 0, counts 2 Critical vulnerabilities, exits non-zero and turns red. deploy-prod declares needs: [verify-evidence]. What becomes of the release?needs: [verify-evidence], a red gate means deploy-prod is never created, and the mechanical check blocks the release without anyone being asked.needs to succeed. A failed dependency means there is no button to press.verify-evidence is the gate that enforces, and its failure stops the deploy.An evidence-backed gate settles exactly one question: is this release allowed out of the building? It says nothing about how the release behaves once it is out there. Plenty of builds pass cosign, come back clean from the scanner, collect their approval, and then fall over the moment real customers touch them. That is the next lesson. Canary, blue/green, and a rollback that finishes in seconds turn 'we approved a bad deploy' into a short blip rather than a lost afternoon.
Try this
Work through “Gate on evidence, not on a click” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: a green pipeline is only as honest as the runner that produced it. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.