Repository navigation
ci: validate every org's resources on every pull request - #76
scott-lowe-vapi wants to merge 1 commit into
Conversation
Nothing ran `npm run validate` before merge. A config that `apply` refuses (name length, structured-output lockstep, duplicated prompts, the maxTokens floor, voice schema) could merge green, and deploys and promotion out of main then stopped until a fix landed. Plain `push` only warns, and can fail partway with an API 400. - ci.yml gets a Validate resources job: validate for every folder under resources/, reporting every failing org rather than stopping at the first. validate makes no network call; the engine's config only needs a key to be set, so the step sets a placeholder that is never sent. The job has no secrets, so forks get it too. No engine change. - tests/ci-validate-workflow.test.ts runs the step itself against fixture orgs: no orgs, all valid, one invalid org among valid ones, an invalid folder name, and no secrets or persisted credentials. - README, AGENTS.md (change loop), the workflows, PR checks and troubleshooting guides, and improvements.md #37 describe it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chris-garber-vapi
left a comment
There was a problem hiding this comment.
I think running validate in CI makes sense
chris-garber-vapi
left a comment
There was a problem hiding this comment.
Review of the Validate resources job. Nothing here blocks merge (no 🔴 or 🟠). The main themes: the docs overstate what fails (3 of the 5 named rules are warnings, and they stay hidden), the local repro path needs a real org key, env-dependent .ts resources validate differently in CI than under apply, and every org being validated on every PR widens the blast radius. Suggestion blocks were run locally against the PR head unless noted.
| node-version: 22 | ||
| cache: npm | ||
|
|
||
| - run: npm ci |
There was a problem hiding this comment.
🟢 npm ci --ignore-scripts is enough here, and it skips the native builds of the optional audio deps (mic, speaker) that validate never loads.
I checked this locally. After npm ci --ignore-scripts, tsx and esbuild run fine and all 5 tests in tests/ci-validate-workflow.test.ts pass. The job also stops depending on the runner having a C toolchain or ALSA headers. Don't use --omit=optional: esbuild's platform binary is an optional dependency, so tsx would break.
| - run: npm ci | |
| # Install scripts only build the optional audio deps for `npm run call`. | |
| - run: npm ci --ignore-scripts |
| # The same checks `apply` runs before every deploy, here before merge: | ||
| # a config that `apply` would refuse never reaches main, where it would |
There was a problem hiding this comment.
🟡 This isn't quite "the same checks apply runs": .ts resources execute on load, and here they can't see the .env.<org> values they get under apply.
config.ts merges .env.<org> into process.env before any resource loads. resourceDirLoad then import()s every .ts resource, and docs/guides/file-formats.md pitches those files as "useful for generating config". This job has no .env.<org>, so a resource that reads the environment validates differently here. Reproduced:
// resources/clinic/assistants/overflow.ts
const site = process.env.CLINIC_SITE;
if (!site) throw new Error("CLINIC_SITE is not set");
export default { name: `${site} Overflow` };== local, with .env.clinic (what apply runs):
✅ Validation passed.
== CI-like, placeholder key only:
❌ Validation failed: Failed to import TypeScript resource "overflow.ts": Error: CLINIC_SITE is not set
So this check fails a config that apply accepts. Once the check is required, that blocks every PR in the repo, and AGENTS.md now says not to weaken the check. It can go wrong the other way too: name: `${process.env.SITE ?? ""} …` can pass here and still break the 40-character cap under apply.
The cheapest fix is to document the constraint instead of changing behavior:
- In
docs/guides/file-formats.md, under TypeScript resources, add something like: "CI validates.tsresources without your.env.<org>. Build them from files in the repo, not fromprocess.env." - In
docs/guides/troubleshooting.md, add a line saying that aFailed to import TypeScript resource … is not seterror from this check means the resource reads.env.<org>. - Soften this comment, and the README and workflows.md wording, to "the same validator
applyruns".
| # block deploys and promotion until someone noticed. Every org is | ||
| # validated, including resources no PR check targets. |
There was a problem hiding this comment.
🟡 Validating every org on every PR means that one broken org, or one validator false positive, fails every PR in the repo, including PRs from teams that never touch that org.
The heads-up in the description covers errors that already exist. The ongoing cost is the blast radius. When apply refuses a config, only that org's deploys stop. When this job fails, every PR in the repo is blocked. In a multi-team repo, team A's PRs go red because of team B's org, and the docs tell A to make this a required check. With AGENTS.md's new "Don't weaken the check", an agent working for team A can only get unblocked by editing team B's resources.
If a PR touches neither the engine nor org B, org B's result can't change. Re-validating B adds no signal; it only adds ways to block. Suggest:
- Pushes to
main, and PRs that touchsrc/**,package*.jsonorci.yml: validate every org. That catches validator and engine regressions, and breakage that was already there turnsmainred, which is where it belongs. - All other PRs: validate only the orgs whose
resources/<org>/changed.
Sketch. Checkout needs fetch-depth: 2: on pull_request, HEAD is the merge commit and HEAD^1 is the base.
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
changed=$(git diff --name-only HEAD^1 HEAD)
if ! grep -qE '^(src/|package(-lock)?\.json$|\.github/workflows/ci\.yml$)' <<<"$changed"; then
mapfile -t touched < <(sed -nE 's#^resources/([^/]+)/.*#\1#p' <<<"$changed" | sort -u)
# keep only entries of $orgs that appear in $touched (a deleted org has no folder)
fi
fiKeeping every org is a defensible choice too. In that case, workflows.md should say plainly that making this check required ties every team's PRs to the health of every org.
| - name: Validate every org | ||
| shell: bash | ||
| env: | ||
| VAPI_PRIVATE_API_KEY: validate-only-never-sent |
There was a problem hiding this comment.
🟢 Nothing enforces "never sent". Pinning VAPI_BASE_URL to an unroutable address would make any accidental API call fail on the runner instead.
Right now the claim holds only because nobody has added a network call. Suppose validate-cmd, or something it imports, starts calling the API, for example to check references against the platform. This step would then send Bearer validate-only-never-sent to api.vapi.ai and fail with a confusing 401. In config.ts an exported env var beats .env* files, so the pin sticks:
| VAPI_PRIVATE_API_KEY: validate-only-never-sent | |
| VAPI_PRIVATE_API_KEY: validate-only-never-sent | |
| # Unroutable, so an accidental API call fails here instead of | |
| # reaching api.vapi.ai. | |
| VAPI_BASE_URL: http://127.0.0.1:9 |
The key: expectation in tests/ci-validate-workflow.test.ts compares the whole env with deepEqual, so it needs VAPI_BASE_URL added too.
The root cause is worth a stacked PR. config.ts exits at import when no key is set, even for offline commands. Checking for the key lazily, in the API client or in a requireApiKey() that only network commands call, would remove the need for this placeholder. It would also drop it from the troubleshooting guide and AGENTS.md (see my comments there).
| for org in "${orgs[@]}"; do | ||
| echo "::group::Validate ${org}" | ||
| if ! node --import tsx src/validate-cmd.ts "$org"; then | ||
| failed+=("$org") | ||
| fi | ||
| echo "::endgroup::" | ||
| done |
There was a problem hiding this comment.
🟡 Three of the five rules this PR says it catches are warnings, so they pass this check and stay hidden inside a collapsed log group.
In src/validate.ts, so-assistant-lockstep, prompt-duplicate-* and max-tokens-floor have severity: "warn". Only name-length and voice-provider-schema are errors. validate-cmd exits 0 on warnings, so on a green run nobody sees them unless they open the job and expand the group. For example, the starter example that the "every org is valid" test copies already emits one, and the test passes without anyone noticing:
⚠️ [so-assistant-lockstep] assistants/receptionist (artifactPlan.structuredOutputIds): assistant "receptionist" lists SO "call-summary" … but SO "call-summary" does NOT list this assistant in assistant_ids
Turning findings into annotations shows them on the PR's Checks summary without changing what fails, and needs no engine change. I ran this against the starter fixture, and all 5 tests still pass:
| for org in "${orgs[@]}"; do | |
| echo "::group::Validate ${org}" | |
| if ! node --import tsx src/validate-cmd.ts "$org"; then | |
| failed+=("$org") | |
| fi | |
| echo "::endgroup::" | |
| done | |
| for org in "${orgs[@]}"; do | |
| echo "::group::Validate ${org}" | |
| output=$(node --import tsx src/validate-cmd.ts "$org" 2>&1) || failed+=("$org") | |
| printf '%s\n' "$output" | |
| echo "::endgroup::" | |
| # Lift each finding into an annotation, warnings included, so it | |
| # shows on the PR's Checks summary and not only inside the | |
| # collapsed group above. | |
| sed -nE \ | |
| -e "s/^ ❌ \[([^]]+)\] (.*)$/::error title=\1 (${org})::\2/p" \ | |
| -e "s/^ ⚠️ +\[([^]]+)\] (.*)$/::warning title=\1 (${org})::\2/p" \ | |
| <<<"$output" | |
| done |
On the starter, it prints ::warning title=so-assistant-lockstep (clinic)::assistants/receptionist (artifactPlan.structuredOutputIds): …
Also:
- Pin this in the "every org is valid" test by asserting that
run.outputincludes::warning title=so-assistant-lockstep (clinic)::. That also records that warnings don't fail the job. - The PR description and commit message list lockstep, duplicated prompts and the
maxTokensfloor among the errorsapplyrefuses. Please fix both. (Theimprovements.mdwording has its own comment.) - Possible stacked PR: a
validate-cmd --format=githubthat emits::error file=<ResourceFile.filePath>,…would pin each annotation to the offending file in the diff, which beats scraping the emoji output.
| It needs no secrets, so it runs on forks too. Make it a required check in | ||
| branch protection, so a config that `apply` would refuse can't reach | ||
| `main`, where it would block deploys and promotion. |
There was a problem hiding this comment.
🟢 "Make it a required check" needs a caveat for merge queues: ci.yml has no merge_group trigger, so inside a GitHub merge queue the required check never reports and the queue stalls.
This applies to the existing test job too. This PR is the first place the docs tell people to require a check from ci.yml, so it's the natural spot for the caveat:
| It needs no secrets, so it runs on forks too. Make it a required check in | |
| branch protection, so a config that `apply` would refuse can't reach | |
| `main`, where it would block deploys and promotion. | |
| It needs no secrets, so it runs on forks too. Make it a required check in | |
| branch protection, so a config that `apply` would refuse can't reach | |
| `main`, where it would block deploys and promotion. If you merge through a | |
| GitHub merge queue, first add `merge_group:` to `ci.yml`'s `on:` triggers, or | |
| the required check never reports and the queue stalls. |
| | 34 | No pre-merge simulation signal; simulations only tested what was deployed | A PR that breaks an agent merges green | #33 | RESOLVED 2026-10-01 | | ||
| | 35 | A failed promotion pushed nothing, not even state | git lost track of resources already on the platform | None | RESOLVED 2026-10-01 | | ||
| | 36 | `cleanup` deletes resources excluded by `.vapi-ignore` | A destructive cleanup can delete resources another team owns | None | RESOLVED 2026-10-03 | | ||
| | 37 | Resource validation ran only at deploy time, after merge | A config `apply` refuses could merge and block deploys and promotion | #32 | RESOLVED 2026-10-03 | |
There was a problem hiding this comment.
🟢 CLAUDE.md asks for the PR number on resolved entries ([RESOLVED YYYY-MM-DD] (#<PR-number>)), and #37 doesn't have one.
#33–#36, earlier in this stack, also leave it out, but #29, #30 and #32 include it. Please add (#76) here and on the **[RESOLVED 2026-10-03]** line under ## 37..
| | 37 | Resource validation ran only at deploy time, after merge | A config `apply` refuses could merge and block deploys and promotion | #32 | RESOLVED 2026-10-03 | | |
| | 37 | Resource validation ran only at deploy time, after merge | A config `apply` refuses could merge and block deploys and promotion | #32 | RESOLVED 2026-10-03 (#76) | |
| `npm run validate` catches the shapes the API rejects (name length, | ||
| structured-output lockstep, duplicated prompts, the `maxTokens` floor, | ||
| per-provider voice schema), but nothing ran it before merge. A config that | ||
| `apply` refuses could land on `main`, and was found only when someone | ||
| deployed or promoted it. |
There was a problem hiding this comment.
🟡 The entry lists lockstep, duplicated prompts and the maxTokens floor as shapes the API rejects, but they're warnings: they fail neither validate, apply, nor this check.
In src/validate.ts, name-length and voice-provider-schema have severity: "error", while so-assistant-lockstep, prompt-duplicate-h1/-block and max-tokens-floor have "warn". Lockstep problems and maxTokens: 1 aren't API rejections either; they're silent inconsistencies. This file is the durable record ("the history is the point"), so a wrong claim here will outlive the PR description.
| `npm run validate` catches the shapes the API rejects (name length, | |
| structured-output lockstep, duplicated prompts, the `maxTokens` floor, | |
| per-provider voice schema), but nothing ran it before merge. A config that | |
| `apply` refuses could land on `main`, and was found only when someone | |
| deployed or promoted it. | |
| `npm run validate` fails on shapes the API rejects mid-push (a name over 40 | |
| characters, per-provider voice schema) and warns on silent inconsistencies | |
| (structured-output lockstep, duplicated prompts, the `maxTokens` floor), but | |
| nothing ran it before merge. A config that `apply` refuses could land on | |
| `main`, and was found only when someone deployed or promoted it. |
| const JOB = ( | ||
| parseYaml(WORKFLOW_TEXT) as { jobs: { validate: { steps: Step[] } } } | ||
| ).jobs.validate; | ||
| const STEP = JOB.steps.find((s) => s.name === "Validate every org")!; |
There was a problem hiding this comment.
🟢 The ! hides a renamed step: every test would fail with Cannot read properties of undefined (reading 'run') instead of naming the cause.
| const STEP = JOB.steps.find((s) => s.name === "Validate every org")!; | |
| const STEP = JOB.steps.find((s) => s.name === "Validate every org"); | |
| assert.ok(STEP, 'ci.yml has no "Validate every org" step; update this test if it was renamed'); |
| test("validate job gets no secrets and keeps no credentials", () => { | ||
| assert.deepEqual( | ||
| { | ||
| secrets: WORKFLOW_TEXT.includes("secrets."), | ||
| key: STEP.env, | ||
| checkout: JOB.steps.find((s) => s.uses?.startsWith("actions/checkout")) | ||
| ?.with, | ||
| }, | ||
| { | ||
| secrets: false, | ||
| key: { VAPI_PRIVATE_API_KEY: "validate-only-never-sent" }, | ||
| checkout: { "persist-credentials": false }, | ||
| }, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
🟢 The no-secrets assertion searches all of ci.yml for the literal secrets.. That's too broad, because it breaks on unrelated jobs, and too narrow, because it misses the ways this job could actually gain privileges.
- Too broad: if another
ci.ymljob ever needs a secret (a coverage upload token, say), this test fails even though the validate job didn't change. - Too narrow: it misses
secrets['X'],toJSON(secrets),secrets: inherit, a job-levelpermissions:escalation, and a switch topull_request_target. That last one is what would actually hand fork code a write token and secrets.
I tested the version below. It passes on this PR as-is, and it fails when I add permissions: { contents: write } to the job:
| test("validate job gets no secrets and keeps no credentials", () => { | |
| assert.deepEqual( | |
| { | |
| secrets: WORKFLOW_TEXT.includes("secrets."), | |
| key: STEP.env, | |
| checkout: JOB.steps.find((s) => s.uses?.startsWith("actions/checkout")) | |
| ?.with, | |
| }, | |
| { | |
| secrets: false, | |
| key: { VAPI_PRIVATE_API_KEY: "validate-only-never-sent" }, | |
| checkout: { "persist-credentials": false }, | |
| }, | |
| ); | |
| }); | |
| test("validate job gets no secrets and keeps no credentials", () => { | |
| const workflow = parseYaml(WORKFLOW_TEXT) as { | |
| on: Record<string, unknown>; | |
| jobs: { validate: Record<string, unknown> }; | |
| }; | |
| assert.deepEqual( | |
| { | |
| // Fork code runs in this job, so it must never get the privileged trigger. | |
| privilegedTrigger: "pull_request_target" in workflow.on, | |
| // Scoped to this job, and catches secrets.X, secrets['X'], | |
| // toJSON(secrets) and `secrets: inherit`. | |
| secrets: /\bsecrets\b/.test(JSON.stringify(workflow.jobs.validate)), | |
| permissions: workflow.jobs.validate.permissions, | |
| key: STEP.env, | |
| checkout: JOB.steps.find((s) => s.uses?.startsWith("actions/checkout")) | |
| ?.with, | |
| }, | |
| { | |
| privilegedTrigger: false, | |
| secrets: false, | |
| permissions: undefined, | |
| key: { VAPI_PRIVATE_API_KEY: "validate-only-never-sent" }, | |
| checkout: { "persist-credentials": false }, | |
| }, | |
| ); | |
| }); |

Value
V.A.L.U.E. tier: small — touches
.github/workflows/(a blast-radius path), and changes what customer forks see on their pull requests.npm run validatebefore merge.apply, and so promotion, refuses to deploy a config with validation errors: a name over 40 characters, structured-output lockstep, duplicated prompts, themaxTokensfloor, or the voice schema. That refusal came after merge:mainheld a config that wouldn't deploy, and promotion stopped until someone opened a fix PR. Plainpushonly warns, then can fail partway with an API 400. Each rule in the validator comes from a real mid-push failure (improvements.mdmembersOverrides.artifactPlan.structuredOutputIds is requiring UUID #8, Specifying handoff tools in a squad requires UUID to function correctly #9, fix(call): clear wrapped partial transcripts cleanly in npm run call #11, feat: simulation suite runner (npm run sim) #18, refactor: state schema with per-resource content hashes #19).main.ci.ymlrunsvalidatefor every folder underresources/on every pull request. It reports every failing org rather than stopping at the first.validatemakes no network call; loading the engine's config only requires a key to be set, so the step sets a placeholder that is never sent. The job has no secrets, so it runs the same on forks and Dependabot PRs.ci.yml, not the PR check workflow, so every fork gets it without turning on PR checks. On this template, which has no org folders, it does nothing.AGENTS.mdchange loop: if the check fails, fix the errors and don't weaken the check;improvements.mdAdd GitOps Support for Pronunciation Dictionaries with Versioned Updates #37.Heads-up for forks: plain
pushonly warned about these errors, so a repo may already carry some. The first PR after this lands will show them, whatever it changes. The troubleshooting guide covers it.applyalready refused those configs, so this moves an existing failure earlier rather than adding a new one.Evidence of value
tests/ci-validate-workflow.test.tsruns the job's real step, read fromci.yml, against copies of the starter example:Mutation: making the loop ignore
validate's exit code fails the two failure-case tests.Testing plan
npm test(514 tests) andnpx tsc --noEmitpass.applyalready runs it on every deploy.Refs TEST-141
🤖 Generated with Claude Code