Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions .github/workflows/promotion.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: Promote Vapi resources

on:
push:
branches: [main]
paths:
- promotion.yml
- resources/**
workflow_dispatch:
inputs:
pipeline:
description: Pipeline name from promotion.yml
required: false
type: string
from:
description: Source org (requires a destination org)
required: false
type: string
to:
description: Destination org (requires a source org)
required: false
type: string

permissions:
contents: write

concurrency:
group: vapi-promotion
cancel-in-progress: false

jobs:
promote:
if: >-
github.event_name == 'workflow_dispatch' ||
(vars.VAPI_PROMOTION_ENABLED == 'true' &&
!contains(github.event.head_commit.message, '[skip promotion]'))
runs-on: ubuntu-latest
env:
VAPI_PROMOTION_TOKENS: ${{ secrets.VAPI_PROMOTION_TOKENS }}
PIPELINE: ${{ inputs.pipeline }}
SOURCE_ORG: ${{ inputs.from }}
TARGET_ORG: ${{ inputs.to }}

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci

- name: Reconcile configured promotions
id: promotion
shell: bash
run: |
set -euo pipefail
test -f promotion.yml || {
echo "promotion.yml is required; copy promotion.example.yml and customize it."
exit 1
}
test -n "$VAPI_PROMOTION_TOKENS" || {
echo "The VAPI_PROMOTION_TOKENS repository secret is required."
exit 1
}

if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
supplied=0
[[ -n "$PIPELINE" ]] && ((supplied += 1))
[[ -n "$SOURCE_ORG" ]] && ((supplied += 1))
[[ -n "$TARGET_ORG" ]] && ((supplied += 1))
[[ "$supplied" == 0 || "$supplied" == 3 ]] || {
echo "Provide pipeline, from, and to together, or leave all three blank."
exit 1
}
args=(--apply)
if [[ "$supplied" == 3 ]]; then
args+=(--pipeline "$PIPELINE")
args+=(--from "$SOURCE_ORG" --to "$TARGET_ORG")
else
args+=(--all)
fi
npm run promote -- "${args[@]}"
else
npm run promote -- --all --apply
fi

- name: Commit reconciled files and UUID state
if: always()
shell: bash
env:
PROMOTION_OUTCOME: ${{ steps.promotion.outcome }}
run: |
set -euo pipefail
paths=(':(glob).vapi-state.*.json')
if [[ "$PROMOTION_OUTCOME" == "success" ]]; then
paths+=(resources)
fi

if [[ -z "$(git status --porcelain -- "${paths[@]}")" ]]; then
echo "Promotion produced no Git changes."
exit 0
fi

git config user.name "vapi-gitops[bot]"
git config user.email "vapi-gitops[bot]@users.noreply.github.com"
git add -A -- "${paths[@]}"
git commit -m "chore: record promoted Vapi state [skip promotion]"
for attempt in 1 2 3; do
git pull --rebase origin main
if git push; then
exit 0
fi
echo "main advanced during promotion; retrying ($attempt/3)."
done
exit 1
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ If you're unsure where something goes, default to `docs/learnings/`. The README
| Create a multi-agent squad | Create `resources/<org>/squads/<name>.yml` |
| Add post-call analysis | Create `resources/<org>/structuredOutputs/<name>.yml` |
| Write test simulations | Create files under `resources/<org>/simulations/` |
| Promote resources across orgs | Copy files between `resources/<org-a>/` and `resources/<org-b>/` |
| Promote resources across orgs | `npm run promote -- --pipeline <name> --from <org-a> --to <org-b> --apply` |
| Deploy local changes (default) | `npm run apply -- <org>` — pull → merge → push, safe against dashboard drift |
| Pre-flight schema check (no network) | `npm run validate -- <org>` — run before every `apply` |
| Audit state/dashboard drift (read-only) | `npm run audit -- <org>` — orphans, ghosts, content-identical clusters, inline-tools. Exit 1 on findings. |
Expand Down Expand Up @@ -877,6 +877,8 @@ npm run push -- <org> --strict # Abort push if any validator
npm run push -- <org> --allow-new-files # Bypass orphan-YAML gate (use only after confirming each orphan is intentionally new — see "Orphan-YAML gate" section above)
npm run apply -- <org> # Pull then push (full sync)
npm run apply -- <org> --allow-new-files # Same, propagating the bypass through to the push stage
npm run promote -- --pipeline <name> --from <source> --to <target> # Read-only promotion plan
npm run promote -- --pipeline <name> --from <source> --to <target> --apply # Forward-only scoped mirror + deploy
npm run validate -- <org> # Lint resources locally (fails fast on schema drift)
npm run audit -- <org> # Read-only drift detector: orphan YAML, state ghosts, content-identical clusters, sibling base-slugs, dashboard orphans, inline model.tools. Exit 1 on findings.
npm run audit -- <org> --type assistants # Scope audit to a single resource type
Expand Down Expand Up @@ -1008,4 +1010,3 @@ When transferring to human:
3. Create simulations (pair personality + scenario)
4. Create suites (batch simulations together)
5. Run via Vapi dashboard or API

121 changes: 103 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Every command works in two modes:
| `npm run setup` | ✅ | — | First-time org wizard — creates `.env.<org>` and `resources/<org>/`. |
| `npm run validate` | — | `npm run validate -- <org>` | Schema-check local YAML/MD with no network call. **Run before every `apply`.** |
| `npm run audit` | — | `npm run audit -- <org> [--type <t>]` | Read-only drift detector — orphan local YAML, state ghosts, UUID collisions, content-identical clusters, sibling base-slug clusters, dashboard orphans, assistants with inline `model.tools`. Exit 1 on any finding; safe to wire into CI. |
| `npm run promote` | — | `npm run promote -- --pipeline <name> --from <org> --to <org> [--apply]` | Plan or apply a forward-only, dependency-aware promotion defined by `promotion.yml`. |
| `npm run apply` | ✅ | `npm run apply -- <org> [--force]` | **Default deploy verb.** Pull → merge → push in one safe pass; resilient against dashboard drift. |
| `npm run pull` | ✅ | `npm run pull -- <org> [flags]` | Fetch remote state into local files / state file. Local-first by default — won't clobber local edits. |
| `npm run push` | ✅ | `npm run push -- <org> [flags]` | Raw push without a pre-pull. Refuses by default when local YAML files lack state entries (orphan-YAML gate); pass `--allow-new-files` to bypass after confirming intent. **Skip unless you just ran `pull` and are certain state is fresh** — otherwise prefer `apply`. |
Expand Down Expand Up @@ -315,35 +316,119 @@ vapi-gitops/

### Promoting Resources Across Orgs

For a safe, non-deployable dev → staging → production walkthrough, see the
[dummy multi-org example](examples/cross-org-promotion/README.md). It includes
fake API tokens, illustrative state mappings, and a tool → assistant dependency
in each org. Nothing under `examples/` is loaded by the GitOps engine.
Copy `promotion.example.yml` to `promotion.yml` and define any number of orgs
in their allowed one-way order. Each pipeline also declares the resource
patterns it owns. Those patterns are a safety boundary: matching destination
files are mirrored, including deletions, while unrelated destination files are
left alone.

#### One-time promotion setup

1. Configure every org with `npm run setup`, using one stable slug per org
(for example `acme-dev`, `acme-staging`, and `acme-prod`).
2. Run `npm run pull -- <org> --bootstrap` for every org. Commit each
`.vapi-state.<org>.json`; never commit `.env.<org>` or
`.vapi-state-hash/`.
3. Copy `promotion.example.yml` to `promotion.yml`. List orgs in forward-only
release order and set the correct `baseUrl` for each region.
4. Give each pipeline the narrowest resource globs it owns. A pattern is both
the copy boundary and the deletion boundary; avoid `**/*` unless the whole
destination org is intentionally a mirror.
5. Choose `bind` or `omit` for credentials and phone numbers in each target
org. Pull generates stable aliases in `.env.<org>` from resources that
already exist there; promotion never copies secrets or provisions numbers.
6. Run the read-only plan for each transition before the first apply and
confirm every create, update, and delete is expected.

```bash
# Copy a squad from dev to production
cp resources/my-org/squads/voice-squad.yml resources/production/squads/
cp resources/my-org/assistants/intake-agent.md resources/production/assistants/

