Files
copilotkit__copilotkit/showcase/scripts/validate-workflow-starters.ts
Jordan Ritter 6574efa548 ci: parse workflows as YAML in validate-workflow-starters
Replace the regex-over-YAML options-block scanner with a real YAML
parse + typed navigation down on.workflow_dispatch.inputs.service.
options. The previous regex depended on a brittle terminator
(`^\S | \n\s*\w+:\s*\n\s{6,}\w+:|\nconcurrency:`) that
would silently break under trivial reformats — adding a top-level key
after `on:` or reordering `concurrency:` would have produced a
false-positive pass.

ALL_SERVICES stays regex-scanned because its matrix is embedded as a
bash heredoc inside a run: step, not as a YAML sub-structure. We do
locate the step via YAML (jobs.detect-changes.steps[*] with run body
referencing ALL_SERVICES) and only then scan `"dispatch_name":"X"`
occurrences inside that step's run body. Cannot JSON.parse the matrix
directly — it interpolates ${{ github.sha }} expressions that aren't
valid JSON pre-execution.

NIT 2: drop the dual `import.meta.url === \`file://${argv[1]}\``
branch and keep only the canonical `fileURLToPath(import.meta.url)`
comparison. The file:// form was belt-and-suspenders for a
pre-fileURLToPath era of Node; Node 20+ handles the modern form
uniformly.

Verified:
- All 12 validator tests pass (substring-spoof, missing-from-both,
  template-excluded, empty-starters, missing-workflow).
- Real showcase_deploy.yml returns OK: all 17 starter(s) registered.
2026-04-18 17:37:03 -07:00

332 lines
12 KiB
TypeScript

