Skip to content

Commit 4eecfc7

Browse files
feat(promotion): gate promotion out of an org on a passing check
`orgs.<slug>.check: <name>` in promotion.yml names a vapi-checks.yml check that must pass in that org before any transition promotes out of it. The gate runs the same inline check as the PR workflow, built from the source org's files at the promoted commit, using that org's key from VAPI_PROMOTION_TOKENS. - Checks are validated before any transition: the named check must exist and read and run in the gated org. - Transitions with no changes skip the gate; plan-only runs print `check would run <name> in <org> (<n> simulations × <t> targets)` and run nothing. - On --apply the check runs after bindings refresh and before promotionPlanApply writes the target. Any non-pass (failed, incomplete, build error) throws `Promotion out of <org> blocked: check <name> <outcome> (<run url>)`, so the target is untouched and earlier transitions are still committed (previous change). - A pass is reused for later transitions out of the same org in the same run, and dropped once a transition applies into that org. - The "Reconcile configured promotions" step gets timeout-minutes: 90 on the step, not the job, so the always() commit step still runs. - With no check: configured, promotion is unchanged: a test pins the plan output to what the pre-gate code prints, and asserts no check runs and vapi-checks.yml is never read. - Docs: promotion.example.yml and README ("Check before promoting", and a pointer from "PR Checks"). Refs TEST-141 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3c1e10e commit 4eecfc7

8 files changed

Lines changed: 738 additions & 2 deletions

File tree

‎.github/workflows/promotion.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ jobs:
5656

5757
- name: Reconcile configured promotions
5858
id: promotion
59+
# A safety net only, on the step rather than the job so a timeout
60+
# still leaves the always() commit step below time to run. Gate
61+
# checks bound themselves (promotionGateRun's deadline), and their
62+
# combined budget is checked against 300 minutes when the config
63+
# loads, so this cap doesn't cut short a long ungated promotion.
64+
timeout-minutes: 330
5965
shell: bash
6066
run: |
6167
set -euo pipefail