# Push to production (missing dependencies auto-resolve)
npm run push -- production
```
# Read-only plan; no files or APIs change
npm run promote -- --pipeline release --from dev --to staging

# Reconcile files, org-local bindings, UUID state, and Vapi
npm run promote -- --pipeline release --from dev --to staging --apply
```

Promotion copies logical references, not physical UUIDs. Dependencies such as
tools and structured outputs are included before assistants, and the normal
destination push resolves every logical name through
`.vapi-state.<destination>.json`. Existing credentials and phone numbers use
the destination org's binding; their secret material is never copied or
provisioned.

Plan mode performs no file or API writes. Like the rest of this template,
loading a `.ts` resource executes its default-export module so dependencies can
be inspected; only run plans from reviewed branches when TypeScript resources
are present.

The merged `promotion.yml` and resource diff are the reviewed plan. That is why
CI may deliberately pass `--allow-new-files`: the PR already names the pipeline
and limits the files that are authorized to become new destination resources.

#### GitHub Actions

The bundled `Promote Vapi resources` workflow supports both automatic and
manual runs:

1. Commit `promotion.yml`.
2. Add a repository secret named `VAPI_PROMOTION_TOKENS` containing a JSON map
from org slug to that org's private API token, for example
`{"dev":"...","staging":"...","prod":"..."}`.
3. Set the repository variable `VAPI_PROMOTION_ENABLED=true` to reconcile all
adjacent transitions after changes land on `main`. This continuously
converges the full pipeline in one run, including the final production org.
4. In **Settings → Actions → General → Workflow permissions**, allow GitHub
Actions to read and write repository contents. If branch protection blocks
bot pushes to `main`, explicitly allow this workflow or use an equivalent
reviewed state-commit path.
5. For a controlled single transition, run the workflow manually and provide
`pipeline`, `from`, and `to`.