/**
* Workflow Starter-List Validator
*
* The starter slug list has two remaining literal copies that must stay
* in sync with `showcase/starters/*` on disk:
* - showcase_deploy.yml workflow_dispatch.inputs.service.options
* (GH Actions requires literal dropdown options; evaluated pre-checkout)
* - showcase_deploy.yml ALL_SERVICES matrix (per-starter deploy metadata:
* railway_id, dockerfile, health_path — can't be filesystem-derived)
*
* showcase_smoke-monitor.yml enumerates starters from `showcase/starters/*`
* at runtime (sparse checkout + bash glob), so drift there is impossible
* and no parity check is needed.
*
* For every directory under `showcase/starters/` (excluding `template/`),
* this script verifies that `starter-<slug>` appears in both deploy.yml
* locations above. Missing entries are reported with explicit fix hints.
*
* Usage (from showcase/ or showcase/scripts/):
* pnpm exec tsx validate-workflow-starters.ts
* VALIDATE_WORKFLOW_STARTERS_REPO_ROOT=/tmp/fixture pnpm exec tsx validate-workflow-starters.ts
*
* Exit codes (aligned with the other validate-* scripts):
* 0 — parity OK
* 1 — drift detected (one or more starters missing from a workflow)
* 3 — unreadable (workflow file or starters dir missing)
* 4 — internal error
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { parse as parseYaml } from "yaml";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ROOT = showcase/ (NOT the repo root). This script lives at
// showcase/scripts/validate-workflow-starters.ts.
// Resolve the repo root so we can find .github/workflows/ even when
// invoked from any cwd. Override via env var for tests / fixtures.
// VALIDATE_WORKFLOW_STARTERS_REPO_ROOT overrides both STARTERS_DIR (via
// showcase/) and DEPLOY_WORKFLOW (via .github/) so a single env var
// re-homes every filesystem read.
const REPO_ROOT =
process.env.VALIDATE_WORKFLOW_STARTERS_REPO_ROOT ??
path.resolve(__dirname, "..", "..");
const SHOWCASE_ROOT = path.join(REPO_ROOT, "showcase");
const STARTERS_DIR = path.join(SHOWCASE_ROOT, "starters");
const DEPLOY_WORKFLOW = path.join(
REPO_ROOT,
".github/workflows/showcase_deploy.yml",
);
// Directories under showcase/starters/ that are NOT starter services
// (skeletons, scaffolding, docs). Add new non-service siblings here.
// Keep in sync with `.github/workflows/showcase_smoke-monitor.yml` —
// the drift detector applies the same exclusion at enumeration time.
const EXCLUDED_DIRS = new Set(["template"]);
const EXIT_OK = 0 as const;
const EXIT_DRIFT = 1 as const;
const EXIT_UNREADABLE = 3 as const;
const EXIT_INTERNAL = 4 as const;
interface MissingEntry {
readonly slug: string;
readonly missingFrom: readonly string[];
}
/**
* Enumerate the set of starter slugs on disk. Each entry is the bare
* directory name (NOT prefixed with `starter-`) so callers can compose
* the workflow-registered name themselves.
*/
export function listStarterSlugs(startersDir: string): string[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(startersDir, { withFileTypes: true });
} catch (err) {
throw new Error(
`Cannot read starters directory ${startersDir}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
return entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((name) => !EXCLUDED_DIRS.has(name))
.sort();
}
/**
* Read a file and return its text. Throws with the source path on
* failure so operators don't have to hunt for which file was missing.
*/
function readTextFile(absPath: string): string {
try {
return fs.readFileSync(absPath, "utf8");
} catch (err) {
throw new Error(
`Cannot read ${absPath}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Parse a workflow YAML string. Thrown errors carry the underlying
* parser message so CI logs identify the exact cause.
*/
function parseWorkflowYaml(deployYaml: string): unknown {
try {
return parseYaml(deployYaml);
} catch (err) {
throw new Error(
`Cannot parse showcase_deploy.yml as YAML: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Navigate parsed YAML to `on.workflow_dispatch.inputs.service.options`
* and return the options array (as strings) or null if any hop is absent
* or mistyped. We accept either `on` or `true` at the top level because
* the YAML spec bool-coerces bare `on:` keys — the `yaml` package (like
* github's own parser) parses `on: { ... }` as key `true`. Real-world
* workflow files hit this in the wild.
*/
function getWorkflowDispatchOptions(parsed: unknown): string[] | null {
if (!parsed || typeof parsed !== "object") return null;
const root = parsed as Record<string, unknown>;
// `on:` gets YAML 1.1 bool-coerced to `true` by the `yaml` package.
// GitHub Actions' own parser treats the key as the literal string "on"
// because the workflow schema pre-binds it, but at the library level we
// have to look both places.
const onNode = (root["on"] ?? root[true as unknown as string]) as
| Record<string, unknown>
| undefined;
if (!onNode || typeof onNode !== "object") return null;
const dispatch = onNode["workflow_dispatch"];
if (!dispatch || typeof dispatch !== "object") return null;
const inputs = (dispatch as Record<string, unknown>)["inputs"];
if (!inputs || typeof inputs !== "object") return null;
const service = (inputs as Record<string, unknown>)["service"];
if (!service || typeof service !== "object") return null;
const options = (service as Record<string, unknown>)["options"];
if (!Array.isArray(options)) return null;
// options may contain non-string literals in a malformed workflow —
// coerce to string defensively so includes() is well-defined.
return options.map((v) => String(v));
}
/**
* Locate the shell step that builds ALL_SERVICES and return its run
* script body so we can extract the embedded JSON matrix. The matrix
* itself lives inside a bash heredoc-style single-quoted variable, so
* it is NOT a YAML sub-structure — we navigate to the step via YAML,
* then the JSON block is lifted out of the run body textually.
*/
function getDetectChangesRunBody(parsed: unknown): string | null {
if (!parsed || typeof parsed !== "object") return null;
const root = parsed as Record<string, unknown>;
const jobs = root["jobs"];
if (!jobs || typeof jobs !== "object") return null;
const detect = (jobs as Record<string, unknown>)["detect-changes"];
if (!detect || typeof detect !== "object") return null;
const steps = (detect as Record<string, unknown>)["steps"];
if (!Array.isArray(steps)) return null;
// Find any step whose run body references ALL_SERVICES — this is the
// step we care about. Keyed on the variable name rather than the step
// `name:` so renaming the step doesn't break validation.
for (const step of steps) {
if (!step || typeof step !== "object") continue;
const run = (step as Record<string, unknown>)["run"];
if (typeof run !== "string") continue;
if (run.includes("ALL_SERVICES")) return run;
}
return null;
}
/**
* Extract every `dispatch_name` value from the embedded ALL_SERVICES
* JSON array inside a run body. Regex scan because the JSON is
* interpolated inside a shell heredoc and can contain `${{ ... }}`
* Actions expressions that make JSON.parse unsafe — but the
* `"dispatch_name":"<value>"` shape is unambiguous.
*/
function extractDispatchNames(runBody: string): string[] {
const names: string[] = [];
const re = /"dispatch_name"\s*:\s*"([^"]+)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(runBody)) !== null) {
names.push(m[1]);
}
return names;
}
/**
* YAML-aware presence check for workflow_dispatch options. Parses the
* workflow document and navigates to `on.workflow_dispatch.inputs.
* service.options`, then tests for exact membership. Immune to
* reformat / reorder / comment churn that would have defeated the
* previous regex-based implementation. Word-boundary semantics are
* preserved by `Array#includes`: "starter-ag2" is distinct from
* "starter-ag2-extended" at the array level.
*/
export function isSlugInWorkflowDispatch(
deployYaml: string,
registeredName: string,
): boolean {
const parsed = parseWorkflowYaml(deployYaml);
const options = getWorkflowDispatchOptions(parsed);
if (options === null) return false;
return options.includes(registeredName);
}
/**
* YAML-aware presence check for the ALL_SERVICES matrix. Parses the
* workflow, locates the `detect-changes` job's step that defines
* ALL_SERVICES, and extracts every `dispatch_name` from the embedded
* JSON via a targeted regex (full JSON.parse is unsafe because the
* matrix interpolates ${{ github.sha }} and similar expressions at
* workflow-execution time). Membership is then checked by equality.
*/
export function isSlugInDeployMatrix(
deployYaml: string,
registeredName: string,
): boolean {
const parsed = parseWorkflowYaml(deployYaml);
const runBody = getDetectChangesRunBody(parsed);
if (runBody === null) return false;
return extractDispatchNames(runBody).includes(registeredName);
}
/**
* Main entry point. Returns an exit code — the CLI wrapper below calls
* process.exit with the return value. Kept pure to make testing easy.
*/
export function runValidation(): number {
let slugs: string[];
try {
slugs = listStarterSlugs(STARTERS_DIR);
} catch (err) {
console.error(
`[validate-workflow-starters] ${
err instanceof Error ? err.message : String(err)
}`,
);
return EXIT_UNREADABLE;
}
if (slugs.length === 0) {
console.error(
`[validate-workflow-starters] No starter directories found under ${STARTERS_DIR} ` +
`(excluding ${[...EXCLUDED_DIRS].join(", ")}). Refusing to pass trivially.`,
);
return EXIT_UNREADABLE;
}
let deployYaml: string;
try {
deployYaml = readTextFile(DEPLOY_WORKFLOW);
} catch (err) {
console.error(
`[validate-workflow-starters] ${
err instanceof Error ? err.message : String(err)
}`,
);
return EXIT_UNREADABLE;
}
const missing: MissingEntry[] = [];
for (const slug of slugs) {
const registered = `starter-${slug}`;
const missingFrom: string[] = [];
if (!isSlugInWorkflowDispatch(deployYaml, registered)) {
missingFrom.push(
`showcase_deploy.yml workflow_dispatch.inputs.service.options`,
);
}
if (!isSlugInDeployMatrix(deployYaml, registered)) {
missingFrom.push(`showcase_deploy.yml ALL_SERVICES matrix`);
}
if (missingFrom.length > 0) {
missing.push({ slug: registered, missingFrom });
}
}
if (missing.length === 0) {
console.log(
`[validate-workflow-starters] OK: all ${slugs.length} starter(s) registered across workflows`,
);
return EXIT_OK;
}
console.error(
`[validate-workflow-starters] FAIL: ${missing.length} starter(s) missing workflow registration:`,
);
for (const entry of missing) {
console.error(` - ${entry.slug}`);
for (const src of entry.missingFrom) {
console.error(` missing from: ${src}`);
}
}
console.error(
`\nHint: add the slug to each location listed above. See existing starter entries for the shape.`,
);
return EXIT_DRIFT;
}
// CLI entry point — skipped when this module is imported by tests.
// `fileURLToPath(import.meta.url)` is the canonical path form on Node 20+;
// compare directly with `process.argv[1]` which is the script path.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
try {
process.exit(runValidation());
} catch (err) {
console.error(
`[validate-workflow-starters] internal error: ${
err instanceof Error ? (err.stack ?? err.message) : String(err)
}`,
);
process.exit(EXIT_INTERNAL);
}
}