Recipes
All examples use three settings in your CI system:
| Name | Kind | Value |
|---|---|---|
PLAYBOOK_URL |
variable | https://deployment-playbook.com |
PLAYBOOK_TEMPLATE_ID |
variable | the Template ID (Templates → your template → Copy) |
PLAYBOOK_API_TOKEN |
secret | a workspace API token (Admin → Connections & webhooks) |
How the gate maps to pipeline jobs
GET /api/gate/<planId>?phase=<p> answers "may work in phase p start?". So:
| Job waits until… | Poll |
|---|---|
| the plan is approved (nothing else) | ?phase=before |
| every Before step is done | ?phase=during |
| every During step is done | ?phase=after |
| every After step is done (plan complete) | ?phase=after and planStatus == "done" |
?phase=after becomes allow: true as soon as Before and During are done, before any After step is approved.
Use planStatus == "done" for a final job that must only turn green when the whole plan is complete.
Create a plan on a git tag
POST /api/plans is idempotent per repository + ref: re-running the pipeline for the same tag returns the same
plan (200, created: false) instead of creating a duplicate.
curl -fsS -X POST "$PLAYBOOK_URL/api/plans" \
-H "Authorization: Bearer $PLAYBOOK_API_TOKEN" -H "Content-Type: application/json" \
-d "{\"templateId\":\"$PLAYBOOK_TEMPLATE_ID\",\"ref\":\"v1.4.0\",\"repository\":\"acme/shop\",\"commitSha\":\"$(git rev-parse HEAD)\",\"environment\":\"production\"}"
# => {"id":"cm…","name":"Release v1.4.0","status":"PROPOSED","planUrl":"…","gateUrl":"…/api/gate/cm…","created":true}
A reusable polling script
Save as scripts/wait-for-playbook.sh (bash + curl + jq). It fails closed: a non-200 response or invalid JSON
counts as "not allowed" and polling continues until the timeout.
#!/usr/bin/env bash
# usage: wait-for-playbook.sh <planId> <before|during|after> [require-done] [timeout-minutes]
set -uo pipefail
plan="$1"; phase="$2"; require_done="${3:-false}"; timeout="${4:-60}"
deadline=$(( $(date +%s) + timeout * 60 ))
while :; do
body=$(curl -sS --max-time 30 -w '\n%{http_code}' -H "Authorization: Bearer $PLAYBOOK_API_TOKEN" \
"$PLAYBOOK_URL/api/gate/$plan?phase=$phase" 2>/dev/null) || body=$'\n000'
code=${body##*$'\n'}; json=${body%$'\n'*}
allow=false; status=""
if [ "$code" = 200 ] && echo "$json" | jq -e 'type=="object"' >/dev/null 2>&1; then
allow=$(echo "$json" | jq -r '.allow == true'); status=$(echo "$json" | jq -r '.planStatus // ""')
echo "allow=$allow planStatus=$status reason=$(echo "$json" | jq -r .reason) $(echo "$json" | jq -r .planUrl)"
else echo "HTTP $code (not allowed)"; fi
if [ "$allow" = true ] && { [ "$require_done" != true ] || [ "$status" = done ]; }; then exit 0; fi
[ "$status" = rolled_back ] && { echo "plan rolled back"; exit 1; }
[ "$(date +%s)" -ge "$deadline" ] && { echo "timed out"; exit 1; }
sleep 15
done
GitHub Actions
The complete, maintained version (composite actions create-playbook-plan and wait-for-playbook, job summary,
Vercel bypass support) is in
Generation-Y-BV/deployment-playbook-example.
The shape:
name: Deploy
on:
push:
tags: ['v*']
jobs:
create-plan:
runs-on: ubuntu-latest
outputs:
plan-id: ${{ steps.plan.outputs.id }}
steps:
- id: plan
env:
PLAYBOOK_URL: ${{ vars.PLAYBOOK_URL }}
TEMPLATE_ID: ${{ vars.PLAYBOOK_TEMPLATE_ID }}
TOKEN: ${{ secrets.PLAYBOOK_API_TOKEN }}
run: |
body=$(jq -n --arg t "$TEMPLATE_ID" --arg r "$GITHUB_REF_NAME" --arg repo "$GITHUB_REPOSITORY" --arg sha "$GITHUB_SHA" \
--arg run "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{templateId:$t, ref:$r, repository:$repo, commitSha:$sha, runUrl:$run}')
res=$(curl -fsS -X POST "$PLAYBOOK_URL/api/plans" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$body")
echo "id=$(echo "$res" | jq -r .id)" >> "$GITHUB_OUTPUT"
echo "Plan: $(echo "$res" | jq -r .planUrl)" >> "$GITHUB_STEP_SUMMARY"
before: # waits until every Before step is done
needs: create-plan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: scripts/wait-for-playbook.sh "${{ needs.create-plan.outputs.plan-id }}" during
env: { PLAYBOOK_URL: '${{ vars.PLAYBOOK_URL }}', PLAYBOOK_API_TOKEN: '${{ secrets.PLAYBOOK_API_TOKEN }}' }
deploy: # runs in parallel with "during"
needs: [create-plan, before]
runs-on: ubuntu-latest
steps:
- run: echo "deploy here"
during: # waits until every During step is done
needs: [create-plan, before]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: scripts/wait-for-playbook.sh "${{ needs.create-plan.outputs.plan-id }}" after
env: { PLAYBOOK_URL: '${{ vars.PLAYBOOK_URL }}', PLAYBOOK_API_TOKEN: '${{ secrets.PLAYBOOK_API_TOKEN }}' }
after-deployment: # green only when the After steps are done (planStatus == done)
needs: [create-plan, deploy, during]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: scripts/wait-for-playbook.sh "${{ needs.create-plan.outputs.plan-id }}" after true
env: { PLAYBOOK_URL: '${{ vars.PLAYBOOK_URL }}', PLAYBOOK_API_TOKEN: '${{ secrets.PLAYBOOK_API_TOKEN }}' }
GitLab CI
Store PLAYBOOK_API_TOKEN as a masked, protected CI/CD variable; PLAYBOOK_URL and PLAYBOOK_TEMPLATE_ID as
variables.
stages: [plan, before, deploy, after]
default:
image: alpine:3.20
before_script: [apk add --no-cache bash curl jq]
workflow:
rules:
- if: $CI_COMMIT_TAG =~ /^v/
create-plan:
stage: plan
script:
- |
body=$(jq -n --arg t "$PLAYBOOK_TEMPLATE_ID" --arg r "$CI_COMMIT_TAG" --arg repo "$CI_PROJECT_PATH" \
--arg sha "$CI_COMMIT_SHA" --arg run "$CI_PIPELINE_URL" '{templateId:$t, ref:$r, repository:$repo, commitSha:$sha, runUrl:$run}')
res=$(curl -fsS -X POST "$PLAYBOOK_URL/api/plans" -H "Authorization: Bearer $PLAYBOOK_API_TOKEN" -H "Content-Type: application/json" -d "$body")
echo "PLAN_ID=$(echo "$res" | jq -r .id)" > plan.env
echo "Plan: $(echo "$res" | jq -r .planUrl)"
artifacts:
reports:
dotenv: plan.env
before:
stage: before
timeout: 2h
script: [bash scripts/wait-for-playbook.sh "$PLAN_ID" during]
deploy: # same stage as "during" => runs in parallel
stage: deploy
script: [echo "deploy here"]
during:
stage: deploy
timeout: 2h
script: [bash scripts/wait-for-playbook.sh "$PLAN_ID" after]
after-deployment:
stage: after
timeout: 2h
script: [bash scripts/wait-for-playbook.sh "$PLAN_ID" after true]
Azure DevOps Pipelines
Add PLAYBOOK_API_TOKEN as a secret pipeline variable (or variable group) and PLAYBOOK_URL,
PLAYBOOK_TEMPLATE_ID as normal variables. Secret variables must be mapped into env explicitly.
trigger:
tags:
include: ['v*']
pool:
vmImage: ubuntu-latest
stages:
- stage: plan
jobs:
- job: create_plan
steps:
- bash: |
body=$(jq -n --arg t "$(PLAYBOOK_TEMPLATE_ID)" --arg r "$(Build.SourceBranchName)" --arg repo "$(Build.Repository.Name)" \
--arg sha "$(Build.SourceVersion)" --arg run "$(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId)" \
'{templateId:$t, ref:$r, repository:$repo, commitSha:$sha, runUrl:$run}')
res=$(curl -fsS -X POST "$(PLAYBOOK_URL)/api/plans" -H "Authorization: Bearer $PLAYBOOK_API_TOKEN" -H "Content-Type: application/json" -d "$body")
echo "##vso[task.setvariable variable=planId;isOutput=true]$(echo "$res" | jq -r .id)"
echo "Plan: $(echo "$res" | jq -r .planUrl)"
name: plan
env: { PLAYBOOK_API_TOKEN: $(PLAYBOOK_API_TOKEN) }
- stage: before
dependsOn: plan
variables: { planId: $[ stageDependencies.plan.create_plan.outputs['plan.planId'] ] }
jobs:
- job: wait
timeoutInMinutes: 120
steps:
- bash: bash scripts/wait-for-playbook.sh "$(planId)" during
env: { PLAYBOOK_URL: $(PLAYBOOK_URL), PLAYBOOK_API_TOKEN: $(PLAYBOOK_API_TOKEN) }
- stage: deploy_and_during
dependsOn: [plan, before]
variables: { planId: $[ stageDependencies.plan.create_plan.outputs['plan.planId'] ] }
jobs:
- job: deploy # jobs in one stage run in parallel
steps: [ { bash: echo "deploy here" } ]
- job: during
timeoutInMinutes: 120
steps:
- bash: bash scripts/wait-for-playbook.sh "$(planId)" after
env: { PLAYBOOK_URL: $(PLAYBOOK_URL), PLAYBOOK_API_TOKEN: $(PLAYBOOK_API_TOKEN) }
- stage: after_deployment
dependsOn: [plan, deploy_and_during]
variables: { planId: $[ stageDependencies.plan.create_plan.outputs['plan.planId'] ] }
jobs:
- job: wait
timeoutInMinutes: 120
steps:
- bash: bash scripts/wait-for-playbook.sh "$(planId)" after true
env: { PLAYBOOK_URL: $(PLAYBOOK_URL), PLAYBOOK_API_TOKEN: $(PLAYBOOK_API_TOKEN) }
Alternatively, gate an Azure DevOps environment with Approvals and checks → Invoke REST API:
URL $(PLAYBOOK_URL)/api/gate/<planId>?phase=during, header Authorization: Bearer <token>, success criteria
eq(root['allow'], true).
Gate a deploy per phase
Use the table at the top: phase=during before you deploy (Before phase done), phase=after once the During checks
are done, and phase=after + planStatus == "done" for the final job.
Deploy and During in parallel
Start the deploy job and a during job that polls ?phase=after at the same time, both after the before job.
Your deploy runs while people tick off the During steps (watch the rollout, smoke tests); the final job needs both.
Final job green only when After is done
Poll ?phase=after and require planStatus == "done" (see the script's require-done argument). A plan becomes
done automatically when its last step is approved or skipped. Fail the job if planStatus becomes rolled_back.
Gate on a single step (&step=)
?step=<planStepId> additionally requires every earlier step in the same phase to be resolved. Example: let the
deploy job wait for everything before Trigger the deploy (so the Go or no-go decision must be approved):
curl -fsS -H "Authorization: Bearer $PLAYBOOK_API_TOKEN" \
"$PLAYBOOK_URL/api/gate/$PLAN_ID?step=$TRIGGER_STEP_ID" | jq -e .allow
Step ids are shown in the plan's webhook payloads (step.id) and via the MCP tool get_plan.
Environments
environment is free text on each plan (production, staging, eu-prod, …). Create one plan per environment you
release to; with the API, pass environment and make the ref unique per environment if you want separate plans for
the same tag (e.g. v1.4.0@staging), because idempotency is keyed on repository + ref.
Idempotent re-runs
Re-running a pipeline for the same tag calls POST /api/plans with the same repository + ref and gets the
existing plan back (200, created: false), so approvals already given are kept. To start over, create a new tag
(or pass a different ref).
Status webhooks (outgoing)
Set a webhook URL on Connections & webhooks. Every plan/step status change is POSTed as JSON with headers:
X-Playbook-Event: e.g.plan.approved,step.approved,plan.doneX-Playbook-Timestamp: unix secondsX-Playbook-Signature-256:sha256=+ hex HMAC-SHA256 of<timestamp>.<raw body>with the webhook secret
Verify (Node.js):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyPlaybookWebhook(rawBody, headers, secret) {
const ts = headers["x-playbook-timestamp"];
const sig = headers["x-playbook-signature-256"] ?? "";
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
Payload fields: event, status, planStatus, phase, steps {approved, skipped, total}, plan {id, name, environment, fourEyes, status}, step {…} (for step events), signedOffBy[], planUrl, workspace {id, slug},
timestamp. Deliveries are logged on the Connections page; failures never block approvals.