Files
copilotkit__copilotkit/showcase/scripts/verify-railway-image-refs.ts
Jordan Ritter c62dd66dc4 Merge remote-tracking branch 'origin/main' into blitz/showcase-harness-rename/integration
# Conflicts:
#	pnpm-lock.yaml
#	showcase/harness/config/alerts/smoke-red-tick.yml
#	showcase/harness/scripts/test-notify-harness-jq.sh
#	showcase/harness/src/probes/drivers/e2e-chat-tools.test.ts
#	showcase/harness/src/probes/drivers/e2e-chat-tools.ts
#	showcase/harness/src/probes/drivers/e2e-demos.test.ts
#	showcase/harness/src/probes/drivers/e2e-demos.ts
#	showcase/harness/src/probes/drivers/e2e-readiness.test.ts
#	showcase/harness/src/probes/drivers/e2e-readiness.ts
#	showcase/harness/src/probes/drivers/e2e-smoke.test.ts
#	showcase/harness/src/probes/drivers/e2e-smoke.ts
#	showcase/harness/src/probes/drivers/liveness.test.ts
#	showcase/harness/src/probes/drivers/liveness.ts
#	showcase/harness/src/probes/drivers/smoke.test.ts
#	showcase/harness/src/probes/drivers/smoke.ts
#	showcase/harness/src/probes/liveness.test.ts
#	showcase/harness/src/probes/liveness.ts
#	showcase/harness/src/probes/smoke.test.ts
#	showcase/harness/src/probes/smoke.ts
#	showcase/harness/test/fixtures/rules/valid/smoke-red-tick.yml
#	showcase/ops/src/probes/drivers/e2e-demos.test.ts
#	showcase/ops/src/probes/drivers/e2e-demos.ts
#	showcase/ops/src/probes/drivers/e2e-smoke.test.ts
#	showcase/ops/src/probes/drivers/e2e-smoke.ts
#	showcase/ops/src/probes/drivers/smoke.test.ts
#	showcase/ops/src/probes/drivers/smoke.ts
#	showcase/ops/src/probes/smoke.test.ts
#	showcase/ops/src/probes/smoke.ts
#	showcase/scripts/verify-railway-image-refs.ts
2026-04-28 16:04:44 -07:00

