Deploy approvals and manual gates
A human confirmation before a production publish — that prompts when a person runs it, and auto-skips to a safe default in CI. One workflow, two audiences, no separate 'CI version'.
blog/deploy-approvalsProduction deploys want a human in the loop. Not a heavyweight change-board for every release, but a moment where someone says "yes, ship it" before the publish happens. The trouble is that the same workflow has to run two ways: interactively, where an engineer is at a terminal and should be asked, and non-interactively, in CI, where there's no one to answer and a hanging prompt deadlocks the runner forever.
Teams usually solve this by forking the logic — a "manual" script with the confirmation and an "automated" one without — and then the two drift, and the automated path quietly loses the gate it was supposed to have.
OrchStep's prompt function is built for exactly this split. The same step asks a human when there's a human, and falls back to its default when there isn't.
The workflow
name: release
# One workflow for humans and CI: a confirm prompt gates the publish.
# Interactively it asks; non-interactively it uses the default and auto-skips.
defaults:
version: "1.4.0"
tasks:
# `orchstep run publish`
publish:
steps:
- name: build
func: shell
do: echo "packaging release {{ vars.version }}"
- name: approve
func: prompt
args:
message: "Publish {{ vars.version }} to production?"
type: confirm
default: false
- name: gate
if: '{{ eq steps.approve.value "true" }}'
then:
- name: ship
func: shell
do: echo "publishing {{ vars.version }} to production"
else:
- name: hold
func: shell
do: echo "approval not granted; publish skipped"The same step, three ways
The approve step is a confirm prompt with default: false. That default is the whole trick — it's what happens when nobody answers.
# Human at a terminal — the prompt appears and waits
orchstep run publish
# CI runner — stdin isn't a terminal, so the prompt auto-skips to its default (false)
ORCHSTEP_NON_INTERACTIVE=true orchstep run publish
# Approve without typing — provide the value by the step's name
orchstep run publish --var approve=trueA prompt step skips automatically when stdin isn't a terminal, when ORCHSTEP_NON_INTERACTIVE=true, or when a --var matching the step name supplies the answer. So in CI the gate doesn't hang — it resolves to false and the deploy holds. The safe outcome is the default outcome, which is the property you want from a guard. Full behavior: Functions: prompt.
The gate reads the answer
The publish itself is conditional on what the prompt returned:
- name: gate
if: '{{ eq steps.approve.value "true" }}'
then:
- name: ship
func: shell
do: echo "publishing {{ vars.version }} to production"
else:
- name: hold
func: shell
do: echo "approval not granted; publish skipped"steps.approve.value is the prompt's result — "true" only if a human confirmed or someone passed --var approve=true. Default false means an unattended run takes the else branch and doesn't publish. To actually ship from automation you have to be explicit about it, which is the right amount of friction for a production gate.
Wiring it into a pipeline
In CI you make the approval a deliberate input — a workflow-dispatch field, a protected-environment step, a manual job. Your runner then passes it through:
orchstep run publish --var version=1.4.0 --var approve=$APPROVEDNo approval, no publish — and the build and packaging steps before the gate still run, so you get a fully prepared artifact that's just waiting on the word.
What you gained
| Concern | Forked scripts | OrchStep |
|---|---|---|
| Ask a human | "manual" script | func: prompt, type: confirm |
| Don't hang CI | "automated" script | auto-skip to default |
| One source of truth | two files that drift | one workflow |
| Gate the publish | hope it's wired | if: on steps.approve.value |
| Approve from CI | bespoke flag | --var approve=true |
Where to go next
- Functions: prompt — text, confirm, select, and non-interactive mode
- Variables & Outputs — reading a step's result downstream
- Error Handling — what runs when a gate holds
Install with curl -fsSL https://orchstep.dev/install.sh | sh. Run orchstep run publish once at your terminal and once with ORCHSTEP_NON_INTERACTIVE=true to see both paths.
curl -fsSL https://orchstep.dev/install.sh | sh