ActionBoxBlog

DevOpsPlatformBackendQA

Feature flag rollout approval: gate 5%, 25%, and 100% exposure

A copy-paste feature flag rollout approval workflow that gates each exposure stage with evidence, guardrails, sequential decisions, and rollback.

The code is deployed. The flag is off. The question is not "can we ship?" — it's "should 25% of production accounts see this behavior?" An exposure gate puts a human decision between one rollout stage and the next, with the evidence and the rollback plan right next to the buttons.

The pattern: approve exposure, not deployment

A deployment gate answers "is the artifact good enough for an environment?". An exposure gate answers a different question: "is the evidence good enough to widen the audience?" Keep them separate:

StageWhat changesGate
DeployArtifact lands on servers, flag offCI checks, no human needed
InternalFlag on for the teamNo gate
Beta5% of low-risk accountsApprove with evidence
Ramp25% of productionApprove with guardrails
Launch100%Approve + rollback check

Step 1: the rollout job creates the approval

bash
#!/usr/bin/env bash
# promote-flag.sh — ask before widening exposure
set -euo pipefail

FLAG_KEY="${1:?flag key}"
NEXT_PERCENT="${2:?next rollout percent}"

CURRENT=$(curl -s -H "Authorization: Bearer $FLAGS_API_TOKEN" \
  "$FLAGS_API_URL/flags/$FLAG_KEY" | jq -r .rollout.percent)

if [ "$NEXT_PERCENT" -le "$CURRENT" ]; then
  echo "Rollout is not increasing ($CURRENT% -> $NEXT_PERCENT%)"
  exit 0
fi

actionbox ask \
  "Expand ${FLAG_KEY} from ${CURRENT}% to ${NEXT_PERCENT}%?" \
  --option approve="Approve expansion" \
  --option hold="Hold at ${CURRENT}%" \
  --context-json "[{\"type\":\"key_value\",\"items\":{\"flag\":\"${FLAG_KEY}\",\"from\":\"${CURRENT}%\",\"to\":\"${NEXT_PERCENT}%\",\"guardrails\":\"error_rate < 0.5% · p95 < 800ms\"}}]" \
  --callback-url https://ops.acme.com/actionbox/flag-callback

The callback does the actual flag update only when the decision resolves to approve — the script never touches the flag directly.

Step 2: attach the evidence to the approval

An approval without evidence is a rubber stamp. Include the numbers that justify the next stage:

bash
actionbox ask \
  "Expand checkout_v2 to 25% of accounts?" \
  --option approve="Approve expansion" \
  --option hold="Hold at 5%" \
  --context-json "[{\"type\":\"key_value\",\"items\":{\"flag\":\"checkout_v2\",\"from\":\"5%\",\"to\":\"25%\",\"error_rate_24h\":\"0.12%\",\"checkout_completion\":\"+1.4% vs baseline\",\"p95_latency\":\"742ms\",\"support_tickets\":\"0 flag-related\",\"rollback\":\"set flag to baseline for affected segment\"}}]" \
  --callback-url https://ops.acme.com/actionbox/flag-callback

The approver sees: what's changing, the guardrail signals, and the exact rollback action — in the dashboard, before tapping approve.

Step 3: fail-closed on timeout

Nobody answers in time? The rollout should not happen. Make the default explicit:

bash
actionbox ask \
  "Expand ${FLAG_KEY} to ${NEXT_PERCENT}%?" \
  --option approve="Approve expansion" \
  --option hold="Hold at ${CURRENT}%" \
  --expires "$(date -u -d '+4 hours' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v+4H '+%Y-%m-%dT%H:%M:%SZ')" \
  --on-expire-json '{"type":"resolve","response":{"type":"single_choice","value":"hold"},"reason":"no response within rollout SLA"}' \
  --callback-url https://ops.acme.com/actionbox/flag-callback

The audit trail records: the request, the decision (or the non-decision), and the reason — so the Friday 5pm "why did the flag stay at 5%?" question answers itself.

Full example: staged rollout with a cleanup reminder

bash
#!/usr/bin/env bash
# staged-rollout.sh — 5% -> 25% -> 100%, each stage gated
set -euo pipefail

FLAG_KEY="billing_checkout_v2"
STAGES=(5 25 100)
ROLLBACK="set ${FLAG_KEY} to baseline for affected segment"

for NEXT in "${STAGES[@]}"; do
  CURRENT=$(curl -s -H "Authorization: Bearer $FLAGS_API_TOKEN" \
    "$FLAGS_API_URL/flags/$FLAG_KEY" | jq -r .rollout.percent)
  if [ "$NEXT" -le "${CURRENT:-0}" ]; then
    continue
  fi

  actionbox ask \
    "Expand ${FLAG_KEY} to ${NEXT}%?" \
    --option approve="Approve expansion to ${NEXT}%" \
    --option hold="Hold at ${CURRENT}%" \
    --context-json "[{\"type\":\"key_value\",\"items\":{\"flag\":\"${FLAG_KEY}\",\"stage\":\"${NEXT}%\",\"rollback\":\"${ROLLBACK}\"}}]" \
    --expires "$(date -u -d '+4 hours' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v+4H '+%Y-%m-%dT%H:%M:%SZ')" \
    --on-expire-json "{\"type\":\"resolve\",\"response\":{\"type\":\"single_choice\",\"value\":\"hold\"},\"reason\":\"stage timeout\"}" \
    --callback-url https://ops.acme.com/actionbox/flag-callback
done

Why this beats "just merge the PR"

  • Approval is about exposure, not code — the artifact shipped days ago; the decision is about the audience
  • Guardrails travel with the request — error rate, latency, and tickets are shown to the approver, not buried in a dashboard
  • Rollback is one tap away in context — the approver knows the exact revert before approving
  • Same pattern for every flag — LaunchDarkly, Unleash, PostHog, or your own flag service: the gate is just an API call

Try it

  1. Install the CLI on your release host
  2. Create a Source → ACTIONBOX_TOKEN
  3. Replace your "change the percentage" script with the gated version

Create a free Source · CLI + callback reference · Cron jobs that ask before acting

Try this workflow in minutes

Create a free Source, then run the exact commands from this post against the live API — no approval infrastructure to build.

S
Suson Sapkota

Founder, Actionbox