223 lines
6.7 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* verify-railway-image-refs.ts — Drift assertion for Railway showcase image refs.
*
* Fetches every service in the CopilotKit Showcase project and validates that
* the configured Docker image reference matches the canonical GHCR form:
* ghcr.io/copilotkit/<service-name>:latest
*
* Backstory: on 2026-04-21, 18 production services were found with malformed
* image refs of the form `ghcr.io/copilotkit/showcase-<slug>atest` (missing
* the `:` before `latest`, so Docker treats `...atest` as the tag). The root
* cause was an out-of-band MCP/manual mutation — no committed code touched
* these refs. This script exists so any future corruption, regardless of
* source, fails loudly and early in CI before a bad deploy goes out.
*
* Usage:
* npx tsx showcase/scripts/verify-railway-image-refs.ts
*
* Requires: RAILWAY_TOKEN env var or ~/.railway/config.json
* Exit: 0 when every service matches the canonical shape, 1 on any violation.
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const RAILWAY_API = "https://backboard.railway.com/graphql/v2";
const SHOWCASE = {
projectId: "6f8c6bff-a80d-4f8f-b78d-50b32bcf4479",
environmentId: "b14919f4-6417-429f-848d-c6ae2201e04f",
};
// Canonical shape: ghcr.io/copilotkit/<name>:latest where <name> is the
// service name itself. This single pattern covers showcase-<slug>,
// showcase-pocketbase, showcase-harness, and any future showcase-* service.
// Enforcing identity between Railway service
// name and image name (modulo the ghcr.io/copilotkit/ prefix and :latest
// tag) is the invariant — if these ever drift apart we want to know.
//
// The regex accepts both `showcase-*` and bare `<name>` images to
// accommodate services like aimock whose wrapper was eliminated — the
// Railway service is still named `showcase-aimock` but the image is now
// `ghcr.io/copilotkit/aimock:latest`.
const IMAGE_SHAPE = /^ghcr\.io\/copilotkit\/[a-z0-9-]+:latest$/;
// Services whose GHCR image name intentionally differs from the Railway
// service name. After the aimock wrapper elimination (PR #128), Railway
// pulls `ghcr.io/copilotkit/aimock:latest` directly instead of the old
// `showcase-aimock` wrapper image. The verify job must accept this
// divergence rather than requiring image === service name.
const IMAGE_OVERRIDES: Record<string, string> = {
"showcase-aimock": "ghcr.io/copilotkit/aimock:latest",
};
function getToken(): string {
if (process.env.RAILWAY_TOKEN) return process.env.RAILWAY_TOKEN;
const configPath = path.join(
process.env.HOME || "~",
".railway",
"config.json",
);
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
if (config?.user?.token) return config.user.token;
}
console.error(
"No Railway token found. Set RAILWAY_TOKEN or run `railway login`.",
);
process.exit(1);
}
async function railwayGql<T = unknown>(
query: string,
variables: Record<string, unknown> = {},
): Promise<T> {
const token = getToken();
const res = await fetch(RAILWAY_API, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
throw new Error(`Railway API error: ${res.status} ${await res.text()}`);
}
const json = (await res.json()) as {
data?: T;
errors?: Array<{ message: string }>;
};
if (json.errors?.length) {
throw new Error(
`Railway GraphQL errors:\n${json.errors.map((e) => ` - ${e.message}`).join("\n")}`,
);
}
return json.data as T;
}
interface ProjectServicesWithInstances {
project: {
services: {
edges: Array<{
node: {
id: string;
name: string;
serviceInstances: {
edges: Array<{
node: {
environmentId: string;
source: { image: string | null } | null;
};
}>;
};
};
}>;
};
};
}
interface Violation {
service: string;
image: string | null;
reason: string;
}
export function validateImage(
serviceName: string,
image: string | null,
): Violation | null {
if (!image) {
return {
service: serviceName,
image,
reason:
"no image source configured (expected a Docker image, not a repo)",
};
}
if (!IMAGE_SHAPE.test(image)) {
return {
service: serviceName,
image,
reason: `does not match canonical shape ^ghcr\\.io/copilotkit/[a-z0-9-]+:latest$`,
};
}
const expected =
IMAGE_OVERRIDES[serviceName] ?? `ghcr.io/copilotkit/${serviceName}:latest`;
if (image !== expected) {
return {
service: serviceName,
image,
reason: `image name mismatches service name (expected exactly ${expected})`,
};
}
return null;
}
async function main(): Promise<void> {
const data = await railwayGql<ProjectServicesWithInstances>(
`query project($id: String!) {
project(id: $id) {
services {
edges { node {
id
name
serviceInstances {
edges { node { environmentId source { image } } }
}
} }
}
}
}`,
{ id: SHOWCASE.projectId },
);
const services = data.project.services.edges
.map((e) => e.node)
.filter((s) => s.name.startsWith("showcase-"));
const violations: Violation[] = [];
let checked = 0;
for (const svc of services) {
const instance = svc.serviceInstances.edges.find(
(e) => e.node.environmentId === SHOWCASE.environmentId,
);
const image = instance?.node.source?.image ?? null;
checked++;
const v = validateImage(svc.name, image);
if (v) violations.push(v);
}
if (violations.length > 0) {
console.error(
`\n✗ Railway image-ref drift detected (${violations.length}/${checked} services)\n`,
);
console.error(
`Expected shape: ghcr.io/copilotkit/<service-name>:latest` +
` (note the ':' before 'latest')\n`,
);
for (const v of violations) {
console.error(` ${v.service}`);
console.error(` current: ${v.image ?? "<unset>"}`);
console.error(` expected: ghcr.io/copilotkit/${v.service}:latest`);
console.error(` reason: ${v.reason}`);
}
console.error(
`\nFix via Railway dashboard or the showcase deploy-to-railway script.` +
` A common past cause was ':' dropped from ':latest' by an out-of-band API mutation.\n`,
);
process.exit(1);
}
console.log(`✓ ${checked} services verified`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((e) => {
console.error(e);
process.exit(1);
});
}