‎README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,30 @@ its cleaned state after downstream deletion completes.
439439
See [sync behavior](docs/learnings/sync-behavior.md#cross-org-promotion-deletions)
440440
for the exact lifecycle.
441441

442+
#### Check before promoting (optional)
443+
444+
Gate an org on a [PR check](#pr-checks-simulations-against-your-branch):
445+
nothing is promoted **out of** it unless the check passes there first.
446+
447+
```yaml
448+
# promotion.yml
449+
orgs:
450+
example-staging:
451+
check: staging-core # a vapi-checks.yml check whose org (and runOrg) is example-staging
452+
```
453+
454+
- Plans print `check would run staging-core in example-staging (<n> simulations × <t> targets)`
455+
and run nothing.
456+
- On `--apply`, the check runs against `resources/example-staging/` at the
457+
promoted commit, in example-staging, with that org's key from
458+
`VAPI_PROMOTION_TOKENS`, before any file is written to the destination. A
459+
failure, an incomplete run (timeout, billing) or a build error blocks the
460+
transition with the run link; transitions that already applied are still
461+
committed.
462+
- A pass is reused for later transitions out of the same org in the same run,
463+
until something is promoted into it.
464+
- Transitions with no changes skip the check.
465+
442466
#### Rolling Back a Promotion
443467

444468
Treat a promotion rollback as a new, auditable Git change: revert the source
@@ -588,6 +612,12 @@ Require the **commit status `Vapi Evals`** in branch protection — not the
588612
status, so dispatch again after that. Running one named check by hand
589613
never changes `Vapi Evals`.
590614

615+
### Gate promotion on a check (optional)
616+
617+
Multi-org repos can require a check to pass in an org before anything is
618+
promoted out of it: set `orgs.<org>.check: <name>` in `promotion.yml` (see
619+
[Check before promoting](#check-before-promoting-optional)).
620+
591621
### Dedicated CI org (optional; recommended with `toolMocks: off`)
592622

593623
1. `npm run setup -- my-ci-org --resources none`.

‎promotion.example.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ orgs:
88
baseUrl: https://api.vapi.ai
99
example-staging:
1010
baseUrl: https://api.vapi.ai
11+
# Optional gate: nothing is promoted out of example-staging unless this
12+
# vapi-checks.yml check (whose org and runOrg are example-staging) passes
13+
# there first. Plans print what it would run; --apply runs it.
14+
# check: staging-core
1115
bindings:
1216
credentials:
1317
default: bind

‎src/promote-cmd.ts‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import { execFileSync } from "node:child_process";
22
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
33
import { dirname, resolve } from "node:path";
44
import { fileURLToPath } from "node:url";
5+
import type { CheckDefinition } from "./check-config.ts";
56
import type { OrgConnection } from "./org-connection.ts";
67
import { childRun, connectionLoad, tokensParse } from "./org-connection.ts";
78
import type { PromotionConfig, PromotionPipeline } from "./promotion.ts";
9+
import type { PromotionGateResult } from "./promotion-gate.ts";
10+
import {
11+
promotionChecksLoad,
12+
promotionGatePlanLine,
13+
promotionGateRun,
14+
} from "./promotion-gate.ts";
815
import {
916
promotionConfigParse,
1017
promotionPlanApply,
@@ -42,6 +49,17 @@ export const APPLIED_PATHS_FILE = "tmp/promotion-applied.txt";
4249

4350
export interface PromotionDeps {
4451
childRun: typeof orgScriptRun;
52+
checkRun: (
53+
check: CheckDefinition,
54+
connection: OrgConnection,
55+
) => Promise<PromotionGateResult>;
56+
}
57+
58+
// Gated orgs' checks, and the orgs whose check already passed in this run.
59+
// A pass stays valid until a transition applies into that org.
60+
interface PromotionGates {
61+
checks: Map<string, CheckDefinition>;
62+
passed: Set<string>;
4563
}
4664

4765
function argumentsParse(args: string[]): PromotionArguments {
@@ -216,6 +234,7 @@ async function transitionRun(
216234
tokens: Map<string, string>,
217235
allowEmptySourceDeletion: boolean,
218236
deps: PromotionDeps,
237+
gates: PromotionGates,
219238
): Promise<boolean> {
220239
const run = deps.childRun;
221240
if (apply) {
@@ -248,7 +267,23 @@ async function transitionRun(
248267
for (const change of plan.changes)
249268
console.log(` ${change.kind.padEnd(6)} ${change.path}`);
250269
if (plan.changes.length === 0) console.log(" no changes");
251-
if (!apply || plan.changes.length === 0) return false;
270+
if (plan.changes.length === 0) return false;
271+
const check = gates.checks.get(transition.source);
272+
if (check && !apply)
273+
console.log(await promotionGatePlanLine(ROOT_DIR, check));
274+
if (!apply) return false;
275+
if (check && !gates.passed.has(transition.source)) {
276+
console.log(` check running ${check.name} in ${transition.source}…`);
277+
const result = await deps.checkRun(
278+
check,
279+
orgConnection(config, transition.source, tokens),
280+
);
281+
if (result.outcome !== "passed")
282+
throw new Error(
283+
`Promotion out of ${transition.source} blocked: check ${check.name} ${result.outcome} (${result.url ?? result.reason})`,
284+
);
285+
gates.passed.add(transition.source);
286+
}
252287
await promotionPlanApply(plan);
253288
const changedPaths = plan.changes.map(
254289
(change) => `resources/${transition.target}/${change.path}`,
@@ -260,18 +295,30 @@ async function transitionRun(
260295
["--force", "--allow-new-files", "--resolve=ours", ...changedPaths],
261296
);
262297
appliedPathsRecord(transition.target);
298+
// The target's files just changed, so an earlier pass no longer covers it.
299+
gates.passed.delete(transition.target);
263300
return plan.changes.some((change) => change.kind === "delete");
264301
}
265302

266303
export async function promotionCommandRun(
267304
args = process.argv.slice(2),
268-
deps: PromotionDeps = { childRun: orgScriptRun },
305+
overrides: Partial<PromotionDeps> = {},
269306
): Promise<void> {
307+
const deps: PromotionDeps = {
308+
childRun: orgScriptRun,
309+
checkRun: (check, connection) =>
310+
promotionGateRun(ROOT_DIR, check, connection),
311+
...overrides,
312+
};
270313
const parsed = argumentsParse(args);
271314
const configPath = resolve(ROOT_DIR, "promotion.yml");
272315
if (!existsSync(configPath))
273316
throw new Error("promotion.yml is required at the repository root");
274317
const config = promotionConfigParse(readFileSync(configPath, "utf8"));
318+
const gates: PromotionGates = {
319+
checks: promotionChecksLoad(ROOT_DIR, config),
320+
passed: new Set(),
321+
};
275322
const tokens = parsed.apply
276323
? tokensParse(TOKENS_ENV)
277324
: new Map<string, string>();
@@ -293,6 +340,7 @@ export async function promotionCommandRun(
293340
tokens,
294341
deletionAuthorizedSources.has(sourceKey),
295342
deps,
343+
gates,
296344
);
297345
if (deleted)
298346
deletionAuthorizedSources.add(

‎src/promotion-gate.ts‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// The promotion check gate: `orgs.<slug>.check: <name>` in promotion.yml
2+
// names a vapi-checks.yml check that must pass in that org before any
3+
// transition promotes out of it. The gate runs the same check as the PR
4+
// workflow, built from the source org's files at the promoted commit.
5+
6+
import type { CheckDefinition } from "./check-config.ts";
7+
import { CHECKS_CONFIG_FILE, checksConfigLoad } from "./check-config.ts";
8+
import { checkJobsBuild } from "./check-build.ts";
9+
import type { CheckOutcome, CheckTargetResult } from "./check-run.ts";
10+
import { checkRunAll, MAX_CONCURRENT, MIN_START_MS } from "./check-run.ts";
11+
import type { OrgConnection } from "./org-connection.ts";
12+
import type { PromotionConfig } from "./promotion.ts";
13+
import { userAgentGet } from "./user-agent.ts";
14+
15+
export interface PromotionGateResult {
16+
outcome: CheckOutcome;
17+
reason: string;
18+
url?: string;
19+
}
20+
21+
const DEFAULT_BASE_URL = "https://api.vapi.ai";
22+
// Gate checks must fit well inside the promotion step's timeout, so a check
23+
// that can't finish is rejected when the config loads, not killed mid-run.
24+
export const GATE_BUDGET_MINUTES = 300;
25+
const OUTCOME_RANK: Record<CheckOutcome, number> = {
26+
passed: 0,
27+
built: 0,
28+
incomplete: 1,
29+
failed: 2,
30+
error: 3,
31+
};
32+
33+
// The gated orgs' checks, validated up front so a typo fails before any
34+
// transition applies.
35+
export function promotionChecksLoad(
36+
rootDir: string,
37+
config: PromotionConfig,
38+
): Map<string, CheckDefinition> {
39+
const gated = Object.entries(config.orgs).filter(([, org]) => org.check);
40+
const checks = new Map<string, CheckDefinition>();
41+
if (gated.length === 0) return checks;
42+
const checksConfig = checksConfigLoad(rootDir);
43+
if (!checksConfig)
44+
throw new Error(
45+
`promotion.yml gates ${gated.map(([slug]) => slug).join(", ")} on checks, but there is no ${CHECKS_CONFIG_FILE}`,
46+
);
47+
// An org that is last in every pipeline is never promoted out of.
48+
const sources = new Set(
49+
Object.values(config.pipelines).flatMap((pipeline) =>
50+
pipeline.orgs.slice(0, -1),
51+
),
52+
);
53+
let budgetMinutes = 0;
54+
for (const [slug, org] of gated) {
55+
if (!sources.has(slug))
56+
throw new Error(
57+
`orgs.${slug}.check: nothing is promoted out of ${slug} (it is last in every pipeline), so this check would never run; gate the org before it instead`,
58+
);
59+
const check = checksConfig.checks[org.check!];
60+
if (!check)
61+
throw new Error(
62+
`orgs.${slug}.check: no check named ${org.check} in ${CHECKS_CONFIG_FILE}`,
63+
);
64+
if (check.org !== slug || check.runOrg !== slug)
65+
throw new Error(
66+
`orgs.${slug}.check: check ${check.name} must read and run in ${slug} (it reads ${check.org} and runs in ${check.runOrg})`,
67+
);
68+
// A gate runs in the real org, never a CI org, so it must not reach real
69+
// systems: no live tools, and no webhooks to the org's own servers.
70+
if (check.toolMocks === "off")
71+
throw new Error(
72+
`orgs.${slug}.check: check ${check.name} sets toolMocks: off, which runs real tools; a gate runs in ${slug} itself, so it must use toolMocks: strict`,
73+
);
74+
if (!check.stripWebhooks)
75+
throw new Error(
76+
`orgs.${slug}.check: check ${check.name} sets stripWebhooks: false, which sends simulated calls' webhooks to ${slug}'s real servers; a gate must keep the default`,
77+
);
78+
// The org's token goes only to the host promotion uses for that org.
79+
if (check.baseUrl && check.baseUrl !== org.baseUrl?.replace(/\/+$/, ""))
80+
throw new Error(
81+
`orgs.${slug}.check: check ${check.name} uses ${check.baseUrl}, but promotion.yml uses ${org.baseUrl ?? "the default API"} for ${slug}; set the same baseUrl in both`,
82+
);
83+
budgetMinutes += gateBudgetMinutes(check);
84+
checks.set(slug, check);
85+
}
86+
if (budgetMinutes > GATE_BUDGET_MINUTES)
87+
throw new Error(
88+
`promotion.yml's gated checks can take up to ${budgetMinutes} minutes in one run, more than the ${GATE_BUDGET_MINUTES} the promotion step allows; lower their timeoutMinutes or targets`,
89+
);
90+
return checks;
91+
}
92+
93+
// The longest a gate check can run: targets run MAX_CONCURRENT at a time,
94+
// and each batch gets a full timeoutMinutes.
95+
export function gateBudgetMinutes(
96+
check: Pick<CheckDefinition, "timeoutMinutes"> & {
97+
targets: readonly unknown[];
98+
},
99+
): number {
100+
return (
101+
Math.ceil(check.targets.length / MAX_CONCURRENT) * check.timeoutMinutes +
102+
MIN_START_MS / 60_000
103+
);
104+
}
105+
106+
export function gateDeadline(
107+
check: Pick<CheckDefinition, "timeoutMinutes"> & {
108+
targets: readonly unknown[];
109+
},
110+
now: number,
111+
): number {
112+
return now + gateBudgetMinutes(check) * 60_000;
113+
}
114+
115+
// Anything short of every target passing blocks: the worst target wins.
116+
export function gateResultReduce(
117+
results: Array<Pick<CheckTargetResult, "outcome" | "reason" | "url">>,
118+
): PromotionGateResult {
119+
const worst = results.reduce((a, b) =>
120+
OUTCOME_RANK[b.outcome] > OUTCOME_RANK[a.outcome] ? b : a,
121+
);
122+
return { outcome: worst.outcome, reason: worst.reason, url: worst.url };
123+
}
124+
125+
// The plan-only line: what the gate would run, built offline.
126+
export async function promotionGatePlanLine(
127+
rootDir: string,
128+
check: CheckDefinition,
129+
): Promise<string> {
130+
const jobs = await checkJobsBuild(rootDir, check);
131+
const broken = jobs.find((job) => !job.result.body);
132+
if (broken)
133+
return ` check would run ${check.name} in ${check.org}, but its payload doesn't build: ${broken.result.errors[0]}`;
134+
const simulations = jobs[0]?.result.body?.simulations.length ?? 0;
135+
return ` check would run ${check.name} in ${check.org} (${simulations} simulation${simulations === 1 ? "" : "s"} × ${jobs.length} target${jobs.length === 1 ? "" : "s"})`;
136+
}
137+
138+
// Run the check live in the source org and reduce it to one result: the
139+
// worst target wins, so anything short of every target passing blocks.
140+
export async function promotionGateRun(
141+
rootDir: string,
142+
check: CheckDefinition,
143+
connection: OrgConnection,
144+
): Promise<PromotionGateResult> {
145+
const jobs = await checkJobsBuild(rootDir, check);
146+
const controller = new AbortController();
147+
const abort = () => controller.abort();
148+
process.once("SIGINT", abort);
149+
process.once("SIGTERM", abort);
150+
try {
151+
const results = await checkRunAll({
152+
jobs,
153+
connectionFor: () => ({
154+
token: connection.token,
155+
baseUrl: connection.baseUrl ?? DEFAULT_BASE_URL,
156+
userAgent: userAgentGet("check"),
157+
}),
158+
deadline: gateDeadline(check, Date.now()),
159+
signal: controller.signal,
160+
});
161+
for (const result of results)
162+
console.log(
163+
` check ${result.job.label}: ${result.outcome} — ${result.reason}${result.url ? ` (${result.url})` : ""}`,
164+
);
165+
return gateResultReduce(results);
166+
} finally {
167+
process.off("SIGINT", abort);
168+
process.off("SIGTERM", abort);
169+
}
170+
}

‎src/promotion.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export interface PromotionBindings {
2929
export interface PromotionOrg {
3030
baseUrl?: string;
3131
bindings: PromotionBindings;
32+
// A vapi-checks.yml check that must pass in this org before anything is
33+
// promoted out of it.
34+
check?: string;
3235
}
3336

3437
export interface PromotionPipeline {
@@ -142,6 +145,8 @@ export function promotionBindingsParse(value: unknown): PromotionBindings {
142145
};
143146
}
144147

148+
const PROMOTION_ORG_KEYS = ["baseUrl", "bindings", "check"];
149+
145150
export function promotionConfigParse(content: string): PromotionConfig {
146151
const raw = object(parseYaml(content), "promotion.yml");
147152
if (raw.version !== 1) throw new Error("promotion.yml version must be 1");
@@ -150,11 +155,25 @@ export function promotionConfigParse(content: string): PromotionConfig {
150155
for (const [slug, value] of Object.entries(orgsRaw)) {
151156
if (!SLUG_RE.test(slug)) throw new Error(`Invalid org slug: ${slug}`);
152157
const org = object(value ?? {}, `org ${slug}`);
158+
// A typo (`checks:`, `Check:`) must not quietly drop a safety gate.
159+
for (const key of Object.keys(org))
160+
if (!PROMOTION_ORG_KEYS.includes(key))
161+
throw new Error(
162+
`org ${slug} has unknown key "${key}" (allowed: ${PROMOTION_ORG_KEYS.join(", ")})`,
163+
);
153164
if (org.baseUrl !== undefined && typeof org.baseUrl !== "string")
154165
throw new Error(`org ${slug}.baseUrl must be a string`);
166+
if (
167+
org.check !== undefined &&
168+
(typeof org.check !== "string" || !SLUG_RE.test(org.check))
169+
)
170+
throw new Error(
171+
`org ${slug}.check must be a check name from vapi-checks.yml`,
172+
);
155173
orgs[slug] = {
156174
baseUrl: typeof org.baseUrl === "string" ? org.baseUrl : undefined,
157175
bindings: promotionBindingsParse(org.bindings),
176+
...(typeof org.check === "string" ? { check: org.check } : {}),
158177
};
159178
}
160179
const pipelinesRaw = object(raw.pipelines, "promotion.yml pipelines");

0 commit comments

Comments
 (0)