From aa32996c67fc190ac7241f9a17f4b47bc42d0942 Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Tue, 21 Jul 2026 19:08:19 -0700 Subject: [PATCH 1/4] feat: reconcile resources across org pipelines --- .github/workflows/promotion.yml | 120 +++++ AGENTS.md | 5 +- README.md | 77 ++- docs/learnings/sync-behavior.md | 8 +- examples/cross-org-promotion/README.md | 49 +- package.json | 1 + promotion.example.yml | 41 ++ src/delete.ts | 22 +- src/promote-cmd.ts | 267 ++++++++++ src/promotion.ts | 670 +++++++++++++++++++++++++ src/push.ts | 38 +- tests/promotion.test.ts | 420 ++++++++++++++++ tests/vapi-ignore-push.test.ts | 14 + 13 files changed, 1669 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/promotion.yml create mode 100644 promotion.example.yml create mode 100644 src/promote-cmd.ts create mode 100644 src/promotion.ts create mode 100644 tests/promotion.test.ts diff --git a/.github/workflows/promotion.yml b/.github/workflows/promotion.yml new file mode 100644 index 0000000..350f8f6 --- /dev/null +++ b/.github/workflows/promotion.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 0d8dbae..55ad4e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ If you're unsure where something goes, default to `docs/learnings/`. The README | Create a multi-agent squad | Create `resources//squads/.yml` | | Add post-call analysis | Create `resources//structuredOutputs/.yml` | | Write test simulations | Create files under `resources//simulations/` | -| Promote resources across orgs | Copy files between `resources//` and `resources//` | +| Promote resources across orgs | `npm run promote -- --pipeline --from --to --apply` | | Deploy local changes (default) | `npm run apply -- ` — pull → merge → push, safe against dashboard drift | | Pre-flight schema check (no network) | `npm run validate -- ` — run before every `apply` | | Audit state/dashboard drift (read-only) | `npm run audit -- ` — orphans, ghosts, content-identical clusters, inline-tools. Exit 1 on findings. | @@ -877,6 +877,8 @@ npm run push -- --strict # Abort push if any validator npm run push -- --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 -- # Pull then push (full sync) npm run apply -- --allow-new-files # Same, propagating the bypass through to the push stage +npm run promote -- --pipeline --from --to # Read-only promotion plan +npm run promote -- --pipeline --from --to --apply # Forward-only scoped mirror + deploy npm run validate -- # Lint resources locally (fails fast on schema drift) npm run audit -- # 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 -- --type assistants # Scope audit to a single resource type @@ -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 - diff --git a/README.md b/README.md index 1c93a41..bc961e9 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Every command works in two modes: | `npm run setup` | ✅ | — | First-time org wizard — creates `.env.` and `resources//`. | | `npm run validate` | — | `npm run validate -- ` | Schema-check local YAML/MD with no network call. **Run before every `apply`.** | | `npm run audit` | — | `npm run audit -- [--type ]` | 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 --from --to [--apply]` | Plan or apply a forward-only, dependency-aware promotion defined by `promotion.yml`. | | `npm run apply` | ✅ | `npm run apply -- [--force]` | **Default deploy verb.** Pull → merge → push in one safe pass; resilient against dashboard drift. | | `npm run pull` | ✅ | `npm run pull -- [flags]` | Fetch remote state into local files / state file. Local-first by default — won't clobber local edits. | | `npm run push` | ✅ | `npm run push -- [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`. | @@ -315,35 +316,75 @@ 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. ```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..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. For a controlled single transition, run the workflow manually and provide + `pipeline`, `from`, and `to`. + +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. #### 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 -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. --- diff --git a/docs/learnings/sync-behavior.md b/docs/learnings/sync-behavior.md index 9ff1680..1927cbd 100644 --- a/docs/learnings/sync-behavior.md +++ b/docs/learnings/sync-behavior.md @@ -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 -- --force --confirm ` | +| `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 ` 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) diff --git a/examples/cross-org-promotion/README.md b/examples/cross-org-promotion/README.md index 198514a..49d07a0 100644 --- a/examples/cross-org-promotion/README.md +++ b/examples/cross-org-promotion/README.md @@ -15,15 +15,13 @@ The example state files intentionally assign different UUIDs to the same logical resources in each org. They are reference material only. Never copy them into the repository root or use them with a real Vapi organization. -## What works today +## What the promotion engine does -The current engine manages each org independently. It resolves tool and -credential aliases through that org's `.vapi-state..json`, so resource -YAML never needs to contain another org's UUID. - -Cross-org promotion is manual today: copy the desired files between org -directories, validate the destination, and apply it. There is not yet a -`promotion.yml` reconciler or a `promote` command. +The engine resolves tools, structured outputs, assistants, and credentials +through each org's own `.vapi-state..json`. `npm run promote` mirrors only +the resource patterns reviewed in `promotion.yml`, includes referenced managed +dependencies, and lets the destination push mint or reuse that org's UUIDs. +Source-org managed UUIDs are never copied as destination references. ## Try it with disposable Vapi orgs @@ -64,18 +62,23 @@ Use real disposable orgs, not the fake IDs in this directory. npm run validate -- example-production ``` -5. Dry-run the first creation. Review the two intentionally new files before - passing the new-file override: +5. Copy `promotion.example.yml` to `promotion.yml`, keep the three example orgs, + and set the resource patterns to `assistants/support-*` and + `tools/customer-*`. + +6. Review the promotion plan. It does not write files or call Vapi: ```bash - npm run push -- example-dev --dry-run --allow-new-files - npm run push -- example-staging --dry-run --allow-new-files - npm run push -- example-production --dry-run --allow-new-files + npm run promote -- --pipeline release --from example-dev --to example-staging ``` -6. After human review, run the corresponding `apply` commands with - `--allow-new-files`. Future updates can use plain `apply` because the real - state files will contain the newly created IDs. +7. After reviewing the plan, apply it. The command refreshes bindings, mirrors + the selected files, and uses the existing guarded apply path for creation, + modification, and deletion: + + ```bash + npm run promote -- --pipeline release --from example-dev --to example-staging --apply + ``` ## Verify the mapping behavior @@ -88,15 +91,17 @@ node -e 'for (const org of ["example-dev", "example-staging", "example-productio The aliases should be identical while every org's physical IDs are different. No UUID should appear in the resource YAML or Markdown. -## Promote and roll back manually +## Promote and roll back through Git -To test an update, change the dev assistant, apply `example-dev`, copy the file -to staging, and apply `example-staging`. Repeat for production after review. +To test an update, change and apply the dev assistant, commit the change, then +run the same promotion command. The destination keeps its existing UUID while +the changed content is patched. Rollback is a forward Git operation: revert the configuration commit and apply -the affected destination org again. The current engine does not automatically -delete dashboard resources whose files were removed by a revert; use the -explicitly gated cleanup flow for those deletions. +the same promotion again. If the revert removes a newly created source file, +the promotion's scoped mirror removes the matching destination file and the +existing `apply --force` path deletes only that state-tracked destination +resource. ## Credentials and phone numbers diff --git a/package.json b/package.json index 9c36477..a48e69f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "audit": "tsx src/audit-cmd.ts", "sim": "tsx src/sim-cmd.ts", "rollback": "tsx src/rollback-cmd.ts", + "promote": "tsx src/promote-cmd.ts", "build": "tsc --noEmit", "test": "node --import tsx --test tests/*.test.ts" }, diff --git a/promotion.example.yml b/promotion.example.yml new file mode 100644 index 0000000..82bd78a --- /dev/null +++ b/promotion.example.yml @@ -0,0 +1,41 @@ +version: 1 + +# Define as many orgs as your release model needs. Credentials and phone +# numbers are org-local bindings: promotion may bind an existing target-org +# resource or omit the reference, but it never provisions or copies one. +orgs: + example-dev: + baseUrl: https://api.vapi.ai + example-staging: + baseUrl: https://api.vapi.ai + bindings: + credentials: + default: bind + analytics-api: omit + phoneNumbers: + default: omit + support-line: bind + example-production: + baseUrl: https://api.vapi.ai + bindings: + credentials: + default: bind + phoneNumbers: + default: omit + +pipelines: + release: + # Transitions may only move forward through this ordered list. + orgs: + - example-dev + - example-staging + - example-production + + # These glob patterns (*, **, and ?) are the promotion ownership boundary. + # Target files inside the boundary are mirrored, including deletions; + # unrelated target files are left alone. Use **/* only when the entire + # destination org is intentionally a mirror of the source org. + resources: + - assistants/support-* + - tools/customer-* + - structuredOutputs/support-* diff --git a/src/delete.ts b/src/delete.ts index b2282bf..702cfa3 100644 --- a/src/delete.ts +++ b/src/delete.ts @@ -1,8 +1,14 @@ import { VapiApiError, vapiDelete } from "./api.ts"; -import { FORCE_DELETE, loadIgnorePatterns, matchesIgnore, VAPI_ENV } from "./config.ts"; +import { + FORCE_DELETE, + loadIgnorePatterns, + matchesIgnore, + VAPI_ENV, +} from "./config.ts"; import { deleteBaseline } from "./hash-store.ts"; import { extractReferencedIds } from "./resolver.ts"; import { FOLDER_MAP } from "./resources.ts"; +import type { TouchedSets } from "./state-merge.ts"; import type { LoadedResources, OrphanedResource, @@ -20,10 +26,12 @@ export function findOrphanedResources( loadedResourceIds: string[], stateResourceIds: Record, ignoredIds?: Set, + scopedIds?: Set, ): OrphanedResource[] { const orphaned: OrphanedResource[] = []; for (const [resourceId, entry] of Object.entries(stateResourceIds)) { + if (scopedIds && !scopedIds.has(resourceId)) continue; if (loadedResourceIds.includes(resourceId)) continue; // Data-safety: an id absent from local files BUT listed in .vapi-ignore // is an opt-out, not an orphan. Excluding here prevents `--force` push @@ -183,6 +191,8 @@ export async function deleteOrphanedResources( loadedResources: LoadedResources, state: StateFile, typesToDelete?: ResourceType[], + scopedIds?: Map>, + touched?: TouchedSets, ): Promise { const shouldCheck = (type: ResourceType) => !typesToDelete || typesToDelete.includes(type); @@ -240,6 +250,7 @@ export async function deleteOrphanedResources( loadedResources.tools.map((t) => t.resourceId), state.tools, ignoredByType.tools.ignored, + scopedIds?.get("tools"), ) : []; const orphanedOutputs = shouldCheck("structuredOutputs") @@ -247,6 +258,7 @@ export async function deleteOrphanedResources( loadedResources.structuredOutputs.map((o) => o.resourceId), state.structuredOutputs, ignoredByType.structuredOutputs.ignored, + scopedIds?.get("structuredOutputs"), ) : []; const orphanedAssistants = shouldCheck("assistants") @@ -254,6 +266,7 @@ export async function deleteOrphanedResources( loadedResources.assistants.map((a) => a.resourceId), state.assistants, ignoredByType.assistants.ignored, + scopedIds?.get("assistants"), ) : []; const orphanedSquads = shouldCheck("squads") @@ -261,6 +274,7 @@ export async function deleteOrphanedResources( loadedResources.squads.map((s) => s.resourceId), state.squads, ignoredByType.squads.ignored, + scopedIds?.get("squads"), ) : []; const orphanedPersonalities = shouldCheck("personalities") @@ -268,6 +282,7 @@ export async function deleteOrphanedResources( loadedResources.personalities.map((p) => p.resourceId), state.personalities, ignoredByType.personalities.ignored, + scopedIds?.get("personalities"), ) : []; const orphanedScenarios = shouldCheck("scenarios") @@ -275,6 +290,7 @@ export async function deleteOrphanedResources( loadedResources.scenarios.map((s) => s.resourceId), state.scenarios, ignoredByType.scenarios.ignored, + scopedIds?.get("scenarios"), ) : []; const orphanedSimulations = shouldCheck("simulations") @@ -282,6 +298,7 @@ export async function deleteOrphanedResources( loadedResources.simulations.map((s) => s.resourceId), state.simulations, ignoredByType.simulations.ignored, + scopedIds?.get("simulations"), ) : []; const orphanedSimulationSuites = shouldCheck("simulationSuites") @@ -289,6 +306,7 @@ export async function deleteOrphanedResources( loadedResources.simulationSuites.map((s) => s.resourceId), state.simulationSuites, ignoredByType.simulationSuites.ignored, + scopedIds?.get("simulationSuites"), ) : []; const orphanedEvals = shouldCheck("evals") @@ -296,6 +314,7 @@ export async function deleteOrphanedResources( loadedResources.evals.map((e) => e.resourceId), state.evals, ignoredByType.evals.ignored, + scopedIds?.get("evals"), ) : []; @@ -436,6 +455,7 @@ export async function deleteOrphanedResources( console.log(` 🗑️ Deleting ${type}: ${resourceId} (${uuid})`); await vapiDelete(`${DELETE_ENDPOINT_MAP[stateKey]}/${uuid}`); delete state[stateKey][resourceId]; + touched?.[stateKey].add(resourceId); await deleteBaseline(VAPI_ENV, uuid); deleted++; } catch (error) { diff --git a/src/promote-cmd.ts b/src/promote-cmd.ts new file mode 100644 index 0000000..27fdd0e --- /dev/null +++ b/src/promote-cmd.ts @@ -0,0 +1,267 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { PromotionConfig, PromotionPipeline } from "./promotion.ts"; +import { + promotionConfigParse, + promotionPlanApply, + promotionPlanBuild, + promotionStateParse, + promotionTransitionValidate, +} from "./promotion.ts"; + +interface PromotionArguments { + pipeline?: string; + source?: string; + target?: string; + all: boolean; + apply: boolean; +} + +interface PromotionTransition { + pipeline: string; + source: string; + target: string; + definition: PromotionPipeline; +} + +interface OrgConnection { + token: string; + baseUrl?: string; +} + +const ROOT_DIR = resolve( + process.env.VAPI_GITOPS_ROOT ?? fileURLToPath(new URL("..", import.meta.url)), +); + +function argumentsParse(args: string[]): PromotionArguments { + const parsed: PromotionArguments = { all: false, apply: false }; + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + if (argument === "--all") { + parsed.all = true; + continue; + } + if (argument === "--apply") { + parsed.apply = true; + continue; + } + if (argument === "--pipeline" && args[index + 1]) { + parsed.pipeline = args[++index]; + continue; + } + if (argument === "--from" && args[index + 1]) { + parsed.source = args[++index]; + continue; + } + if (argument === "--to" && args[index + 1]) { + parsed.target = args[++index]; + continue; + } + throw new Error(`Unrecognized or incomplete argument: ${argument}`); + } + if (parsed.all && (parsed.pipeline || parsed.source || parsed.target)) + throw new Error( + "--all cannot be combined with --pipeline, --from, or --to", + ); + if (!parsed.all && (!parsed.pipeline || !parsed.source || !parsed.target)) + throw new Error( + "Usage: npm run promote -- --pipeline --from --to [--apply]", + ); + return parsed; +} + +function transitionsBuild( + config: PromotionConfig, + args: PromotionArguments, +): PromotionTransition[] { + if (!args.all) { + const definition = promotionTransitionValidate( + config, + args.pipeline!, + args.source!, + args.target!, + ); + return [ + { + pipeline: args.pipeline!, + source: args.source!, + target: args.target!, + definition, + }, + ]; + } + const transitions: PromotionTransition[] = []; + for (const [pipeline, definition] of Object.entries(config.pipelines).sort( + ([left], [right]) => left.localeCompare(right), + )) { + for (let index = 0; index < definition.orgs.length - 1; index++) { + const source = definition.orgs[index]!; + const target = definition.orgs[index + 1]!; + transitions.push({ pipeline, source, target, definition }); + } + } + return transitions; +} + +function envValue(content: string, key: string): string | undefined { + const line = content + .split("\n") + .find((candidate) => candidate.trimStart().startsWith(`${key}=`)); + if (!line) return undefined; + const value = line.slice(line.indexOf("=") + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) + return value.slice(1, -1); + return value || undefined; +} + +function tokensParse(): Map { + const configured = process.env.VAPI_PROMOTION_TOKENS; + if (!configured) return new Map(); + let raw: unknown; + try { + raw = JSON.parse(configured); + } catch { + throw new Error("VAPI_PROMOTION_TOKENS must be valid JSON"); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + throw new Error("VAPI_PROMOTION_TOKENS must map org slugs to tokens"); + const tokens = new Map(); + for (const [org, token] of Object.entries(raw)) { + if (typeof token !== "string" || token.length === 0) + throw new Error( + `VAPI_PROMOTION_TOKENS entry for ${org} must be a non-empty token string`, + ); + tokens.set(org, token); + } + return tokens; +} + +function connectionLoad( + config: PromotionConfig, + org: string, + tokens: Map, +): OrgConnection { + const envPath = resolve(ROOT_DIR, `.env.${org}`); + const envContent = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""; + const envToken = envValue(envContent, "VAPI_TOKEN"); + const token = tokens.get(org) ?? envToken; + if (!token) + throw new Error( + `Missing token for org ${org}; set VAPI_PROMOTION_TOKENS or .env.${org}`, + ); + return { + token, + baseUrl: config.orgs[org]?.baseUrl ?? envValue(envContent, "VAPI_BASE_URL"), + }; +} + +function childRun( + script: string, + org: string, + connection: OrgConnection, + args: string[], +): void { + const environment: NodeJS.ProcessEnv = { + ...process.env, + VAPI_TOKEN: connection.token, + }; + if (connection.baseUrl) environment.VAPI_BASE_URL = connection.baseUrl; + if (!connection.baseUrl) delete environment.VAPI_BASE_URL; + const result = spawnSync( + process.execPath, + ["--import", "tsx", script, org, ...args], + { cwd: ROOT_DIR, env: environment, stdio: "inherit" }, + ); + if (result.error) + throw new Error( + `Failed to run ${script} for ${org}: ${result.error.message}`, + ); + if (result.status !== 0) throw new Error(`${script} failed for ${org}`); +} + +function stateLoad(org: string) { + const path = resolve(ROOT_DIR, `.vapi-state.${org}.json`); + if (!existsSync(path)) + throw new Error( + `Missing state for ${org}; run promotion with --apply to bootstrap bindings first`, + ); + return promotionStateParse(readFileSync(path, "utf8")); +} + +async function transitionRun( + config: PromotionConfig, + transition: PromotionTransition, + apply: boolean, + tokens: Map, +): Promise { + if (apply) { + childRun( + "src/pull.ts", + transition.source, + connectionLoad(config, transition.source, tokens), + ["--bootstrap", "--bindings-only"], + ); + childRun( + "src/pull.ts", + transition.target, + connectionLoad(config, transition.target, tokens), + ["--bootstrap", "--bindings-only"], + ); + } + const plan = await promotionPlanBuild({ + rootDir: ROOT_DIR, + source: transition.source, + target: transition.target, + patterns: transition.definition.resources, + sourceState: stateLoad(transition.source), + targetState: stateLoad(transition.target), + bindings: config.orgs[transition.target]!.bindings, + }); + console.log( + `\n${transition.pipeline}: ${transition.source} → ${transition.target}`, + ); + for (const change of plan.changes) + console.log(` ${change.kind.padEnd(6)} ${change.path}`); + if (plan.changes.length === 0) console.log(" no changes"); + if (!apply || plan.changes.length === 0) return; + await promotionPlanApply(plan); + const changedPaths = plan.changes.map( + (change) => `resources/${transition.target}/${change.path}`, + ); + childRun( + "src/apply.ts", + transition.target, + connectionLoad(config, transition.target, tokens), + ["--force", "--allow-new-files", "--resolve=ours", ...changedPaths], + ); +} + +export async function promotionCommandRun( + args = process.argv.slice(2), +): Promise { + const parsed = argumentsParse(args); + const configPath = resolve(ROOT_DIR, "promotion.yml"); + if (!existsSync(configPath)) + throw new Error("promotion.yml is required at the repository root"); + const config = promotionConfigParse(readFileSync(configPath, "utf8")); + const tokens = parsed.apply ? tokensParse() : new Map(); + delete process.env.VAPI_PROMOTION_TOKENS; + for (const transition of transitionsBuild(config, parsed)) + await transitionRun(config, transition, parsed.apply, tokens); +} + +const isMainModule = + resolve(process.argv[1] ?? "") === resolve(fileURLToPath(import.meta.url)); +if (isMainModule) { + promotionCommandRun().catch((error: unknown) => { + console.error( + `❌ Promotion failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + }); +} diff --git a/src/promotion.ts b/src/promotion.ts new file mode 100644 index 0000000..3c8a7e0 --- /dev/null +++ b/src/promotion.ts @@ -0,0 +1,670 @@ +import { existsSync } from "node:fs"; +import { + mkdir, + readdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, extname, join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; +import { isDeepStrictEqual } from "node:util"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import type { ResourceType, StateFile } from "./types.ts"; + +type BindingPolicy = "bind" | "omit"; + +export interface PromotionBindingMap { + default: BindingPolicy; + aliases: Record; +} + +export interface PromotionBindings { + credentials: PromotionBindingMap; + phoneNumbers: PromotionBindingMap; +} + +export interface PromotionOrg { + baseUrl?: string; + bindings: PromotionBindings; +} + +export interface PromotionPipeline { + orgs: string[]; + resources: string[]; +} + +export interface PromotionConfig { + version: 1; + orgs: Record; + pipelines: Record; +} + +export interface PromotionChange { + kind: "create" | "update" | "delete"; + path: string; + content?: string; +} + +export interface PromotionPlan { + rootDir: string; + target: string; + changes: PromotionChange[]; +} + +export interface PromotionPlanOptions { + rootDir: string; + source: string; + target: string; + patterns: string[]; + sourceState: StateFile; + targetState: StateFile; + bindings?: PromotionBindings; +} + +interface PromotionResource { + path: string; + content: string; + type: ResourceType; + id: string; +} + +interface PromotionBindingsResolved { + credentialReverse: Map; + sourcePhones: Map; + targetPhones: Map; +} + +const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; +const FOLDER_MAP: Record = { + tools: "tools", + structuredOutputs: "structuredOutputs", + assistants: "assistants", + squads: "squads", + personalities: "simulations/personalities", + scenarios: "simulations/scenarios", + simulations: "simulations/tests", + simulationSuites: "simulations/suites", + evals: "evals", +}; +const VALID_EXTENSIONS: readonly string[] = [".yml", ".yaml", ".ts", ".md"]; +const REFERENCE_FIELDS: Record = { + toolId: "tools", + toolIds: "tools", + structuredOutputId: "structuredOutputs", + structuredOutputIds: "structuredOutputs", + assistantId: "assistants", + assistantIds: "assistants", + assistant_ids: "assistants", + personalityId: "personalities", + scenarioId: "scenarios", + simulationId: "simulations", + simulationIds: "simulations", +}; +const DEFAULT_BINDINGS: PromotionBindings = { + credentials: { default: "bind", aliases: {} }, + phoneNumbers: { default: "omit", aliases: {} }, +}; + +function object(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be a mapping`); + } + return Object.fromEntries(Object.entries(value)); +} + +function policy(value: unknown, label: string): BindingPolicy { + if (value === "bind" || value === "omit") return value; + throw new Error(`${label} must be "bind" or "omit"`); +} + +function bindingMap( + value: unknown, + fallback: BindingPolicy, + label: string, +): PromotionBindingMap { + if (value === undefined) return { default: fallback, aliases: {} }; + const raw = object(value, label); + const aliasesRaw = + raw.aliases === undefined ? {} : object(raw.aliases, `${label}.aliases`); + const aliases: Record = {}; + for (const [alias, configured] of Object.entries(aliasesRaw)) + aliases[alias] = policy(configured, `${label}.aliases.${alias}`); + for (const [alias, configured] of Object.entries(raw)) { + if (alias !== "default" && alias !== "aliases") + aliases[alias] = policy(configured, `${label}.${alias}`); + } + return { + default: + raw.default === undefined + ? fallback + : policy(raw.default, `${label}.default`), + aliases, + }; +} + +function bindings(value: unknown): PromotionBindings { + const raw = value === undefined ? {} : object(value, "bindings"); + return { + credentials: bindingMap(raw.credentials, "bind", "bindings.credentials"), + phoneNumbers: bindingMap(raw.phoneNumbers, "omit", "bindings.phoneNumbers"), + }; +} + +export function promotionConfigParse(content: string): PromotionConfig { + const raw = object(parseYaml(content), "promotion.yml"); + if (raw.version !== 1) throw new Error("promotion.yml version must be 1"); + const orgsRaw = object(raw.orgs, "promotion.yml orgs"); + const orgs: Record = {}; + for (const [slug, value] of Object.entries(orgsRaw)) { + if (!SLUG_RE.test(slug)) throw new Error(`Invalid org slug: ${slug}`); + const org = object(value ?? {}, `org ${slug}`); + if (org.baseUrl !== undefined && typeof org.baseUrl !== "string") + throw new Error(`org ${slug}.baseUrl must be a string`); + orgs[slug] = { + baseUrl: typeof org.baseUrl === "string" ? org.baseUrl : undefined, + bindings: bindings(org.bindings), + }; + } + const pipelinesRaw = object(raw.pipelines, "promotion.yml pipelines"); + if (Object.keys(pipelinesRaw).length === 0) + throw new Error("promotion.yml must declare at least one pipeline"); + const pipelines: Record = {}; + for (const [name, value] of Object.entries(pipelinesRaw)) { + if (!SLUG_RE.test(name)) throw new Error(`Invalid pipeline slug: ${name}`); + const pipeline = object(value, `pipeline ${name}`); + if ( + !Array.isArray(pipeline.orgs) || + !pipeline.orgs.every((org) => typeof org === "string") + ) + throw new Error(`pipeline ${name}.orgs must be a list of org slugs`); + if ( + !Array.isArray(pipeline.resources) || + pipeline.resources.length === 0 || + !pipeline.resources.every( + (pattern) => typeof pattern === "string" && pattern.length > 0, + ) + ) + throw new Error(`pipeline ${name}.resources must be a non-empty list`); + const orderedOrgs = pipeline.orgs; + if ( + orderedOrgs.length < 2 || + new Set(orderedOrgs).size !== orderedOrgs.length + ) + throw new Error(`pipeline ${name} must have at least two unique orgs`); + for (const org of orderedOrgs) + if (!orgs[org]) + throw new Error(`pipeline ${name} references undeclared org: ${org}`); + pipelines[name] = { + orgs: [...orderedOrgs], + resources: [...pipeline.resources], + }; + } + return { version: 1, orgs, pipelines }; +} + +export function promotionTransitionValidate( + config: PromotionConfig, + pipelineName: string, + source: string, + target: string, +): PromotionPipeline { + const pipeline = config.pipelines[pipelineName]; + if (!pipeline) throw new Error(`Unknown promotion pipeline: ${pipelineName}`); + const sourceIndex = pipeline.orgs.indexOf(source); + const targetIndex = pipeline.orgs.indexOf(target); + if (sourceIndex === -1 || targetIndex === -1) + throw new Error( + `Both source and target must belong to pipeline ${pipelineName}`, + ); + if (sourceIndex >= targetIndex) + throw new Error( + `Promotion transitions must move forward in pipeline ${pipelineName}`, + ); + return pipeline; +} + +function glob(pattern: string, path: string): boolean { + let expression = ""; + for (let index = 0; index < pattern.length; index++) { + const char = pattern[index]; + if (char === "*" && pattern[index + 1] === "*") { + expression += ".*"; + index++; + continue; + } + if (char === "*") { + expression += "[^/]*"; + continue; + } + if (char === "?") { + expression += "[^/]"; + continue; + } + expression += "\\^$.|+(){}[]".includes(char ?? "") ? `\\${char}` : char; + } + return new RegExp(`^${expression}$`).test(path); +} + +function selected(path: string, patterns: string[]): boolean { + const stem = path.replace(/\.(yml|yaml|md|ts)$/, ""); + return patterns.some((pattern) => glob(pattern, path) || glob(pattern, stem)); +} + +async function ignorePatternsRead( + root: string, + org: string, +): Promise { + const path = join(root, "resources", org, ".vapi-ignore"); + if (!existsSync(path)) return []; + return (await readFile(path, "utf8")) + .split("\n") + .map((line) => line.trim()) + .filter( + (line) => + line.length > 0 && !line.startsWith("#") && !line.startsWith("!"), + ); +} + +function ignored(path: string, patterns: string[]): boolean { + const stem = path.replace(/\.(yml|yaml|md|ts)$/, ""); + return patterns.some((pattern) => glob(pattern, stem)); +} + +function resourceType(path: string): { type: ResourceType; id: string } | null { + for (const [type, folder] of Object.entries(FOLDER_MAP)) { + if (!path.startsWith(`${folder}/`)) continue; + const id = path.slice(folder.length + 1).replace(/\.(yml|yaml|md|ts)$/, ""); + return { type: type as ResourceType, id }; + } + return null; +} + +async function resourcesRead( + root: string, + org: string, +): Promise { + const base = join(root, "resources", org); + if (!existsSync(base)) return []; + const files: PromotionResource[] = []; + async function visit(directory: string): Promise { + for (const entry of (await readdir(directory)).sort()) { + if (entry.startsWith(".")) continue; + const path = join(directory, entry); + if ((await stat(path)).isDirectory()) { + await visit(path); + continue; + } + if (!VALID_EXTENSIONS.includes(extname(path))) continue; + const relativePath = relative(base, path); + const metadata = resourceType(relativePath); + if (metadata) + files.push({ + path: relativePath, + content: await readFile(path, "utf8"), + ...metadata, + }); + } + } + await visit(base); + return files.sort((left, right) => left.path.localeCompare(right.path)); +} + +function referencesCanonicalize( + value: unknown, + sourceState: StateFile, +): unknown { + const reverse: Record> = {} as Record< + ResourceType, + Map + >; + for (const type of Object.keys(FOLDER_MAP) as ResourceType[]) + reverse[type] = new Map( + Object.entries(sourceState[type]).map(([id, entry]) => [entry.uuid, id]), + ); + function visit(current: unknown, key?: string): unknown { + if (typeof current === "string" && key && REFERENCE_FIELDS[key]) { + const clean = current.split("##")[0]?.trim() ?? ""; + return reverse[REFERENCE_FIELDS[key]].get(clean) ?? clean; + } + if (Array.isArray(current)) return current.map((item) => visit(item, key)); + if (!current || typeof current !== "object") return current; + return Object.fromEntries( + Object.entries(current).map(([childKey, child]) => [ + childKey, + visit(child, childKey), + ]), + ); + } + return visit(value); +} + +function envBindings(content: string, prefix: string): Map { + const result = new Map(); + for (const line of content.split("\n")) { + const match = line.match(new RegExp(`^${prefix}([A-Z0-9_]+)=(.+)$`)); + if (match) + result.set(match[1]!.toLowerCase().replace(/_/g, "-"), match[2]!.trim()); + } + return result; +} + +async function bindingsResolve( + root: string, + source: string, + target: string, + sourceState: StateFile, +): Promise { + const sourceEnv = existsSync(join(root, `.env.${source}`)) + ? await readFile(join(root, `.env.${source}`), "utf8") + : ""; + const targetEnv = existsSync(join(root, `.env.${target}`)) + ? await readFile(join(root, `.env.${target}`), "utf8") + : ""; + const credentialReverse = new Map( + Object.entries(sourceState.credentials).map(([alias, entry]) => [ + entry.uuid, + alias, + ]), + ); + const sourcePhones = new Map(); + for (const [alias, id] of envBindings(sourceEnv, "VAPI_PHONE_NUMBER_")) + sourcePhones.set(id, alias); + return { + credentialReverse, + sourcePhones, + targetPhones: envBindings(targetEnv, "VAPI_PHONE_NUMBER_"), + }; +} + +function policyFor(map: PromotionBindingMap, alias: string): BindingPolicy { + return map.aliases[alias] ?? map.aliases[alias.toUpperCase()] ?? map.default; +} + +function bindingsApply( + value: unknown, + bindings: PromotionBindings, + resolved: PromotionBindingsResolved, + targetState: StateFile, +): unknown { + function credential(ref: string): string | undefined { + const alias = resolved.credentialReverse.get(ref) ?? ref; + if (policyFor(bindings.credentials, alias) === "omit") return undefined; + if (!targetState.credentials[alias]) + throw new Error( + `Credential binding "${alias}" is required in the target state`, + ); + return alias; + } + function phone(ref: string): string | undefined { + const alias = ( + resolved.sourcePhones.get(ref) ?? ref.replace(/^VAPI_PHONE_NUMBER_/, "") + ) + .toLowerCase() + .replace(/_/g, "-"); + if (policyFor(bindings.phoneNumbers, alias) === "omit") return undefined; + const target = resolved.targetPhones.get(alias); + if (!target) + throw new Error( + `Phone-number binding "${alias}" is required in .env target bindings`, + ); + return target; + } + function visit(current: unknown): unknown { + if (Array.isArray(current)) return current.map(visit); + if (!current || typeof current !== "object") return current; + const output: Record = {}; + for (const [key, child] of Object.entries(current)) { + if (key === "credentialId" && typeof child === "string") { + const bound = credential(child); + if (bound) output[key] = bound; + continue; + } + if (key === "phoneNumberId" && typeof child === "string") { + const bound = phone(child); + if (bound) output[key] = bound; + continue; + } + if (key === "credentialIds" && Array.isArray(child)) { + output[key] = child + .filter((item): item is string => typeof item === "string") + .map(credential) + .filter((item): item is string => item !== undefined); + continue; + } + if (key === "phoneNumberIds" && Array.isArray(child)) { + output[key] = child + .filter((item): item is string => typeof item === "string") + .map(phone) + .filter((item): item is string => item !== undefined); + continue; + } + output[key] = visit(child); + } + return output; + } + return visit(value); +} + +function parseContent( + content: string, + path: string, +): { data: unknown; body?: string } { + if (extname(path) !== ".md") return { data: parseYaml(content) }; + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) throw new Error(`Invalid Markdown frontmatter: ${path}`); + return { data: parseYaml(match[1] ?? ""), body: match[2] ?? "" }; +} + +async function resourceData( + file: PromotionResource, + root: string, + source: string, +): Promise<{ data: unknown; body?: string }> { + if (extname(file.path) !== ".ts") + return parseContent(file.content, file.path); + const url = pathToFileURL(join(root, "resources", source, file.path)).href; + const module = (await import(`${url}?promotion`)) as { default?: unknown }; + if (module.default === undefined) + throw new Error(`TypeScript resource has no default export: ${file.path}`); + return { data: module.default }; +} + +function renderContent(data: unknown, path: string, body?: string): string { + const yaml = stringifyYaml(data); + return extname(path) === ".md" ? `---\n${yaml}---\n${body ?? ""}` : yaml; +} + +function dependenciesFind( + value: unknown, + sourceState: StateFile, + files: PromotionResource[], +): Array<{ type: ResourceType; id: string }> { + const lookup = new Map( + files.map((file) => [`${file.type}:${file.id}`, file]), + ); + const references: Array<{ type: ResourceType; id: string }> = []; + const reverse: Record> = {} as Record< + ResourceType, + Map + >; + for (const type of Object.keys(FOLDER_MAP) as ResourceType[]) + reverse[type] = new Map( + Object.entries(sourceState[type]).map(([id, entry]) => [entry.uuid, id]), + ); + function visit(current: unknown, key?: string): void { + if (typeof current === "string" && key && REFERENCE_FIELDS[key]) { + const type = REFERENCE_FIELDS[key]; + const clean = current.split("##")[0]?.trim() ?? ""; + const id = reverse[type].get(clean) ?? clean; + if (!lookup.has(`${type}:${id}`)) + throw new Error( + `Referenced managed dependency is missing from source: ${type}/${id}`, + ); + references.push({ type, id }); + return; + } + if (Array.isArray(current)) { + for (const item of current) visit(item, key); + return; + } + if (current && typeof current === "object") + for (const [childKey, child] of Object.entries(current)) + visit(child, childKey); + } + visit(value); + return references; +} + +export async function promotionPlanBuild( + options: PromotionPlanOptions, +): Promise { + const bindings = options.bindings ?? DEFAULT_BINDINGS; + const allSource = await resourcesRead(options.rootDir, options.source); + const target = await resourcesRead(options.rootDir, options.target); + const targetIgnore = await ignorePatternsRead( + options.rootDir, + options.target, + ); + const selectedSource = allSource.filter( + (file) => + selected(file.path, options.patterns) && + !ignored(file.path, targetIgnore), + ); + const selectedTarget = target.filter( + (file) => + selected(file.path, options.patterns) && + !ignored(file.path, targetIgnore), + ); + const stateConfirmsDeletion = ( + Object.keys(FOLDER_MAP) as ResourceType[] + ).some((type) => + Object.keys(options.sourceState[type]).some((id) => + VALID_EXTENSIONS.some((extension) => + selected(`${FOLDER_MAP[type]}/${id}${extension}`, options.patterns), + ), + ), + ); + if ( + selectedSource.length === 0 && + selectedTarget.length > 0 && + !stateConfirmsDeletion + ) + throw new Error( + "Refusing an empty-source mirror deletion without matching source state", + ); + const wanted = new Map( + selectedSource.map((file) => [`${file.type}:${file.id}`, file]), + ); + const queue = [...wanted.values()]; + while (queue.length > 0) { + const file = queue.shift()!; + const parsed = await resourceData(file, options.rootDir, options.source); + for (const dependency of dependenciesFind( + parsed.data, + options.sourceState, + allSource, + )) { + const key = `${dependency.type}:${dependency.id}`; + const dependencyFile = allSource.find( + (candidate) => `${candidate.type}:${candidate.id}` === key, + ); + if (dependencyFile && ignored(dependencyFile.path, targetIgnore)) + throw new Error( + `Referenced dependency is ignored by target .vapi-ignore: ${dependencyFile.path}`, + ); + if (dependencyFile && !wanted.has(key)) { + wanted.set(key, dependencyFile); + queue.push(dependencyFile); + } + } + } + const resolved = await bindingsResolve( + options.rootDir, + options.source, + options.target, + options.sourceState, + ); + const targetByPath = new Map(target.map((file) => [file.path, file])); + const sourcePaths = new Set([...wanted.values()].map((file) => file.path)); + const changes: PromotionChange[] = []; + for (const file of wanted.values()) { + const parsed = await resourceData(file, options.rootDir, options.source); + const canonical = referencesCanonicalize(parsed.data, options.sourceState); + const transformed = bindingsApply( + canonical, + bindings, + resolved, + options.targetState, + ); + if ( + extname(file.path) === ".ts" && + !isDeepStrictEqual(parsed.data, transformed) + ) + throw new Error( + `TypeScript resource ${file.path} contains org-specific references; use logical aliases or YAML/Markdown so promotion can rewrite them safely`, + ); + const content = + extname(file.path) === ".ts" || + isDeepStrictEqual(parsed.data, transformed) + ? file.content + : renderContent(transformed, file.path, parsed.body); + const existing = targetByPath.get(file.path); + if (!existing || existing.content !== content) + changes.push({ + kind: existing ? "update" : "create", + path: file.path, + content, + }); + } + for (const file of target) + if ( + selected(file.path, options.patterns) && + !ignored(file.path, targetIgnore) && + !sourcePaths.has(file.path) + ) + changes.push({ kind: "delete", path: file.path }); + changes.sort((left, right) => left.path.localeCompare(right.path)); + return { rootDir: options.rootDir, target: options.target, changes }; +} + +export async function promotionPlanApply(plan: PromotionPlan): Promise { + const base = join(plan.rootDir, "resources", plan.target); + for (const change of plan.changes) { + const path = join(base, change.path); + if (change.kind === "delete") { + await rm(path, { force: true }); + continue; + } + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, change.content ?? ""); + } +} + +export function promotionStateParse(content: string): StateFile { + const raw = object(JSON.parse(content), "state file"); + const empty: StateFile = { + credentials: {}, + assistants: {}, + structuredOutputs: {}, + tools: {}, + squads: {}, + personalities: {}, + scenarios: {}, + simulations: {}, + simulationSuites: {}, + evals: {}, + }; + for (const type of Object.keys(empty) as ResourceType[]) { + const section = raw[type]; + if (!section || typeof section !== "object" || Array.isArray(section)) + continue; + for (const [id, entry] of Object.entries(section)) { + const uuid = + typeof entry === "string" ? entry : object(entry, `${type}.${id}`).uuid; + if (typeof uuid === "string") empty[type][id] = { uuid }; + } + } + return empty; +} diff --git a/src/push.ts b/src/push.ts index 7cfeb0c..c25223e 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1,6 +1,8 @@ +import { select } from "@inquirer/prompts"; import { relative, resolve } from "path"; import { fileURLToPath } from "url"; import { getDryRunCounts, VapiApiError, vapiRequest } from "./api.ts"; +import { canonicalizeForHash, type VapiResource } from "./canonical.ts"; import { ALLOW_NEW_FILES, APPLY_FILTER, @@ -21,8 +23,6 @@ import { findExistingResourceByName, type RemoteResource, } from "./dep-dedup.ts"; -import { select } from "@inquirer/prompts"; -import { canonicalizeForHash, type VapiResource } from "./canonical.ts"; import { checkDriftForUpdate, type DriftCheckResult } from "./drift.ts"; import { deleteBaseline, writeBaseline } from "./hash-store.ts"; import { assertStateMigrated } from "./migrate-hash-store.ts"; @@ -74,6 +74,7 @@ import { FOLDER_MAP, loadResources, loadSingleResource, + parseResourceFilePath, pathMatchesFolder, } from "./resources.ts"; import { hashPayload, loadState, saveState, upsertState } from "./state.ts"; @@ -810,7 +811,11 @@ export async function applyEval( if (existingUuid) { const updatePayload = removeExcludedKeys(payload, "evals"); console.log(` 🔄 Updating eval: ${resourceId} (${existingUuid})`); - const result = await vapiRequest("PATCH", `/eval/${existingUuid}`, updatePayload); + const result = await vapiRequest( + "PATCH", + `/eval/${existingUuid}`, + updatePayload, + ); await writeBaselineFromResponse(existingUuid, result, state); return existingUuid; } else { @@ -981,10 +986,7 @@ function scopeLoadedResourcesForApply( ? filterResourcesByPaths(resources.simulations, "simulations") : [], simulationSuites: shouldApplyResourceType("simulationSuites") - ? filterResourcesByPaths( - resources.simulationSuites, - "simulationSuites", - ) + ? filterResourcesByPaths(resources.simulationSuites, "simulationSuites") : [], evals: shouldApplyResourceType("evals") ? filterResourcesByPaths(resources.evals, "evals") @@ -1676,21 +1678,21 @@ async function main(): Promise { // Determine which types to check for orphaned deletions // Full apply: check all types. Partial apply: only check the filtered type(s). let typesToDelete: ResourceType[] | undefined; + let scopedDeleteIds: Map> | undefined; if (partial) { typesToDelete = []; if (APPLY_FILTER.resourceTypes?.length) { typesToDelete.push(...APPLY_FILTER.resourceTypes); } else if (APPLY_FILTER.filePaths?.length) { - if (tools.length > 0) typesToDelete.push("tools"); - if (structuredOutputs.length > 0) - typesToDelete.push("structuredOutputs"); - if (assistants.length > 0) typesToDelete.push("assistants"); - if (squads.length > 0) typesToDelete.push("squads"); - if (personalities.length > 0) typesToDelete.push("personalities"); - if (scenarios.length > 0) typesToDelete.push("scenarios"); - if (simulations.length > 0) typesToDelete.push("simulations"); - if (simulationSuites.length > 0) typesToDelete.push("simulationSuites"); - if (evals.length > 0) typesToDelete.push("evals"); + scopedDeleteIds = new Map(); + for (const filePath of APPLY_FILTER.filePaths) { + const parsed = parseResourceFilePath(filePath); + if (!parsed) continue; + const ids = scopedDeleteIds.get(parsed.type) ?? new Set(); + ids.add(parsed.resourceId); + scopedDeleteIds.set(parsed.type, ids); + } + typesToDelete.push(...scopedDeleteIds.keys()); } } @@ -1715,6 +1717,8 @@ async function main(): Promise { }, state, typesToDelete, + scopedDeleteIds, + touched, ); // Apply in dependency order: diff --git a/tests/promotion.test.ts b/tests/promotion.test.ts new file mode 100644 index 0000000..7161787 --- /dev/null +++ b/tests/promotion.test.ts @@ -0,0 +1,420 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { + promotionConfigParse, + promotionPlanApply, + promotionPlanBuild, + promotionTransitionValidate, +} from "../src/promotion.ts"; +import type { StateFile } from "../src/types.ts"; + +function state(entries: Partial = {}): StateFile { + return { + credentials: {}, + assistants: {}, + structuredOutputs: {}, + tools: {}, + squads: {}, + personalities: {}, + scenarios: {}, + simulations: {}, + simulationSuites: {}, + evals: {}, + ...entries, + }; +} + +async function fixture(): Promise<{ + root: string; + cleanup: () => Promise; +}> { + const root = await mkdtemp(join(tmpdir(), "vapi-promotion-")); + return { root, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function put(root: string, path: string, content: string): Promise { + const full = join(root, path); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, content); +} + +const configText = `version: 1 +orgs: + source: {} + target: {} +pipelines: + release: + orgs: [source, target] + resources: [assistants/**, tools/**] +`; + +test("promotion config validates pipelines and forward-only transitions", () => { + const config = promotionConfigParse(configText); + assert.equal( + promotionTransitionValidate(config, "release", "source", "target").orgs[0], + "source", + ); + assert.throws( + () => promotionTransitionValidate(config, "release", "target", "source"), + /forward/i, + ); + assert.throws( + () => promotionConfigParse("version: 1\norgs: {one: {}}\npipelines: {}\n"), + /pipeline/i, + ); + assert.throws( + () => + promotionConfigParse( + "version: 1\norgs: {a: {}, b: {}}\npipelines: {x: {orgs: [a, a], resources: []}}\n", + ), + /unique|resource/i, + ); + assert.throws( + () => + promotionConfigParse( + "version: 1\norgs: {a: {}, b: {bindings: {credentials: {default: copy}}}}\npipelines: {x: {orgs: [a, b], resources: [tools/**]}}\n", + ), + /bind|omit/, + ); + assert.doesNotThrow(() => + promotionConfigParse( + "version: 1\norgs:\n a:\n b:\npipelines: {x: {orgs: [a, b], resources: [tools/**]}}\n", + ), + ); +}); + +test("promotion plans creates, updates, deletes, and preserves unrelated target files", async () => { + const fx = await fixture(); + try { + await put( + fx.root, + "resources/source/assistants/new.md", + "---\nname: New\n---\n# Prompt\n", + ); + await put(fx.root, "resources/source/tools/shared.yml", "name: source\n"); + await put( + fx.root, + "resources/target/assistants/old.md", + "---\nname: Old\n---\nold\n", + ); + await put(fx.root, "resources/target/tools/shared.yml", "name: target\n"); + await put(fx.root, "resources/target/squads/keep.yml", "name: keep\n"); + const plan = await promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["assistants/**", "tools/**"], + sourceState: state(), + targetState: state(), + }); + assert.deepEqual( + plan.changes.map((change) => [change.kind, change.path]), + [ + ["create", "assistants/new.md"], + ["delete", "assistants/old.md"], + ["update", "tools/shared.yml"], + ], + ); + await promotionPlanApply(plan); + await assert.rejects( + readFile(join(fx.root, "resources/target/assistants/old.md")), + ); + assert.equal( + await readFile(join(fx.root, "resources/target/squads/keep.yml"), "utf8"), + "name: keep\n", + ); + } finally { + await fx.cleanup(); + } +}); + +test("promotion includes managed dependencies and preserves markdown bodies", async () => { + const fx = await fixture(); + try { + await put( + fx.root, + "resources/source/assistants/agent.md", + "---\nname: Agent\nmodel:\n toolIds: ['book ## dependency note']\nartifactPlan:\n structuredOutputIds: ['result ## output note']\n---\n# Keep this prompt exactly\n\nNo changes.\n", + ); + await put( + fx.root, + "resources/source/tools/book.yml", + "type: function\nfunction: { name: book }\n", + ); + await put( + fx.root, + "resources/source/structuredOutputs/result.yml", + "name: Result\nschema: { type: object }\n", + ); + const plan = await promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["assistants/**"], + sourceState: state(), + targetState: state(), + }); + assert.deepEqual( + plan.changes.map((change) => change.path), + ["assistants/agent.md", "structuredOutputs/result.yml", "tools/book.yml"], + ); + await promotionPlanApply(plan); + assert.match( + await readFile( + join(fx.root, "resources/target/assistants/agent.md"), + "utf8", + ), + /# Keep this prompt exactly\n\nNo changes\./, + ); + assert.doesNotMatch( + await readFile( + join(fx.root, "resources/target/assistants/agent.md"), + "utf8", + ), + /dependency note|output note/, + ); + } finally { + await fx.cleanup(); + } +}); + +test("promotion inspects TypeScript resources for managed dependencies", async () => { + const fx = await fixture(); + try { + await put( + fx.root, + "resources/source/assistants/agent.ts", + 'export default { name: "Agent", model: { toolIds: ["book"] } };\n', + ); + await put( + fx.root, + "resources/source/tools/book.yml", + "type: function\nfunction: { name: book }\n", + ); + const plan = await promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["assistants/**"], + sourceState: state(), + targetState: state(), + }); + assert.deepEqual( + plan.changes.map((change) => change.path), + ["assistants/agent.ts", "tools/book.yml"], + ); + } finally { + await fx.cleanup(); + } +}); + +test("promotion honors target ignore rules for selected dependencies", async () => { + const fx = await fixture(); + try { + await put( + fx.root, + "resources/source/assistants/agent.md", + "---\nname: Agent\nmodel:\n toolIds: [book]\n---\nprompt\n", + ); + await put(fx.root, "resources/source/tools/book.yml", "type: function\n"); + await put(fx.root, "resources/target/.vapi-ignore", "tools/book\n"); + await assert.rejects( + promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["assistants/**"], + sourceState: state(), + targetState: state(), + }), + /ignored by target \.vapi-ignore/i, + ); + } finally { + await fx.cleanup(); + } +}); + +test("promotion canonicalizes source UUIDs and applies credential and phone policies", async () => { + const fx = await fixture(); + try { + const sourceTool = "11111111-1111-1111-1111-111111111111"; + const sourceCredential = "22222222-2222-2222-2222-222222222222"; + await put( + fx.root, + "resources/source/assistants/a.md", + `---\nname: A\nmodel:\n toolIds: [${sourceTool}]\n credentialIds: [${sourceCredential}, unknown]\nserver:\n credentialId: ${sourceCredential}\nphoneNumberIds: [source-phone, unknown-phone]\n---\nprompt\n`, + ); + await put(fx.root, "resources/source/tools/t.yml", "type: function\n"); + await put( + fx.root, + ".env.source", + "# BEGIN VAPI MANAGED BINDINGS\nVAPI_PHONE_NUMBER_MAIN=source-phone\n# END VAPI MANAGED BINDINGS\n", + ); + await put( + fx.root, + ".env.target", + "VAPI_PHONE_NUMBER_MAIN=target-phone\n\n# BEGIN VAPI MANAGED BINDINGS\n# END VAPI MANAGED BINDINGS\n", + ); + const plan = await promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["assistants/**"], + sourceState: state({ + tools: { t: { uuid: sourceTool } }, + credentials: { crm: { uuid: sourceCredential } }, + }), + targetState: state({ + credentials: { crm: { uuid: "target-credential" } }, + }), + bindings: { + credentials: { default: "bind", aliases: { unknown: "omit" } }, + phoneNumbers: { default: "omit", aliases: { main: "bind" } }, + }, + }); + const content = plan.changes[0]?.content ?? ""; + assert.match(content, /toolIds:\n\s+- t/); + assert.match(content, /credentialId: crm/); + assert.match(content, /credentialIds:\n\s+- crm/); + assert.doesNotMatch(content, /unknown/); + assert.match(content, /phoneNumberIds:\n\s+- target-phone/); + assert.doesNotMatch(content, new RegExp(sourceTool)); + assert.doesNotMatch(content, new RegExp(sourceCredential)); + } finally { + await fx.cleanup(); + } +}); + +test("promotion plan is dry until explicitly applied", async () => { + const fx = await fixture(); + try { + await put(fx.root, "resources/source/tools/t.yml", "type: function\n"); + const plan = await promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["tools/**"], + sourceState: state(), + targetState: state(), + }); + assert.equal(plan.changes.length, 1); + await assert.rejects( + readFile(join(fx.root, "resources/target/tools/t.yml")), + ); + } finally { + await fx.cleanup(); + } +}); + +test("promotion refuses an untracked empty-source wipe", async () => { + const fx = await fixture(); + try { + await put( + fx.root, + "resources/target/assistants/agent.md", + "---\nname: Agent\n---\nprompt\n", + ); + await assert.rejects( + promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["assistants/**"], + sourceState: state(), + targetState: state(), + }), + /empty-source mirror deletion/i, + ); + } finally { + await fx.cleanup(); + } +}); + +test("promotion replaces a destination UUID-suffixed file without duplicating it", async () => { + const fx = await fixture(); + try { + await put( + fx.root, + "resources/source/assistants/agent.md", + "---\nname: Agent\n---\nnew prompt\n", + ); + await put( + fx.root, + "resources/target/assistants/agent-aaaaaaaa.md", + "---\nname: Agent\n---\nold prompt\n", + ); + const plan = await promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["assistants/**"], + sourceState: state(), + targetState: state({ + assistants: { + "agent-aaaaaaaa": { + uuid: "aaaaaaaa-1111-1111-1111-111111111111", + }, + }, + }), + }); + assert.deepEqual( + plan.changes.map((change) => [change.kind, change.path]), + [ + ["delete", "assistants/agent-aaaaaaaa.md"], + ["create", "assistants/agent.md"], + ], + ); + } finally { + await fx.cleanup(); + } +}); + +test("promote CLI dry run uses the reviewed config without writing target files", async () => { + const fx = await fixture(); + try { + await put(fx.root, "promotion.yml", configText); + await put( + fx.root, + ".vapi-state.source.json", + `${JSON.stringify(state(), null, 2)}\n`, + ); + await put( + fx.root, + ".vapi-state.target.json", + `${JSON.stringify(state(), null, 2)}\n`, + ); + await put(fx.root, "resources/source/tools/t.yml", "type: function\n"); + + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(process.cwd(), "src/promote-cmd.ts"), + "--pipeline", + "release", + "--from", + "source", + "--to", + "target", + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env, VAPI_GITOPS_ROOT: fx.root }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /create\s+tools\/t.yml/); + await assert.rejects( + readFile(join(fx.root, "resources/target/tools/t.yml")), + ); + } finally { + await fx.cleanup(); + } +}); diff --git a/tests/vapi-ignore-push.test.ts b/tests/vapi-ignore-push.test.ts index 4e32b5b..1b768f5 100644 --- a/tests/vapi-ignore-push.test.ts +++ b/tests/vapi-ignore-push.test.ts @@ -625,6 +625,20 @@ test("findOrphanedResources: ignored ids do not affect non-ignored orphans", () ]); }); +test("findOrphanedResources: an explicit file scope cannot delete sibling orphans", () => { + const stateMap = { + selected: { uuid: DUMMY_UUID_1 }, + sibling: { uuid: DUMMY_UUID_2 }, + }; + const orphans = deleteModule.findOrphanedResources( + [], + stateMap, + undefined, + new Set(["selected"]), + ); + assert.deepEqual(orphans, [{ resourceId: "selected", uuid: DUMMY_UUID_1 }]); +}); + // ───────────────────────────────────────────────────────────────────────────── // validateNoIgnoredReferences: new validator. Returns error-severity finding // for any squad/assistant that references an ignored assistant id. From 469013eb8f413237e41bd942c173c73ed165abda Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Tue, 21 Jul 2026 21:18:08 -0700 Subject: [PATCH 2/4] fix: preserve promoted files during scoped apply --- src/pull.ts | 112 ++++++++++++++++++++++++-------- tests/apply-scoped-pull.test.ts | 32 +++++++-- 2 files changed, 111 insertions(+), 33 deletions(-) diff --git a/src/pull.ts b/src/pull.ts index cbb897b..fec6cf4 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1,15 +1,20 @@ import { execSync } from "child_process"; import { existsSync, readdirSync, statSync } from "fs"; import { mkdir, writeFile } from "fs/promises"; -import { dirname, join, relative, resolve } from "path"; +import { dirname, extname, join, relative, resolve } from "path"; import { fileURLToPath } from "url"; import { stringify } from "yaml"; -import { vapiGet, VapiApiError } from "./api.ts"; +import { VapiApiError, vapiGet } from "./api.ts"; import { fetchAllPhoneNumbers, - syncBindings, type PhoneNumberBinding, + syncBindings, } from "./bindings.ts"; +import { + buildReverseMap, + canonicalizeForHash, + type VapiResource, +} from "./canonical.ts"; import { APPLY_FILTER, BASE_DIR, @@ -22,16 +27,11 @@ import { VAPI_ENV, VAPI_TOKEN, } from "./config.ts"; -import { - buildReverseMap, - canonicalizeForHash, - type VapiResource, -} from "./canonical.ts"; import { credentialReverseMap } from "./credentials.ts"; import { classifyDrift, - formatDriftLabel, type DriftDirection, + formatDriftLabel, } from "./drift.ts"; import { readBaseline, writeBaseline } from "./hash-store.ts"; import { assertStateMigrated } from "./migrate-hash-store.ts"; @@ -42,6 +42,7 @@ import { import { FOLDER_MAP, hashLocalResource, + parseResourceFilePath, resolvePullScopeFromFilePaths, } from "./resources.ts"; import { extractBaseSlug, isBackupCopyFile, slugify } from "./slug-utils.ts"; @@ -127,6 +128,27 @@ function getLocallyChangedFiles(): Set { return files; } +export function preserveExplicitOursPaths( + changedFiles: Set | undefined, + filePaths: string[], + env: string, +): Set { + const preserved = new Set(changedFiles); + for (const filePath of filePaths) { + const parsed = parseResourceFilePath(filePath); + if (!parsed) continue; + preserved.add( + join( + "resources", + env, + FOLDER_MAP[parsed.type], + `${parsed.resourceId}${extname(filePath)}`, + ), + ); + } + return preserved; +} + // ───────────────────────────────────────────────────────────────────────────── // API Functions // ───────────────────────────────────────────────────────────────────────────── @@ -437,7 +459,11 @@ export async function writeDashboardBackup( state: StateFile, ): Promise { const credReverse = credentialReverseMap(state); - const withCredNames = canonicalizeForHash(platformPayload, state, credReverse); + const withCredNames = canonicalizeForHash( + platformPayload, + state, + credReverse, + ); // Filesystem-safe ISO timestamp: 2026-06-04T19-22-33 (no colons, no ms). const timestamp = new Date() .toISOString() @@ -487,12 +513,19 @@ function emptyDriftCounts(): DriftDirectionCounts { }; } -function parseResolveMode(explicit?: DriftResolveMode): DriftResolveMode | undefined { +function parseResolveMode( + explicit?: DriftResolveMode, +): DriftResolveMode | undefined { if (explicit) return explicit; const arg = process.argv.find((a) => a.startsWith("--resolve=")); if (!arg) return undefined; const mode = arg.slice("--resolve=".length); - if (mode === "ours" || mode === "theirs" || mode === "fail" || mode === "defer") + if ( + mode === "ours" || + mode === "theirs" || + mode === "fail" || + mode === "defer" + ) return mode; throw new Error( `Invalid --resolve value: ${mode}. Use --resolve=ours|theirs|fail|defer`, @@ -928,9 +961,7 @@ async function resolveBothDivergedResources(options: { console.error( `\n❌ ${bothDiverged.length} resource(s) have 3-way drift (both local and dashboard changed since last pull).`, ); - console.error( - " Pass --resolve=ours|theirs|fail to proceed:", - ); + console.error(" Pass --resolve=ours|theirs|fail to proceed:"); for (const entry of bothDiverged) { console.error( ` - ${FOLDER_MAP[entry.resourceType]}/${entry.resourceId}\n` + @@ -965,7 +996,11 @@ async function resolveBothDivergedResources(options: { continue; } - const withCredNames = canonicalizeForHash(entry.resource, state, credReverse); + const withCredNames = canonicalizeForHash( + entry.resource, + state, + credReverse, + ); await writeResourceFile( entry.resourceType, @@ -1093,14 +1128,31 @@ export async function runPull(options: PullOptions = {}): Promise { for (const f of changedFiles) { if (!f.startsWith(`resources/${VAPI_ENV}/`)) changedFiles.delete(f); } - if (changedFiles.size > 0) { - console.log( - `\n📦 ${changedFiles.size} locally modified file(s) will be preserved`, - ); - console.log( - " Use --force to overwrite all local files with platform state", - ); - } + } + + if ( + !force && + !bootstrap && + resolveMode === "ours" && + filePathFilter?.length + ) { + // Explicit `ours` is the authority even when git's stat cache misses a + // file that was rewritten immediately before this pull (promotion does + // exactly that). Otherwise pull can restore only part of a promotion. + changedFiles = preserveExplicitOursPaths( + changedFiles, + filePathFilter, + VAPI_ENV, + ); + } + + if (gitEnabled && changedFiles && changedFiles.size > 0) { + console.log( + `\n📦 ${changedFiles.size} locally modified file(s) will be preserved`, + ); + console.log( + " Use --force to overwrite all local files with platform state", + ); } const ignorePatterns = !bootstrap && !force ? loadIgnorePatterns() : []; @@ -1225,7 +1277,11 @@ export async function runPull(options: PullOptions = {}): Promise { pullOptsFor("structuredOutputs"), ); if (shouldPull("squads")) - stats.squads = await pullResourceType("squads", state, pullOptsFor("squads")); + stats.squads = await pullResourceType( + "squads", + state, + pullOptsFor("squads"), + ); if (shouldPull("personalities")) stats.personalities = await pullResourceType( "personalities", @@ -1308,7 +1364,9 @@ export async function runPull(options: PullOptions = {}): Promise { console.log(" 🚫 = matched .vapi-ignore (not tracked)"); console.log(" ✏️ = locally modified (preserved)"); console.log(" ⬆️ = local ahead of dashboard (preserved)"); - console.log(" ⬇️ = both diverged, --resolve=theirs (overwrote local)"); + console.log( + " ⬇️ = both diverged, --resolve=theirs (overwrote local)", + ); console.log(" 📝 = engine wrote/updated file on disk"); console.log(" 🗑️ = locally deleted (intent in state)"); console.log( @@ -1326,7 +1384,7 @@ export async function runPull(options: PullOptions = {}): Promise { "\n💡 Tip: run plain pull first (this) to see what changed before resorting to --force.", ); console.log( - " --force is for \"I know exactly what I want from the dashboard and I'm overwriting locals\" — rare.", + ' --force is for "I know exactly what I want from the dashboard and I\'m overwriting locals" — rare.', ); } } diff --git a/tests/apply-scoped-pull.test.ts b/tests/apply-scoped-pull.test.ts index 0f36dc2..bae62d0 100644 --- a/tests/apply-scoped-pull.test.ts +++ b/tests/apply-scoped-pull.test.ts @@ -4,10 +4,10 @@ import test from "node:test"; process.argv = ["node", "test", "test-fixture-org"]; process.env.VAPI_TOKEN = process.env.VAPI_TOKEN || "test-token-not-used"; -const { - parseResourceFilePath, - resolvePullScopeFromFilePaths, -} = await import("../src/resources.ts"); +const { parseResourceFilePath, resolvePullScopeFromFilePaths } = await import( + "../src/resources.ts" +); +const { preserveExplicitOursPaths } = await import("../src/pull.ts"); test("parseResourceFilePath: long-form assistant path", () => { const parsed = parseResourceFilePath( @@ -20,7 +20,9 @@ test("parseResourceFilePath: long-form assistant path", () => { }); test("parseResourceFilePath: short-form assistant path", () => { - const parsed = parseResourceFilePath("assistants/call-transfer-test-c95f4c6b.md"); + const parsed = parseResourceFilePath( + "assistants/call-transfer-test-c95f4c6b.md", + ); assert.deepEqual(parsed, { type: "assistants", resourceId: "call-transfer-test-c95f4c6b", @@ -33,7 +35,9 @@ test("resolvePullScopeFromFilePaths: maps file paths to dashboard UUIDs by state { credentials: {}, assistants: { - "call-transfer-test-c95f4c6b": { uuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + "call-transfer-test-c95f4c6b": { + uuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + }, }, structuredOutputs: {}, tools: {}, @@ -76,3 +80,19 @@ test("resolvePullScopeFromFilePaths: new resources without state skip pull", () assert.equal(scope.skippedWithoutState.length, 1); assert.equal(scope.skippedWithoutState[0]?.resourceId, "new-agent"); }); + +test("resolve ours preserves every scoped file when git misses a fresh rewrite", () => { + const assistant = + "resources/test-fixture-org/assistants/promotion-smoke-assistant.md"; + const tool = "resources/test-fixture-org/tools/promotion-smoke-lookup.yml"; + const output = + "resources/test-fixture-org/structuredOutputs/promotion-smoke-result.yml"; + + const preserved = preserveExplicitOursPaths( + new Set([tool, output]), + [assistant, tool, output], + "test-fixture-org", + ); + + assert.deepEqual([...preserved].sort(), [assistant, output, tool].sort()); +}); From 04802804d2e4c6fe6fd78b9157506b7fd9f4d3f9 Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Tue, 21 Jul 2026 21:25:17 -0700 Subject: [PATCH 3/4] fix: carry promotion deletions across orgs --- src/promote-cmd.ts | 26 ++++++++++++++++++++++---- src/promotion.ts | 4 +++- tests/promotion.test.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/promote-cmd.ts b/src/promote-cmd.ts index 27fdd0e..d55c86c 100644 --- a/src/promote-cmd.ts +++ b/src/promote-cmd.ts @@ -198,7 +198,8 @@ async function transitionRun( transition: PromotionTransition, apply: boolean, tokens: Map, -): Promise { + allowEmptySourceDeletion: boolean, +): Promise { if (apply) { childRun( "src/pull.ts", @@ -221,6 +222,7 @@ async function transitionRun( sourceState: stateLoad(transition.source), targetState: stateLoad(transition.target), bindings: config.orgs[transition.target]!.bindings, + allowEmptySourceDeletion, }); console.log( `\n${transition.pipeline}: ${transition.source} → ${transition.target}`, @@ -228,7 +230,7 @@ async function transitionRun( for (const change of plan.changes) console.log(` ${change.kind.padEnd(6)} ${change.path}`); if (plan.changes.length === 0) console.log(" no changes"); - if (!apply || plan.changes.length === 0) return; + if (!apply || plan.changes.length === 0) return false; await promotionPlanApply(plan); const changedPaths = plan.changes.map( (change) => `resources/${transition.target}/${change.path}`, @@ -239,6 +241,7 @@ async function transitionRun( connectionLoad(config, transition.target, tokens), ["--force", "--allow-new-files", "--resolve=ours", ...changedPaths], ); + return plan.changes.some((change) => change.kind === "delete"); } export async function promotionCommandRun( @@ -251,8 +254,23 @@ export async function promotionCommandRun( const config = promotionConfigParse(readFileSync(configPath, "utf8")); const tokens = parsed.apply ? tokensParse() : new Map(); delete process.env.VAPI_PROMOTION_TOKENS; - for (const transition of transitionsBuild(config, parsed)) - await transitionRun(config, transition, parsed.apply, tokens); + // Applying a deletion removes the intermediate org's state entry. Carry the + // reviewed authorization forward so the same deletion can reach later orgs. + const deletionAuthorizedSources = new Set(); + for (const transition of transitionsBuild(config, parsed)) { + const sourceKey = `${transition.pipeline}:${transition.source}`; + const deleted = await transitionRun( + config, + transition, + parsed.apply, + tokens, + deletionAuthorizedSources.has(sourceKey), + ); + if (deleted) + deletionAuthorizedSources.add( + `${transition.pipeline}:${transition.target}`, + ); + } } const isMainModule = diff --git a/src/promotion.ts b/src/promotion.ts index 3c8a7e0..b4dbfb3 100644 --- a/src/promotion.ts +++ b/src/promotion.ts @@ -61,6 +61,7 @@ export interface PromotionPlanOptions { sourceState: StateFile; targetState: StateFile; bindings?: PromotionBindings; + allowEmptySourceDeletion?: boolean; } interface PromotionResource { @@ -549,7 +550,8 @@ export async function promotionPlanBuild( if ( selectedSource.length === 0 && selectedTarget.length > 0 && - !stateConfirmsDeletion + !stateConfirmsDeletion && + !options.allowEmptySourceDeletion ) throw new Error( "Refusing an empty-source mirror deletion without matching source state", diff --git a/tests/promotion.test.ts b/tests/promotion.test.ts index 7161787..08e418c 100644 --- a/tests/promotion.test.ts +++ b/tests/promotion.test.ts @@ -334,6 +334,37 @@ test("promotion refuses an untracked empty-source wipe", async () => { } }); +test("promotion carries a reviewed empty-source deletion to the next org", async () => { + const fx = await fixture(); + try { + await put( + fx.root, + "resources/target/assistants/agent.md", + "---\nname: Agent\n---\nprompt\n", + ); + const plan = await promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["assistants/**"], + sourceState: state(), + targetState: state({ + assistants: { + agent: { uuid: "aaaaaaaa-1111-1111-1111-111111111111" }, + }, + }), + allowEmptySourceDeletion: true, + }); + + assert.deepEqual( + plan.changes.map((change) => [change.kind, change.path]), + [["delete", "assistants/agent.md"]], + ); + } finally { + await fx.cleanup(); + } +}); + test("promotion replaces a destination UUID-suffixed file without duplicating it", async () => { const fx = await fixture(); try { From 7ad38e230bd8494d5e98f8e00ff8a788f212ab2a Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Tue, 21 Jul 2026 21:25:18 -0700 Subject: [PATCH 4/4] docs: explain cross-org promotion setup --- README.md | 46 ++++++++++++++++++++++++++++++++- docs/learnings/sync-behavior.md | 22 ++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bc961e9..cb00c08 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,24 @@ 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 -- --bootstrap` for every org. Commit each + `.vapi-state..json`; never commit `.env.` 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.` 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 # Read-only plan; no files or APIs change npm run promote -- --pipeline release --from dev --to staging @@ -358,9 +376,17 @@ manual runs: 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. For a controlled single transition, run the workflow manually and provide +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 @@ -371,6 +397,24 @@ 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//` 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 source diff --git a/docs/learnings/sync-behavior.md b/docs/learnings/sync-behavior.md index 1927cbd..34e9dd9 100644 --- a/docs/learnings/sync-behavior.md +++ b/docs/learnings/sync-behavior.md @@ -176,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..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 -- --force `, 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