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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,57 @@ jobs:

- name: Lint workflows
run: ./actionlint -color

validate:
name: Validate resources
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

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

- run: npm ci

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

Suggested change
- 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
Comment on lines +85 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 .ts resources without your .env.<org>. Build them from files in the repo, not from process.env."
  • In docs/guides/troubleshooting.md, add a line saying that a Failed to import TypeScript resource … is not set error from this check means the resource reads .env.<org>.
  • Soften this comment, and the README and workflows.md wording, to "the same validator apply runs".

# block deploys and promotion until someone noticed. Every org is
# validated, including resources no PR check targets.
Comment on lines +87 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 touch src/**, package*.json or ci.yml: validate every org. That catches validator and engine regressions, and breakage that was already there turns main red, 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
fi

Keeping 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.

#
# `validate` makes no network call, but loading the engine's config
# needs a key to be set. The placeholder below is never sent anywhere,
# and no secret is available to this job, so it runs the same on forks.
- name: Validate every org
shell: bash
env:
VAPI_PRIVATE_API_KEY: validate-only-never-sent

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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:

Suggested change
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).

run: |
set -euo pipefail
shopt -s nullglob
orgs=()
for dir in resources/*/; do
orgs+=("$(basename "$dir")")
done
if (( ${#orgs[@]} == 0 )); then
echo "No org folders under resources/; nothing to validate."
exit 0
fi
failed=()
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
Comment on lines +109 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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:

Suggested change
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.output includes ::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 maxTokens floor among the errors apply refuses. Please fix both. (The improvements.md wording has its own comment.)
  • Possible stacked PR: a validate-cmd --format=github that emits ::error file=<ResourceFile.filePath>,… would pin each annotation to the offending file in the diff, which beats scraping the emoji output.

if (( ${#failed[@]} > 0 )); then
echo "::error::Validation failed for: ${failed[*]}. Run \`npm run validate -- <org>\` locally to see each finding."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The failure message sends people to a local command that won't run without the org's real API key, and it doesn't say the findings are already in this log.

Every finding is already printed inside that org's ::group:: above, so "run locally to see each finding" sends people to redo work they don't need to. The local command also fails for anyone without .env.<org>:

❌ No Vapi private API key found for org "clinic".
   Copy a private key from https://dashboard.vapi.ai/org/api-keys (Private API Keys section),
   then add it to .env.clinic as: VAPI_PRIVATE_API_KEY=<your private API key>

That covers fork contributors and people triaging Dependabot PRs, the very people this job is meant for, plus teammates without access to that org. The error then tells them to fetch a production private key for a check that is offline. Point at the log instead, and give the placeholder the job itself uses:

Suggested change
echo "::error::Validation failed for: ${failed[*]}. Run \`npm run validate -- <org>\` locally to see each finding."
echo "::error::Validation failed for: ${failed[*]}. Each org's findings are in its log group above. To reproduce locally without that org's key: VAPI_PRIVATE_API_KEY=validate-only npm run validate -- <org>"

The existing test still passes, since it matches ::error::Validation failed for: clinic..

exit 1
fi
echo "Validated ${#orgs[@]} org(s): ${orgs[*]}"
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ precisely.
2. **Edit the files** under `resources/<org>/`. Settings and examples:
[resource reference](docs/guides/resource-reference.md); tested files to
copy from: [`examples/starter/`](examples/starter/README.md).
3. **Validate:** `npm run validate -- <org>` (offline).
3. **Validate:** `npm run validate -- <org>` (offline). CI's **Validate
resources** check runs it for every org on every PR; if that check fails,
run it locally for the org it names and fix the errors. Don't weaken the
check or the workflow to get past it.
Comment on lines +133 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 An agent following step 3 without .env.<org> hits the "missing API key" error, which tells it to get a production private key just to run an offline check.

AGENTS.md is the agent's playbook. Picture an agent fixing a red Validate resources check in a fresh clone or a cloud sandbox. It will either stop and ask the user for that org's private key, or give up. A human pasting a prod key into an agent session to run an offline check is the wrong outcome. One extra sentence fixes it:

Suggested change
3. **Validate:** `npm run validate -- <org>` (offline). CI's **Validate
resources** check runs it for every org on every PR; if that check fails,
run it locally for the org it names and fix the errors. Don't weaken the
check or the workflow to get past it.
3. **Validate:** `npm run validate -- <org>` (offline). CI's **Validate
resources** check runs it for every org on every PR; if that check fails,
run it locally for the org it names and fix the errors. Without that org's
`.env.<org>`, run `VAPI_PRIVATE_API_KEY=validate-only npm run validate -- <org>`;
never ask for a real key just to validate. Don't weaken the check or the
workflow to get past it.

4. **Build PR checks offline** if `vapi-checks.yml` exists:
`npm run check -- --all --dry-run`. Fix anything it reports.
5. **Deploy only with a yes** (safety rule 1): `npm run apply -- <org>`, or
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ npm run apply -- my-org # pull the latest, merge, push
```

Commit the changed files and `.vapi-state.my-org.json` so your team shares the
same name → UUID mappings.
same name → UUID mappings. Every pull request runs the same validation for
every org in CI (the **Validate resources** check), so a config `apply` would
refuse fails before it merges.

### 5. Test it

Expand Down
3 changes: 3 additions & 0 deletions docs/guides/pr-checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ PR push. It asks for `statuses: write` only to post the direct links.
`promotion.yml`, the engine (`src/**`, `package*.json`), nor the check's
own `paths` skip it, and `Vapi Evals` posts success.
- A newer push cancels the older run.
- Separately, the **Validate resources** check (in `ci.yml`) runs
`npm run validate` on every org, including resources no check targets.
It's offline and runs whether or not PR checks are turned on.

## 7. Make it required (after a burn-in)

Expand Down
19 changes: 19 additions & 0 deletions docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,22 @@ falling through to a full deploy. Pass either:
- a resource type — `npm run push -- my-org assistants`, or
- a path — `npm run push -- my-org assistants/foo.yml` (short form)
or `npm run push -- my-org resources/my-org/assistants/foo.yml` (long form).

## "Validate resources" fails in CI

The check runs `npm run validate` for every org under `resources/`. The
job log names each failing org; run the same command locally to see its
findings:

```bash
npm run validate -- <org>
```

Each error names the file, field and rule. Plain `push` only warns about
Comment on lines +87 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The local repro step fails for anyone without that org's .env.<org>, and the error it prints tells them to go get the org's production private key.

Running npm run validate -- <org> with no key prints ❌ No Vapi private API key found for org "<org>". Copy a private key from https://dashboard.vapi.ai/org/api-keys …. The people most likely to land on this page are fork contributors, people triaging Dependabot PRs, and teammates who don't deploy that org. None of them have that key, and none of them should need it for an offline check.

Also, a finding names the resource, not the file. formatFinding prints type/resourceId (fieldPath), e.g. assistants/front-desk-overflow (name).

Suggested change
The check runs `npm run validate` for every org under `resources/`. The
job log names each failing org; run the same command locally to see its
findings:
```bash
npm run validate -- <org>
```
Each error names the file, field and rule. Plain `push` only warns about
The check runs `npm run validate` for every org under `resources/`. The
job log names each failing org, and that org's log group lists every
finding. To reproduce locally:
```bash
VAPI_PRIVATE_API_KEY=validate-only npm run validate -- <org>
```
`validate` never calls the API, but the engine won't start without a key, so
a placeholder is enough. You don't need that org's real key or its
`.env.<org>`.
Each error names the resource (`assistants/<id>`), field and rule. Plain
`push` only warns about

these errors, so a repository that has been deploying with `push` can carry
some from before the check existed; they show up on the next pull request,
whatever it changes. Fix them in that PR or a separate one first. `apply`
refuses to deploy until they're fixed anyway.

A folder under `resources/` that isn't a valid org name (lowercase letters,
digits and hyphens) fails too. Rename it, or move it out of `resources/`.
6 changes: 6 additions & 0 deletions docs/guides/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ npm run validate -- <org>
npm run apply -- <org>
```

CI runs the same validation on every pull request, for every org under
`resources/` (the **Validate resources** check in `.github/workflows/ci.yml`).
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.
Comment on lines +22 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 "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:

Suggested change
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.


To deploy only some resources, pass resource types or file paths. `apply`
and `push` accept the same scoping:

Expand Down
53 changes: 53 additions & 0 deletions improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ you which stack PR closes the row.**
| 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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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..

Suggested change
| 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) |


**Active backlog after cleanup:** `#2`, `#6`, `#8`, `#12`, `#20`, `#24–#26`, `#31`, and the open remainder of `#27` (wiring the listing-completeness verdict into push/delete/audit, and moving `cleanup.ts` onto the shared pager). Resolved entries stay in this file as historical incident notes per the maintenance directive; stale superseded backlog rows are not duplicated.

Expand Down Expand Up @@ -1933,6 +1934,58 @@ orphan; after it, only the orphan.

---

## 37. Resource validation ran only at deploy time, after merge

**[RESOLVED 2026-10-03]**

**Discovered:** 2026-10-03, while reviewing which static checks run before
the PR check's simulations.

### Problem

`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.
Comment on lines +1946 to +1950

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
`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.


### Current behavior (Verified)

- `src/apply.ts` runs `validate` before every deploy and stops on errors.
Promotion deploys through `apply`, so it stops too, but only after the
change merged.
- `src/push.ts` runs the same validators but only warns unless `--strict`.
- `ci.yml` ran the build and tests only. `tests/examples.test.ts`
validates `examples/`, not `resources/<org>/`.
- The PR check's payload build (`npm run check -- --dry-run`) covers only
the resources its targets reach, and only in repos that turned PR checks on.

### Risk

A broken config merges green. Deploys and promotion out of `main` then stop
until a fix PR lands, or, with plain `push`, the push continues and fails
partway with an API 400.

### Current mitigation

None needed once the fix below lands.

### Possible fix (landed)

A **Validate resources** job in `.github/workflows/ci.yml` runs `validate`
for every folder under `resources/` on every pull request, reporting every
failing org rather than stopping at the first. `validate` makes no network
call, but loading the engine's config requires a key, so the step sets a
placeholder that is never sent; the job has no secrets, so it runs on forks.
No engine change. `tests/ci-validate-workflow.test.ts` runs the step itself
against fixture orgs.

### Status

**RESOLVED 2026-10-03.**

---

## Out of scope (intentionally not improvements)

- **State file is identity-only and not git-ignored.** It's intentionally
Expand Down
155 changes: 155 additions & 0 deletions tests/ci-validate-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";

// ci.yml's "Validate resources" job runs `validate` on every org before
// merge, with no secrets. These tests run the job's real step (read from
// ci.yml, run with bash) against a copy of the engine and fixture orgs.

const REPO = fileURLToPath(new URL("..", import.meta.url));
const STARTER = join(REPO, "examples", "starter", "resources", "starter");
const WORKFLOW_TEXT = readFileSync(
join(REPO, ".github/workflows/ci.yml"),
"utf8",
);

interface Step {
name?: string;
run?: string;
env?: Record<string, string>;
uses?: string;
with?: Record<string, unknown>;
}

const JOB = (
parseYaml(WORKFLOW_TEXT) as { jobs: { validate: { steps: Step[] } } }
).jobs.validate;
const STEP = JOB.steps.find((s) => s.name === "Validate every org")!;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 The ! hides a renamed step: every test would fail with Cannot read properties of undefined (reading 'run') instead of naming the cause.

Suggested change
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');


// Run the step in a scratch repository holding the given org folders.
function validateStepRun(orgs: Record<string, (dir: string) => void>): {
code: number | null;
output: string;
} {
const root = mkdtempSync(join(tmpdir(), "vapi-ci-validate-"));
try {
cpSync(join(REPO, "src"), join(root, "src"), { recursive: true });
cpSync(join(REPO, "package.json"), join(root, "package.json"));
symlinkSync(join(REPO, "node_modules"), join(root, "node_modules"), "dir");
mkdirSync(join(root, "resources"));
// A file at the top of resources/ is not an org.
writeFileSync(join(root, "resources", ".vapi-ignore.example"), "");
for (const [org, fill] of Object.entries(orgs)) {
const dir = join(root, "resources", org);
mkdirSync(dir);
fill(dir);
}
// Only what the runner would have: no inherited Vapi keys.
const result = spawnSync("bash", ["-c", STEP.run!], {
cwd: root,
encoding: "utf8",
timeout: 60_000,
env: { PATH: process.env.PATH, HOME: process.env.HOME, ...STEP.env },
});
return { code: result.status, output: `${result.stdout}${result.stderr}` };
} finally {
rmSync(root, { recursive: true, force: true });
}
}

const starterCopy = (dir: string) => cpSync(STARTER, dir, { recursive: true });

const longNameAdd = (dir: string) => {
starterCopy(dir);
writeFileSync(
join(dir, "assistants", "front-desk-overflow.yml"),
"name: Front Desk Overflow Assistant For Weekend Calls\n",
);
};

test("validate step passes when there are no org folders", () => {
const run = validateStepRun({});
assert.deepEqual(
[run.code, run.output.includes("nothing to validate")],
[0, true],
run.output,
);
});

test("validate step passes when every org is valid", () => {
const run = validateStepRun({
clinic: starterCopy,
"clinic-dev": starterCopy,
});
assert.deepEqual(
[
run.code,
/Validated 2 org\(s\): (clinic clinic-dev|clinic-dev clinic)\n/.test(
run.output,
),
],
[0, true],
run.output,
);
});

test("validate step fails naming only the invalid org, after checking all of them", () => {
const run = validateStepRun({
clinic: longNameAdd,
"clinic-dev": starterCopy,
});
assert.deepEqual(
{
code: run.code,
bothValidated: [
"::group::Validate clinic\n",
"::group::Validate clinic-dev\n",
].every((group) => run.output.includes(group)),
reason: run.output.includes("Vapi caps at 40"),
error: run.output.includes("::error::Validation failed for: clinic."),
},
{ code: 1, bothValidated: true, reason: true, error: true },
run.output,
);
});

test("validate step fails on an org folder that isn't a valid org name", () => {
const run = validateStepRun({ Clinic_Prod: starterCopy });
assert.deepEqual(
[
run.code,
run.output.includes("::error::Validation failed for: Clinic_Prod."),
],
[1, true],
run.output,
);
});

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 },
},
);
});
Comment on lines +141 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.yml job 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-level permissions: escalation, and a switch to pull_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:

Suggested change
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 },
},
);
});

Loading