Files
2026-07-13 12:58:18 +08:00

816 lines
28 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* deploy-to-railway.ts — Create or update a Railway service for a showcase integration.
*
* Reads the package's manifest.yaml and creates the Railway service via
* GraphQL API, pulling the image from GHCR.
*
* Usage:
* npx tsx showcase/scripts/deploy-to-railway.ts <slug>
* npx tsx showcase/scripts/deploy-to-railway.ts mastra
* npx tsx showcase/scripts/deploy-to-railway.ts --list # list existing showcase services
* npx tsx showcase/scripts/deploy-to-railway.ts --go-live <slug> # flip deployed: true + redeploy
*
* Requires: RAILWAY_TOKEN env var or ~/.railway/config.json
*/
import fs from "fs";
import path from "path";
import yaml from "yaml";
import { fileURLToPath } from "url";
import { RAILWAY_GRAPHQL_ENDPOINT } from "./lib/railway-graphql";
import { RailwayTokenError, resolveRailwayToken } from "./lib/railway-token";
import {
PROJECT_ID,
PRODUCTION_ENV_ID,
healthcheckPathFor,
isTrackedService,
} from "./railway-envs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const PACKAGES_DIR = path.join(ROOT, "integrations");
const RAILWAY_API = RAILWAY_GRAPHQL_ENDPOINT;
const SHOWCASE = {
projectId: PROJECT_ID,
environmentId: PRODUCTION_ENV_ID,
};
/**
* Resolve the healthcheck path to apply to a Railway service instance during
* provisioning, disambiguating the two reasons {@link healthcheckPathFor}
* returns undefined:
*
* - `{ kind: "set", path }` — a tracked service with an explicit SSOT
* healthcheckPath (e.g. aimock → `/health`,
* agents → `/api/health`). Apply that path.
* - `{ kind: "omit" }` — a TRACKED service that deliberately has a
* null/omitted healthcheckPath (dashboard,
* docs, dojo, webhooks, pocketbase). These
* have no HTTP health endpoint; forcing
* `/api/health` 404s and wedges the deploy.
* Set NO healthcheck (Railway default).
* - `{ kind: "set", path: "/api/health" }` for an UNTRACKED service — a
* brand-new/unknown service this script is
* onboarding takes the agent-class default.
*
* Pure (SSOT lookup only) so both createService and goLive share one source
* of truth and it is unit-testable without network I/O.
*/
export function resolveProvisionHealthcheck(
serviceName: string,
): { kind: "set"; path: string } | { kind: "omit" } {
if (!isTrackedService(serviceName)) {
// Not in the SSOT at all — agent-class default for a new service.
return { kind: "set", path: "/api/health" };
}
const trackedPath = healthcheckPathFor(serviceName, "prod");
// Tracked-null → omit the healthcheck (no HTTP health endpoint).
return trackedPath === undefined
? { kind: "omit" }
: { kind: "set", path: trackedPath };
}
/**
* Resolve the Railway bearer token for this run. Wraps the shared
* `resolveRailwayToken` envelope and maps any RailwayTokenError onto
* the script's exit-1 contract for operator/config errors. The shared
* helper never calls process.exit — exit-code mapping lives HERE so the
* helper stays unit-testable.
*
* Memoized at module scope so the Railway config isn't re-read and the
* deprecation warning isn't re-emitted on every GraphQL request. A
* failure is NOT cached — the first call's error still maps to exit 1
* (or rethrows for non-RailwayTokenError), so a transient/operator
* error remains fail-loud and a subsequent run gets a fresh resolution.
*/
let cachedToken: string | undefined;
function getToken(): string {
if (cachedToken) return cachedToken;
try {
cachedToken = resolveRailwayToken().token;
return cachedToken;
} catch (e) {
if (e instanceof RailwayTokenError) {
console.error(e.message);
process.exit(1);
}
throw e;
}
}
// ── GHCR digest resolution ─────────────────────────────────────────────
//
// Prod services must be born PINNED to a content-addressable digest
// (`@sha256:...`), never the mutable `:latest` tag — otherwise a freshly
// provisioned prod service silently tracks `:latest` until a later
// `bin/railway promote` re-pins it, and in that window a regressed
// `:latest` flows straight into prod. This mirrors the Ruby promote CLI
// (`showcase/bin/railway`, class GHCR) so the two stay behaviorally
// identical.
//
// GHCR exposes the OCI Distribution Spec at
// https://ghcr.io/v2/<org>/<image>/manifests/<tag>
// but its manifest endpoint REJECTS a raw GitHub/Actions token sent as
// `Authorization: Bearer <token>` with HTTP 403 — the token must first be
// exchanged for a short-lived registry bearer via the /token endpoint. So
// we ALWAYS hit /token, even when a token is present.
const GHCR_ORG = "copilotkit";
const GHCR_ACCEPT_MANIFEST = [
"application/vnd.oci.image.index.v1+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.docker.distribution.manifest.v2+json",
].join(", ");
/**
* Minimal HTTP response shape — only the bits the resolver reads. Injected
* so the resolver core is unit-testable without real network I/O.
*/
export interface GhcrHttpResponse {
status: number;
headers: Record<string, string>;
body: string;
}
export interface GhcrHttp {
get(url: string, headers: Record<string, string>): Promise<GhcrHttpResponse>;
head(url: string, headers: Record<string, string>): Promise<GhcrHttpResponse>;
}
/**
* GHCR bearer (PAT) for manifest reads. Distinct from the Railway token.
* Order mirrors the Ruby CLI:
* 1. GHCR_TOKEN — explicit PAT (local dev or CI override).
* 2. GITHUB_TOKEN — GitHub Actions automatic token (needs packages:read).
* Returns undefined when neither is set (public-package anonymous reads
* still succeed via the /token exchange).
*/
export function ghcrPat(): string | undefined {
const t = (process.env.GHCR_TOKEN || process.env.GITHUB_TOKEN || "").trim();
return t || undefined;
}
/** Flatten a fetch Headers into a plain record keyed BOTH as-sent and
* lower-cased, so callers can read `Docker-Content-Digest` case-insensitively
* (mirrors the Ruby CLI's dual-keying). */
function headersToRecord(h: Headers): Record<string, string> {
const out: Record<string, string> = {};
h.forEach((value, key) => {
out[key] = value;
out[key.toLowerCase()] = value;
});
return out;
}
const realGhcrHttp: GhcrHttp = {
async get(url, headers) {
const res = await fetch(url, { method: "GET", headers });
return {
status: res.status,
headers: headersToRecord(res.headers),
body: await res.text(),
};
},
async head(url, headers) {
const res = await fetch(url, { method: "HEAD", headers });
return {
status: res.status,
headers: headersToRecord(res.headers),
body: "",
};
},
};
/**
* Mint the short-lived registry bearer via GHCR's /token exchange. When a
* PAT is present we authenticate the exchange with Basic auth
* (base64("x-access-token:<pat>")). Throws if a PAT was supplied but the
* exchange failed — the caller must NOT silently downgrade to anonymous
* (that conflates "no token" with "supplied token failed"). Returns
* undefined only when NO PAT was supplied and the package is not
* anonymously pullable.
*/
async function ghcrBearerFor(
name: string,
pat: string | undefined,
http: GhcrHttp,
): Promise<string | undefined> {
const url = `https://ghcr.io/token?service=ghcr.io&scope=repository:${GHCR_ORG}/${name}:pull`;
const headers: Record<string, string> = {};
if (pat) {
headers.Authorization =
"Basic " + Buffer.from(`x-access-token:${pat}`).toString("base64");
}
const resp = await http.get(url, headers);
if (resp.status >= 400) {
if (pat) {
throw new Error(
`GHCR /token exchange failed (${resp.status}) for ${GHCR_ORG}/${name}`,
);
}
return undefined;
}
let parsed: { token?: string };
try {
parsed = JSON.parse(resp.body) as { token?: string };
} catch (e) {
throw new Error(
`GHCR /token returned unparseable body for ${GHCR_ORG}/${name}: ${(e as Error).message}`,
{ cause: e },
);
}
return parsed.token;
}
/**
* Resolve `ghcr.io/copilotkit/<name>:<tag>` to its content digest
* (`sha256:...`). Fail-loud: throws on a 404 (the tag does not exist),
* on any other >=400, and on a missing Docker-Content-Digest header.
* NEVER returns the bare tag as a fallback — that would reintroduce the
* mutable-:latest-in-prod bug.
*/
export async function resolveGhcrDigest(
name: string,
tag = "latest",
http: GhcrHttp = realGhcrHttp,
pat: string | undefined = ghcrPat(),
): Promise<string> {
const bearer = await ghcrBearerFor(name, pat, http);
const url = `https://ghcr.io/v2/${GHCR_ORG}/${name}/manifests/${tag}`;
const headers: Record<string, string> = { Accept: GHCR_ACCEPT_MANIFEST };
if (bearer) headers.Authorization = `Bearer ${bearer}`;
const resp = await http.head(url, headers);
if (resp.status === 404) {
throw new Error(
`GHCR manifest HEAD 404 for ghcr.io/${GHCR_ORG}/${name}:${tag} — the image tag does not exist; cannot pin prod to a digest. Build & push the image first.`,
);
}
if (resp.status >= 400) {
throw new Error(
`GHCR manifest HEAD ${resp.status} for ghcr.io/${GHCR_ORG}/${name}:${tag}`,
);
}
const digest =
resp.headers["docker-content-digest"] ||
resp.headers["Docker-Content-Digest"];
if (!digest) {
throw new Error(
`GHCR did not return Docker-Content-Digest for ghcr.io/${GHCR_ORG}/${name}:${tag}`,
);
}
return digest;
}
/**
* Build the digest-pinned PROD image ref for a slug. Resolves the current
* `:latest` digest for `showcase-<slug>` and returns
* `ghcr.io/copilotkit/showcase-<slug>@sha256:<digest>`. Fail-loud: any
* resolution failure propagates — prod is NEVER born on the bare tag.
* Exported for unit testing against an injected resolver.
*/
export async function buildProdImageRef(
slug: string,
resolve: (name: string) => Promise<string> = (name) =>
resolveGhcrDigest(name),
): Promise<string> {
const repoName = `showcase-${slug}`;
const digest = await resolve(repoName);
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) {
throw new Error(
`GHCR returned a malformed digest for ${repoName}: ${JSON.stringify(
digest,
)} — refusing to pin prod to a non-canonical digest.`,
);
}
return `ghcr.io/${GHCR_ORG}/${repoName}@${digest}`;
}
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) {
const text = await res.text();
throw new Error(`Railway API error: ${res.status} ${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;
}
// ── List services ──────────────────────────────────────────────────────
interface ProjectServices {
project: {
services: {
edges: Array<{ node: { id: string; name: string } }>;
};
};
}
async function listServices(): Promise<void> {
const data = await railwayGql<ProjectServices>(
`query project($id: String!) {
project(id: $id) {
services {
edges { node { id name } }
}
}
}`,
{ id: SHOWCASE.projectId },
);
const services = data.project.services.edges.map((e) => e.node);
console.log("Showcase services on Railway:\n");
for (const s of services) {
console.log(` ${s.name.padEnd(35)} ${s.id}`);
}
}
// ── Find a service by name ─────────────────────────────────────────────
async function findService(name: string): Promise<{ id: string } | null> {
const data = await railwayGql<ProjectServices>(
`query project($id: String!) {
project(id: $id) {
services {
edges { node { id name } }
}
}
}`,
{ id: SHOWCASE.projectId },
);
const match = data.project.services.edges.find((e) => e.node.name === name);
return match ? { id: match.node.id } : null;
}
// ── Create service ─────────────────────────────────────────────────────
interface ServiceCreateResult {
serviceCreate: { id: string; name: string };
}
interface ServiceDomainResult {
serviceDomainCreate: { domain: string };
}
async function createService(slug: string): Promise<void> {
const pkgDir = path.join(PACKAGES_DIR, slug);
if (!fs.existsSync(pkgDir)) {
console.error(`Package not found: ${pkgDir}`);
process.exit(1);
}
const manifestPath = path.join(pkgDir, "manifest.yaml");
if (!fs.existsSync(manifestPath)) {
console.error(`No manifest.yaml in ${pkgDir}`);
process.exit(1);
}
const serviceName = `showcase-${slug}`;
// Check if already exists
const existing = await findService(serviceName);
if (existing) {
console.log(`Service ${serviceName} already exists: ${existing.id}`);
console.log(
`Dashboard: https://railway.com/project/${SHOWCASE.projectId}/service/${existing.id}`,
);
return;
}
// This script provisions into the PRODUCTION environment
// (SHOWCASE.environmentId === PRODUCTION_ENV_ID). The showcase contract is
// "prod is PINNED to a digest; staging floats :latest." So resolve the
// GHCR digest NOW and pin the source image — a prod service must be born
// digest-pinned, never tracking the mutable `:latest` tag. Fail-loud: if
// the digest can't be resolved, abort rather than silently provisioning
// prod on `:latest` (the regression class that broke ms-agent-dotnet).
// Staging provisioning lives elsewhere (provision-starter-fleet.ts) and is
// intentionally NOT changed — staging floats by design.
let imagePath: string;
try {
imagePath = await buildProdImageRef(slug);
} catch (e) {
console.error(
`Failed to resolve a GHCR digest for showcase-${slug}: ${(e as Error).message}`,
);
console.error(
"Prod services must be pinned to a content digest (@sha256:...), not the mutable :latest tag.",
);
console.error(
"Ensure the image has been built & pushed to ghcr.io/copilotkit/showcase-" +
slug +
":latest, and that GHCR_TOKEN or GITHUB_TOKEN (packages:read) is set, then re-run.",
);
process.exit(1);
}
console.log(`Creating Railway service: ${serviceName}`);
console.log(` Image (digest-pinned): ${imagePath}`);
// 1. Create the service
const createResult = await railwayGql<ServiceCreateResult>(
`mutation serviceCreate($input: ServiceCreateInput!) {
serviceCreate(input: $input) { id name }
}`,
{
input: {
projectId: SHOWCASE.projectId,
name: serviceName,
source: { image: imagePath },
},
},
);
const svcId = createResult.serviceCreate.id;
console.log(` Created service: ${svcId}`);
// 2. Configure health check, region, and registry credentials
const githubToken = process.env.GITHUB_TOKEN;
if (!githubToken) {
console.warn(
"\n WARNING: GITHUB_TOKEN not set. Registry credentials will not be configured.",
);
console.warn(
" Set GITHUB_TOKEN and re-run, or configure manually in the Railway dashboard.\n",
);
}
// Resolve the healthcheck path from the SSOT (railway-envs.ts) so this
// onboarding script does not become a SECOND untracked source of truth (the
// failure mode behind the aimock silent-null incident). `serviceName` is the
// canonical SSOT key (`showcase-<slug>`) and SHOWCASE.environmentId is
// PRODUCTION_ENV_ID, so we resolve the prod value. The shared resolver
// distinguishes a tracked-null service (omit the healthcheck) from an
// untracked new service (agent-class `/api/health` default) — forcing
// `/api/health` onto a tracked-null service 404s and wedges the deploy.
const instanceInput: Record<string, unknown> = {
region: "us-west1",
};
const hc = resolveProvisionHealthcheck(serviceName);
if (hc.kind === "set") {
instanceInput.healthcheckPath = hc.path;
}
if (githubToken) {
// GHCR registry username: read from env (GHCR_USERNAME preferred, then
// GITHUB_ACTOR for CI contexts). Fail loud rather than baking a
// personal handle into the script.
const ghcrUser = (
process.env.GHCR_USERNAME ||
process.env.GITHUB_ACTOR ||
""
).trim();
if (!ghcrUser) {
console.error(
"GITHUB_TOKEN is set but no GHCR username is available. Set GHCR_USERNAME (or GITHUB_ACTOR in CI) to the username the token is issued to.",
);
process.exit(1);
}
instanceInput.registryCredentials = {
username: ghcrUser,
password: githubToken,
};
}
await railwayGql(
`mutation serviceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) {
serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input)
}`,
{
serviceId: svcId,
environmentId: SHOWCASE.environmentId,
input: instanceInput,
},
);
console.log(
` Configured health check, region${githubToken ? ", registry credentials" : ""}`,
);
// 3. Generate public domain
const domainResult = await railwayGql<ServiceDomainResult>(
`mutation serviceDomainCreate($input: ServiceDomainCreateInput!) {
serviceDomainCreate(input: $input) { domain }
}`,
{
input: {
serviceId: svcId,
environmentId: SHOWCASE.environmentId,
},
},
);
const domain = domainResult.serviceDomainCreate.domain;
const publicUrl = `https://${domain}`;
console.log(` Public URL: ${publicUrl}`);
// 4. Set NODE_ENV=production
await railwayGql(
`mutation variableCollectionUpsert($input: VariableCollectionUpsertInput!) {
variableCollectionUpsert(input: $input)
}`,
{
input: {
projectId: SHOWCASE.projectId,
environmentId: SHOWCASE.environmentId,
serviceId: svcId,
variables: { NODE_ENV: "production" },
},
},
);
console.log(` Set NODE_ENV=production`);
// 5. Update the CI workflow file with the service ID
const workflowPath = path.resolve(
ROOT,
"..",
".github",
"workflows",
"showcase_deploy.yml",
);
if (fs.existsSync(workflowPath)) {
let workflow = fs.readFileSync(workflowPath, "utf-8");
const placeholder = new RegExp(
`(showcase-${slug}.*?)RAILWAY_SERVICE_ID`,
"s",
);
if (workflow.match(placeholder)) {
workflow = workflow.replace(placeholder, `$1${svcId}`);
fs.writeFileSync(workflowPath, workflow);
console.log(`\n Updated showcase_deploy.yml with service ID ${svcId}`);
}
}
// 6. Update manifest.yaml backend_url with the actual domain
const manifestRaw = fs.readFileSync(manifestPath, "utf-8");
const manifest = yaml.parse(manifestRaw);
if (!manifest.backend_url || manifest.backend_url.includes("PLACEHOLDER")) {
const updated = manifestRaw.replace(
/^backend_url:.*$/m,
`backend_url: "${publicUrl}"`,
);
fs.writeFileSync(manifestPath, updated);
console.log(` Updated manifest.yaml backend_url to ${publicUrl}`);
}
console.log(`
Done! Next steps:
1. Push an image: docker build & push to ghcr.io/copilotkit/showcase-${slug}:latest
(or push code and let CI build it)
2. Once healthy: npx tsx showcase/scripts/deploy-to-railway.ts --go-live ${slug}
3. Commit the workflow + manifest changes and push
`);
}
// ── Go live ────────────────────────────────────────────────────────────
const PROD_DIGEST_SHAPE =
/^ghcr\.io\/copilotkit\/[a-z0-9-]+@sha256:[0-9a-f]{64}$/;
interface ProdInstanceQuery {
project: {
services: {
edges: Array<{
node: {
name: string;
serviceInstances: {
edges: Array<{
node: {
environmentId: string;
source: { image: string | null } | null;
};
}>;
};
};
}>;
};
};
}
/**
* Injectable Railway query for the prod serviceInstance image. Mirrors the
* `GhcrHttp` DI seam above: production passes the real `railwayGql`-backed
* implementation, tests pass a fake returning a canned `ProdInstanceQuery`.
* `projectId` is the Railway project to look the service up in.
*/
export type ProdInstanceQueryFn = (
projectId: string,
) => Promise<ProdInstanceQuery>;
/** Default prod-instance query: hits Railway via the module-level gql client. */
const realProdInstanceQuery: ProdInstanceQueryFn = (projectId) =>
railwayGql<ProdInstanceQuery>(
`query project($id: String!) {
project(id: $id) {
services {
edges {
node {
name
serviceInstances {
edges { node { environmentId source { image } } }
}
}
}
}
}
}`,
{ id: projectId },
);
/**
* Typed error thrown by `assertProdDigestPinned` when the prod image is
* missing, unconfigured, or not digest-pinned. The CLI call site (`goLive`)
* maps this onto the exit-1 contract — the guard itself stays
* process.exit-free so it is unit-testable (mirrors `resolveRailwayToken` /
* `getToken`, where exit mapping lives at the call site, not in the core).
*/
export class ProdPinError extends Error {
constructor(message: string) {
super(message);
this.name = "ProdPinError";
}
}
/**
* Fail-loud guard: assert the PROD serviceInstance for `serviceName` has a
* digest-pinned `source.image` (`@sha256:...`). THROWS a `ProdPinError` if
* the service/instance is missing, has no image, or is still on the mutable
* `:latest` tag — production behavior (exit 1) is preserved by the caller,
* which catches and maps. Railway I/O is injected via `query` so the guard
* is unit-testable without network access (mirrors the `GhcrHttp` seam).
*/
export async function assertProdDigestPinned(
serviceName: string,
environmentId: string = SHOWCASE.environmentId,
query: ProdInstanceQueryFn = realProdInstanceQuery,
projectId: string = SHOWCASE.projectId,
): Promise<void> {
const data = await query(projectId);
const svc = data.project.services.edges.find(
(e) => e.node.name === serviceName,
);
if (!svc) {
throw new ProdPinError(
`Cannot verify prod image pin: service ${serviceName} not found in project ${projectId}.`,
);
}
const instance = svc.node.serviceInstances.edges.find(
(e) => e.node.environmentId === environmentId,
);
const image = instance?.node.source?.image ?? null;
if (!image) {
throw new ProdPinError(
`Cannot go live: ${serviceName} has no production image source configured.`,
);
}
if (!PROD_DIGEST_SHAPE.test(image)) {
throw new ProdPinError(
`Refusing to go live: ${serviceName} prod image is not digest-pinned (got: ${image}).\n` +
"Prod must be pinned to ghcr.io/copilotkit/<repo>@sha256:<digest>, not the mutable :latest tag.\n" +
`Run \`bin/railway promote ${serviceName.replace(/^showcase-/, "")}\` (or re-provision) to digest-pin prod, then retry --go-live.`,
);
}
console.log(` Prod image is digest-pinned: ${image}`);
}
async function goLive(slug: string): Promise<void> {
const manifestPath = path.join(PACKAGES_DIR, slug, "manifest.yaml");
if (!fs.existsSync(manifestPath)) {
console.error(`No manifest.yaml for ${slug}`);
process.exit(1);
}
// Read manifest for backend_url
const manifest = yaml.parse(fs.readFileSync(manifestPath, "utf-8"));
const backendUrl = manifest.backend_url;
if (!backendUrl) {
console.error(`No backend_url in manifest.yaml for ${slug}`);
process.exit(1);
}
// Guard: prod must be digest-pinned before it goes live. createService now
// pins at provisioning time, but a service created before this fix (or
// edited by hand) could still be tracking the mutable `:latest` tag.
// Going live on `:latest` is exactly the regression class this fix
// closes, so refuse it loudly rather than lighting up an unpinned prod.
// The guard throws a typed ProdPinError; map it onto the exit-1 contract
// HERE so the guard itself stays process.exit-free and unit-testable.
try {
await assertProdDigestPinned(`showcase-${slug}`);
} catch (e) {
if (e instanceof ProdPinError) {
console.error(e.message);
process.exit(1);
}
throw e;
}
// Health check — resolve the path from the SSOT (railway-envs.ts) via the
// SAME shared resolver as createService so this does not become a SECOND
// source of truth. A tracked-null service (dashboard, docs, dojo, webhooks,
// pocketbase) has no HTTP health endpoint; probing `/api/health` would 404
// and (via process.exit below) wedge the deploy — so SKIP the HTTP check
// entirely for those.
const ssotKey = `showcase-${slug}`;
const hc = resolveProvisionHealthcheck(ssotKey);
if (hc.kind === "omit") {
console.log(
` Skipping HTTP health check — ${ssotKey} has no healthcheckPath in the SSOT (tracked-null service).`,
);
} else {
const healthUrl = `${backendUrl}${hc.path}`;
console.log(`Checking health: ${healthUrl}`);
try {
const res = await fetch(healthUrl, {
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
console.error(`Health check failed: ${res.status}`);
process.exit(1);
}
console.log(` Healthy!`);
} catch (e: unknown) {
console.error(` Not reachable: ${(e as Error).message}`);
process.exit(1);
}
}
// Update manifest: deployed: true
const raw = fs.readFileSync(manifestPath, "utf-8");
const updated = raw.replace(/^deployed:\s*false$/m, "deployed: true");
fs.writeFileSync(manifestPath, updated);
console.log(` Set deployed: true in manifest.yaml`);
// Regenerate registry
console.log(` Regenerating registry...`);
const { execSync } = await import("child_process");
execSync("npx tsx showcase/scripts/generate-registry.ts", {
cwd: path.resolve(ROOT, ".."),
stdio: "inherit",
});
console.log(`\nDone! Commit and push to light up the stack chip.`);
}
// ── Main ───────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help") {
console.log(`Usage:
npx tsx showcase/scripts/deploy-to-railway.ts <slug> Create Railway service
npx tsx showcase/scripts/deploy-to-railway.ts --list List showcase services
npx tsx showcase/scripts/deploy-to-railway.ts --go-live <slug> Verify health + flip deployed
`);
process.exit(0);
}
if (args[0] === "--list") {
await listServices();
return;
}
if (args[0] === "--go-live") {
if (!args[1]) {
console.error("Usage: --go-live <slug>");
process.exit(1);
}
await goLive(args[1]);
return;
}
await createService(args[0]);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((e) => {
console.error(e);
process.exit(1);
});
}