Automatic runs watch committed changes to `promotion.yml` and `resources/**`.
A manual run with no inputs reconciles every adjacent transition; supplying
inputs requires all three values and reconciles only that transition.

After a successful apply, the workflow commits destination files and the
updated, UUID-only state files back to `main` with `[skip promotion]`. This
keeps Git as the durable record of each org's posture without committing API
tokens, credential secrets, phone-number provisioning, or developer-local hash
baselines.

For a complete fake dev → staging → production fixture, see the
[dummy multi-org example](examples/cross-org-promotion/README.md). Nothing under
`examples/` is loaded by the engine.

#### Source-org and deletion boundary

Promotion treats `resources/<source>/` in Git as the reviewed desired state for
downstream orgs. It applies destination orgs only; deploy or pull the source org
through its normal GitOps lifecycle separately.

For a mirrored deletion, delete the managed source file in the PR but keep its
committed source-state UUID mapping until the downstream workflow succeeds.
That mapping is the tombstone proving the resource was previously managed, so
an empty or misconfigured source cannot wipe a destination accidentally. The
workflow carries an authorized deletion through every adjacent org in the same
run, removes each destination mapping after its API deletion, and leaves files
outside the pipeline patterns untouched. Reconcile the source org and commit
its cleaned state after downstream deletion completes.

See [sync behavior](docs/learnings/sync-behavior.md#cross-org-promotion-deletions)
for the exact lifecycle.

#### Rolling Back a Promotion

Treat a promotion rollback as a new, auditable Git change: revert the commit
that changed the promoted resources, then apply the destination org again.
Treat a promotion rollback as a new, auditable Git change: revert the source
configuration commit, then run the same forward promotion again.

```bash
git revert <promotion-commit>
npm run apply -- production
npm run promote -- --pipeline release --from dev --to staging --apply
```

This is distinct from `npm run rollback`, which restores a single org from a
local pre-deploy snapshot. Today, `apply` does not delete dashboard resources
whose files were removed by the revert; use the explicitly gated cleanup flow
for those deletions. A mirror-style promotion workflow must include deletions
for `git revert` plus re-promotion to fully restore the prior desired state.
local pre-deploy snapshot. Because promotion uses the pipeline's scoped mirror
boundary, reverting a resource creation also removes that promoted resource
from the destination without touching unrelated destination resources.

---

Expand Down
30 changes: 27 additions & 3 deletions docs/learnings/sync-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,13 @@ stale pointer. Never blocks, never prompts.
| Command | Behavior |
|---|---|
| `pull` | 🗑️ deletion intent honored — file is NOT re-materialized; state entry kept |
| `push` | resource not loaded → state-without-file = orphan candidate. Plain push **leaves the dashboard untouched**. Actual deletion is the double-gated cleanup verb: `npm run cleanup -- <org> --force --confirm <org>` |
| `push` / `apply` | resource not loaded → state-without-file = orphan candidate. Without `--force`, the dashboard is left untouched and the pending deletion is printed. With `--force`, reference-safe state-tracked orphans are deleted in reverse dependency order. `promotion.yml` uses this path only after its scoped mirror has removed the matching destination file. |
| to stop tracking entirely | add it to `.vapi-ignore` (it will never re-appear on pull) |

A first-class "delete locally → apply deletes on platform" flow is **not yet
supported** — deletion stays an explicit, double-gated operation.
`cleanup --force --confirm <org>` remains the explicit whole-org cleanup verb.
For ordinary GitOps deletion, `apply --force` is the deliberate deletion gate;
for cross-org promotion, the reviewed promotion resource patterns are the
additional ownership boundary.

### D. Tracked locally, deleted on the dashboard (L + S + B, no D)

Expand Down Expand Up @@ -174,6 +176,28 @@ orphan gate, audit, interactive picker, explicit CLI paths (refused with 🚫),
and gitignored (`*.bkp.*`). They can be diffed and merged from — never pulled,
pushed, or counted.

## Cross-org promotion deletions

Promotion mirrors the committed source resource tree into downstream orgs; it
does not deploy the source org itself. A safe deletion therefore has two
separate lifecycles:

1. Delete the source resource file in a reviewed Git change, but retain its
`.vapi-state.<source>.json` entry. The entry is the deletion tombstone.
2. Review `npm run promote` and merge or apply it. Only matching destination
paths are removed, in reverse dependency order.
3. An automatic `--all --apply` run carries that authorization through every
adjacent org even after the intermediate org removes its own state entry.
4. Verify the destination APIs return 404 and the bot commit removed the
destination files and UUID mappings.
5. Reconcile the source org separately with a scoped
`npm run apply -- <source> --force <deleted-file-path...>`, then commit the
cleaned source state.

Do not delete the source state mapping or refresh it away before step 2. With
no source files and no tombstone, promotion refuses the empty-source mirror
instead of guessing that a full destination wipe was intended.

---

## Flag cheat sheet
Expand Down
Loading