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

908 lines
36 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* provision-starter-fleet.ts — Committed, reproducible Railway provisioner
* for the "starter container fleet" in the showcase STAGING environment.
*
* Creates (or idempotently updates) one always-on Railway service per
* starter template, pulling the per-starter image from GHCR:
*
* service name : starter-<slug> (RAW starter slug, e.g.
* starter-langgraph-js, NOT the
* remapped dashboard column slug)
* image : ghcr.io/copilotkit/starter-<slug>:latest
* env : STAGING ONLY (railway-envs STAGING_ENV_ID)
* sleep : sleepApplication = false (always-on, so the starters are
* staging-probed like every other managed showcase service)
* healthcheck : "/" (the starters' single deployable image EXPOSEs 3000
* running the Next.js frontend; the frontend serves "/" and
* "/api/copilotkit" but has NO "/api/health" route — that
* path 404s and would wedge Railway healthchecks forever.
* The agent's "/health" lives on the internal agent port
* 8123, which Railway does not expose. So "/" is the only
* correct, reachable healthcheck for the exposed surface.)
* region : us-west1 (matches every existing showcase service)
* GHCR creds : registryCredentials from GITHUB_TOKEN + GHCR username
* (same mechanism deploy-to-railway.ts uses)
* domain : a generated Railway domain per service (serviceDomainCreate)
*
* The 12 starter slugs are the keys of STARTER_TO_COLUMN in
* showcase/harness/src/probes/helpers/starter-mapping.ts. That list is NOT
* literally shared with the smoke matrix (showcase/tests/e2e/starter-smoke.spec.ts)
* or the CI build matrix (.github/workflows/showcase_build.yml) — those are
* INDEPENDENT lists. The drift test
* (showcase/harness/src/probes/helpers/starter-mapping-drift.test.ts) keeps
* STARTER_TO_COLUMN in lockstep with the smoke matrix + the on-disk column set
* ONLY (it does NOT check showcase_build.yml — the CI build matrix is independent
* and not covered by that test). This script derives its target list from
* STARTER_TO_COLUMN, so the drift-synced map keeps the fleet aligned with the
* smoke/column set.
*
* SSOT relationship (S2): the 12 starter-<slug> services are now FULL
* railway-envs SSOT entries (SERVICES in showcase/scripts/railway-envs.ts),
* gateValidated + ciBuilt exactly like a showcase-* agent. This script is NOT
* a competing source of truth: it is the STAGING PROVISIONER, deriving its 12
* targets from STARTER_TO_COLUMN (the canonical starter-slug map; the smoke
* matrix and CI build matrix are INDEPENDENT lists, per the note above). The railway-envs SSOT owns the
* image-ref gate + the cluster-promote closure (prod @sha256 pinning); this
* script owns idempotent staging service creation. They are complementary, not
* double-managing — the slug set is the single shared input, so they cannot
* drift. The starter_smoke probe still auto-discovers starter-* services at
* runtime (railway-services source, namePrefix "starter-") for verification.
*
* NOTE: prod pinning + image-ref drift for starters is handled by the
* railway-envs gate (verify-railway-image-refs.ts) and bin/railway lint-prod,
* NOT by this staging-only provisioner.
*
* IDEMPOTENT: a starter-* service that already exists in staging is UPDATED
* (instance settings re-applied; a domain created only if none exists, and an
* "already exists" rejection on re-create is absorbed as a no-op) rather than
* duplicated or errored. The pinned image is always (re)deployed via
* serviceInstanceRedeploy so it actually runs — serviceInstanceUpdate alone
* only pins the image ref.
*
* Usage:
* npx tsx showcase/scripts/provision-starter-fleet.ts # provision all 12
* npx tsx showcase/scripts/provision-starter-fleet.ts --list # list starter-* services in staging
* npx tsx showcase/scripts/provision-starter-fleet.ts --dry-run # plan only, no mutations
*
* Requires: RAILWAY_TOKEN env var or ~/.railway/config.json, plus GITHUB_TOKEN
* (+ GHCR_USERNAME or GITHUB_ACTOR) so the private GHCR images can be pulled.
*/
import { fileURLToPath } from "url";
import { STARTER_TO_COLUMN } from "../harness/src/probes/helpers/starter-mapping";
import { PROJECT_ID, STAGING_ENV_ID } from "./railway-envs";
import { RAILWAY_GRAPHQL_ENDPOINT } from "./lib/railway-graphql";
import { RailwayTokenError, resolveRailwayToken } from "./lib/railway-token";
const RAILWAY_API = RAILWAY_GRAPHQL_ENDPOINT;
/** Railway service-name prefix for the starter fleet (RAW starter slug). */
export const STARTER_FLEET_PREFIX = "starter-";
/**
* The healthcheck path Railway probes against the exposed container port.
* "/" — see the file header for why this is NOT "/api/health".
*/
export const STARTER_HEALTHCHECK_PATH = "/";
/** Region every existing showcase service runs in; match it. */
export const STARTER_REGION = "us-west1";
// ── Target derivation (from the starter-mapping SSOT) ───────────────────
export interface StarterTarget {
/** RAW starter slug, e.g. "langgraph-js", "adk". */
slug: string;
/** Railway service name, e.g. "starter-langgraph-js". */
serviceName: string;
/** GHCR image ref, e.g. "ghcr.io/copilotkit/starter-langgraph-js:latest". */
image: string;
}
/**
* Derive the 12 provisioning targets from STARTER_TO_COLUMN (the SSOT). The
* service name and image both use the RAW starter slug (the map KEY), never
* the remapped dashboard column slug (the map VALUE). Sorted for stable,
* reproducible output.
*
* Exported pure for unit testing.
*/
export function deriveStarterTargets(
mapping: Readonly<Record<string, string>> = STARTER_TO_COLUMN,
): StarterTarget[] {
return Object.keys(mapping)
.sort()
.map((slug) => ({
slug,
serviceName: `${STARTER_FLEET_PREFIX}${slug}`,
image: `ghcr.io/copilotkit/${STARTER_FLEET_PREFIX}${slug}:latest`,
}));
}
// ── GHCR registry credentials ───────────────────────────────────────────
export interface RegistryCredentials {
username: string;
password: string;
}
/**
* Resolve GHCR registry credentials the same way deploy-to-railway.ts does:
* GITHUB_TOKEN as the password, and GHCR_USERNAME (preferred) or GITHUB_ACTOR
* (CI) as the username. Returns undefined when GITHUB_TOKEN is unset (caller
* warns and proceeds without creds). THROWS when a token is present but no
* username is available — fail loud rather than baking a personal handle in.
*
* Exported pure for unit testing.
*/
export function resolveRegistryCredentials(
env: NodeJS.ProcessEnv = process.env,
): RegistryCredentials | undefined {
const githubToken = env.GITHUB_TOKEN;
if (!githubToken) return undefined;
const ghcrUser = (env.GHCR_USERNAME || env.GITHUB_ACTOR || "").trim();
if (!ghcrUser) {
throw new 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.",
);
}
return { username: ghcrUser, password: githubToken };
}
// ── Injectable Railway GraphQL boundary ─────────────────────────────────
/** Minimal GraphQL caller signature — injected so the core is unit-testable. */
export type RailwayGqlFn = <T = unknown>(
query: string,
variables?: Record<string, unknown>,
) => Promise<T>;
interface ProjectServicesResult {
project: {
services: {
edges: Array<{
node: {
id: string;
name: string;
// A transitional service can return a null serviceInstances
// connection (or null .edges) — both fields are optional/nullable
// so the drain loop can coalesce instead of throwing a TypeError
// that would abort the entire fetch.
serviceInstances?: {
edges?: Array<{
node: {
environmentId: string;
domains?: {
serviceDomains?: Array<{ domain: string }>;
} | null;
};
}> | null;
} | null;
};
}>;
pageInfo?: {
hasNextPage: boolean;
endCursor?: string | null;
} | null;
};
} | null;
}
interface ServiceCreateResult {
serviceCreate: { id: string; name: string };
}
interface ServiceDomainResult {
serviceDomainCreate: { domain: string };
}
/**
* `serviceInstanceRedeploy` returns a Boolean! in Railway's schema — `true`
* once the redeploy is enqueued. VERIFIED against the two in-repo consumers
* that read this mutation's result:
* - showcase/scripts/redeploy-env.ts types it `serviceInstanceRedeploy?:
* boolean` and gates on `!== true`;
* - showcase/bin/railway (RestoreCommand P5) requires
* `redeployed["serviceInstanceRedeploy"]` truthy.
* Both treat it as a bare boolean, and bin/railway's REDEPLOY_MUTATION selects
* NO subfields on the result (confirming a scalar, not an object/deployment).
*/
interface ServiceInstanceRedeployResult {
serviceInstanceRedeploy: boolean | null;
}
/**
* `serviceInstanceUpdate` returns a Boolean! in Railway's schema — `true`
* once the instance config (sleepApplication / healthcheck / image / region /
* registryCredentials) is applied. Same precedent as serviceInstanceRedeploy
* (redeploy-env.ts / bin/railway treat the scalar as a bare boolean). A
* `false`/`null` return means the config was NOT applied — e.g. the service
* would run WITHOUT the intended always-on/healthcheck config — so it must be
* asserted, never discarded.
*/
interface ServiceInstanceUpdateResult {
serviceInstanceUpdate: boolean | null;
}
/**
* Uniform Railway-mutation-result guard: a single chokepoint so no mutation's
* result can be silently discarded (the "mutation result not validated" defect
* class). `ok` extracts the meaningful field from the result; when it is
* falsy (empty string, false, null, undefined) we throw, naming the mutation
* and service so the failure is forensic rather than a silent success.
*
* Returns the (now-verified) result for fluent use at the call site.
*/
function assertMutationOk<T>(
result: T,
ok: (r: T) => unknown,
mutation: string,
serviceName: string,
serviceId: string,
note = "mutation did not take effect; refusing to report success.",
): T {
const value = ok(result);
if (!value) {
throw new Error(
`${mutation} returned ${JSON.stringify(
value,
)} for ${serviceName} (${serviceId}) — ${note}`,
);
}
return result;
}
/** Existing-service lookup result: id + whether it already has a staging domain. */
export interface ExistingService {
id: string;
hasStagingDomain: boolean;
}
/**
* Fetch the project's services (with their staging-env domains) and index
* them by name. Returns a map of serviceName -> ExistingService so the
* provisioner can decide create-vs-update and skip redundant domain creation.
*
* Exported for unit testing against an injected RailwayGqlFn.
*/
export async function fetchExistingServices(
gql: RailwayGqlFn,
projectId: string,
stagingEnvId: string,
): Promise<Map<string, ExistingService>> {
const byName = new Map<string, ExistingService>();
// `project.services` is a Relay ServiceConnection that PAGE-LIMITS. The
// showcase project holds ~27 SSOT + 12 starter services, comfortably more
// than one page. A single un-paginated query returns a TRUNCATED snapshot —
// a starter that lands on a later page then looks ABSENT, the provisioner
// takes the CREATE path, and serviceCreate rejects with a non-transient
// "already exists" (NOT retried) which ABORTS the whole run. So drain every
// page via `pageInfo.hasNextPage`/`endCursor` (standard Relay cursor loop —
// bin/railway's SERVICES_LIST_QUERY predates this hazard and is unpaginated,
// so there is no in-repo precedent to mirror), accumulating into byName.
let after: string | null = null;
// Defensive upper bound so a malformed `pageInfo` (always hasNextPage with a
// stuck cursor) can't spin forever — far above any realistic project size.
for (let page = 0; page < 1000; page++) {
const data: ProjectServicesResult = await gql<ProjectServicesResult>(
`query project($id: String!, $after: String) {
project(id: $id) {
services(first: 100, after: $after) {
edges { node {
id
name
serviceInstances {
edges { node {
environmentId
domains { serviceDomains { domain } }
} }
}
} }
pageInfo { hasNextPage endCursor }
}
}
}`,
{ id: projectId, after },
);
if (!data.project) {
throw new Error(
`Railway project ${projectId} returned null — check PROJECT_ID and that the Railway token has access to this project.`,
);
}
for (const edge of data.project.services.edges) {
const svc = edge.node;
// A transitional service can surface a null serviceInstances connection
// (or null .edges); coalesce so an unguarded `.find` can't throw a
// TypeError that aborts the entire fetch before any starter is touched.
const stagingInstance = (svc.serviceInstances?.edges ?? []).find(
(e) => e.node.environmentId === stagingEnvId,
);
const hasStagingDomain =
(stagingInstance?.node.domains?.serviceDomains?.length ?? 0) > 0;
byName.set(svc.name, { id: svc.id, hasStagingDomain });
}
const pageInfo = data.project.services.pageInfo;
if (!pageInfo?.hasNextPage || !pageInfo.endCursor) {
return byName;
}
after = pageInfo.endCursor;
}
// Reaching the defensive page bound while pageInfo.hasNextPage is STILL true
// means the snapshot is TRUNCATED — services on undrained pages look absent,
// which would feed erroneous CREATE decisions (and a non-transient "already
// exists" abort). Refuse to provision against a partial snapshot: fail loud
// rather than return the truncated map.
throw new Error(
`fetchExistingServices drained ${byName.size} services across the page bound but Railway still reports more pages (hasNextPage) — refusing to provision against a truncated service snapshot.`,
);
}
export interface ProvisionOptions {
gql: RailwayGqlFn;
projectId: string;
stagingEnvId: string;
targets: StarterTarget[];
registryCredentials?: RegistryCredentials;
/** When true, no mutations are sent — plan only. */
dryRun?: boolean;
log?: (line: string) => void;
/**
* Sleep implementation between retries of the post-create
* serviceInstanceUpdate / serviceDomainCreate (Railway materializes the
* env-scoped instance asynchronously). Injected so tests don't actually
* wait. Defaults to a real setTimeout-backed delay.
*/
sleepMs?: (ms: number) => Promise<void>;
}
/** Default inter-retry delay schedule (ms). */
const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
/**
* The Railway eventual-consistency error classes that warrant a retry: the
* env-scoped serviceInstance is materialized asynchronously after
* serviceCreate, so both serviceInstanceUpdate AND serviceDomainCreate /
* serviceInstanceRedeploy issued too eagerly can race it. The instance is
* surfaced either as "ServiceInstance not found" or (for the domain create)
* as a generic "Service ... not found" while the instance is still settling.
* NOTE: an "already exists" domain error is NON-transient and is handled
* separately (caught as a benign no-op) — it must NOT match here.
*
* Railway INTERPOLATES the service id into the second form — the real message
* is "Service <id> not found" (e.g. "Service abc-123 not found"), NOT the
* contiguous "Service not found". So the pattern matches "Service" followed by
* "not found" with the id in between; the `(Instance)?` alternation still
* covers the literal "ServiceInstance not found" form. The bridge is `[^\n]*?`
* (NOT `[\s\S]*?`) — the interpolated id never contains a newline, so keeping
* the match SINGLE-LINE prevents a multi-error newline-joined GraphQL blob
* from bridging an unrelated "Service ..." line to a "... not found" line and
* mis-classifying a non-transient failure as the eventual-consistency signal.
*/
const TRANSIENT_ERROR_RE = /Service(Instance)?\b[^\n]*?not found/i;
/**
* Retry a Railway mutation that can transiently fail while the env-scoped
* instance is still materializing after serviceCreate. Re-throws
* non-transient errors immediately and re-throws the last transient error
* once the schedule is exhausted. The transient predicate is overridable
* per-call so callers with a different eventual-consistency signature can
* supply their own.
*/
async function withRetry<T>(
fn: () => Promise<T>,
sleep: (ms: number) => Promise<void>,
isTransient: (msg: string) => boolean = (msg) => TRANSIENT_ERROR_RE.test(msg),
): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
try {
return await fn();
} catch (e) {
lastErr = e;
const msg = e instanceof Error ? e.message : String(e);
// Only retry the known eventual-consistency errors.
if (!isTransient(msg)) throw e;
if (attempt === RETRY_DELAYS_MS.length) break;
await sleep(RETRY_DELAYS_MS[attempt]);
}
}
// Schedule exhausted on a transient error: wrap the rethrow with context
// (how many retries, that it was transient) so the operator sees WHY the
// run failed, preserving the original error as `cause`.
const lastMsg = lastErr instanceof Error ? lastErr.message : String(lastErr);
throw new Error(
`Exhausted ${RETRY_DELAYS_MS.length} retries for transient Railway error: ${lastMsg}`,
{ cause: lastErr },
);
}
/**
* An "already exists"-class rejection. Two call sites converge on it rather
* than abort, on a partial-failure re-run (the start-of-run snapshot missed
* the resource due to Railway eventual consistency, or a prior run died
* mid-fleet):
* - serviceDomainCreate: treat as the domain already existing (benign no-op);
* - serviceCreate: re-fetch the now-visible service by name and fall through
* to the UPDATE path instead of aborting the whole fleet.
* This is NOT a transient/retryable error — matching it in the retry predicate
* would waste the retry schedule on a permanent condition.
*/
const ALREADY_EXISTS_RE = /(already\s+exists|duplicate)/i;
export interface ProvisionRecord {
slug: string;
serviceName: string;
image: string;
serviceId: string;
action: "created" | "updated";
domain?: string;
domainAction: "created" | "existing" | "skipped" | "would-create";
}
export interface ProvisionSummary {
records: ProvisionRecord[];
}
/**
* The idempotent provisioning core. For each target:
* - create the service (source.image = GHCR ref) if it does not exist,
* otherwise reuse the existing service id (UPDATE path);
* - serviceInstanceUpdate against the STAGING env with
* sleepApplication: false + healthcheckPath + region + (optional) GHCR
* registryCredentials — applied on BOTH the create and update paths so a
* re-run converges drifted settings;
* - serviceInstanceRedeploy so the pinned image ACTUALLY RUNS. A
* serviceCreate + serviceInstanceUpdate(source.image) only pins the image
* ref; it does NOT start a deployment. Railway's image auto-updates fire
* only when a NEW digest is pushed to the tag, so a freshly-provisioned
* service whose :latest digest already exists would sit with no running
* deployment (and the starter_smoke probe would never find it up) without
* this explicit redeploy. This mirrors the documented update+redeploy
* pattern in bin/railway (RestoreCommand / pin_and_verify) and the
* explicit serviceInstanceRedeploy that showcase_deploy.yml issues after
* every GHCR push (see showcase/RAILWAY.md). The update path re-asserts
* source.image too, so it also redeploys to run the (re-)pinned image.
* - serviceDomainCreate for the staging env, but ONLY when the service has
* no staging domain yet (so re-runs don't pile up domains). A
* serviceDomainCreate that rejects with an "already exists" error (the
* snapshot missed the domain due to eventual consistency, or a prior run
* died mid-fleet) is caught as a benign no-op so the re-run converges
* instead of aborting the remaining fleet.
*
* Pure w.r.t. I/O: every Railway interaction goes through the injected `gql`.
*/
export async function provisionStarterFleet(
opts: ProvisionOptions,
): Promise<ProvisionSummary> {
const {
gql,
projectId,
stagingEnvId,
targets,
registryCredentials,
dryRun = false,
log = () => {},
sleepMs = (ms: number) => new Promise((r) => setTimeout(r, ms)),
} = opts;
const existing = await fetchExistingServices(gql, projectId, stagingEnvId);
const records: ProvisionRecord[] = [];
for (const target of targets) {
const prior = existing.get(target.serviceName);
let serviceId: string;
let action: "created" | "updated";
if (prior) {
serviceId = prior.id;
action = "updated";
log(`↻ ${target.serviceName} exists (${serviceId}) — updating`);
} else if (dryRun) {
serviceId = "<dry-run>";
action = "created";
log(`+ ${target.serviceName} would be created (${target.image})`);
} else {
// CRITICAL: pass `environmentId` so the new service instance is
// scoped to STAGING only. Without it, Railway materializes the
// instance in the project's DEFAULT (production) environment — which
// would leak a prod instance for every starter. `registryCredentials`
// is also supplied here so the staging instance can pull the private
// GHCR image from birth (before the serviceInstanceUpdate below
// re-asserts the rest of the config).
const createInput: Record<string, unknown> = {
projectId,
environmentId: stagingEnvId,
name: target.serviceName,
source: { image: target.image },
};
if (registryCredentials) {
createInput.registryCredentials = registryCredentials;
}
try {
const created = await gql<ServiceCreateResult>(
`mutation serviceCreate($input: ServiceCreateInput!) {
serviceCreate(input: $input) { id name }
}`,
{ input: createInput },
);
assertMutationOk(
created,
(r) => r.serviceCreate?.id,
"serviceCreate",
target.serviceName,
"<creating>",
);
serviceId = created.serviceCreate.id;
action = "created";
log(`+ ${target.serviceName} created (${serviceId})`);
} catch (e) {
// A snapshot-miss (Railway eventual consistency) can make a service
// look absent → CREATE path → a non-transient "already exists"
// rejection. That must NOT abort the whole fleet: re-fetch the now-
// visible service id by name and fall through to the UPDATE path so
// the run converges. Any other error still fails loud.
const msg = e instanceof Error ? e.message : String(e);
if (!ALREADY_EXISTS_RE.test(msg)) throw e;
const refetched = await fetchExistingServices(
gql,
projectId,
stagingEnvId,
);
const found = refetched.get(target.serviceName);
if (!found) {
// The create said "already exists" but a re-fetch can't find it —
// genuinely inconsistent; fail loud rather than guess.
throw new Error(
`serviceCreate for ${target.serviceName} rejected as already-existing, but a re-fetch could not find it by name — refusing to proceed against an inconsistent snapshot.`,
{ cause: e },
);
}
serviceId = found.id;
action = "updated";
log(
`↻ ${target.serviceName} already exists (re-fetched ${serviceId}) — updating: ${msg}`,
);
}
}
// Apply sleep + healthcheck + region (+ creds) against staging on BOTH
// paths. The image source is also re-asserted on the update path so a
// drifted existing service converges back to the canonical GHCR ref.
const instanceInput: Record<string, unknown> = {
source: { image: target.image },
sleepApplication: false,
healthcheckPath: STARTER_HEALTHCHECK_PATH,
region: STARTER_REGION,
};
if (registryCredentials) {
instanceInput.registryCredentials = registryCredentials;
}
if (!dryRun && serviceId !== "<dry-run>") {
// Railway materializes the env-scoped serviceInstance asynchronously
// after serviceCreate; a serviceInstanceUpdate issued too eagerly can
// race it ("ServiceInstance not found"). Retry briefly so a fresh
// create converges. Existing services (update path) hit this on the
// first try.
const updated = await withRetry(
() =>
gql<ServiceInstanceUpdateResult>(
`mutation serviceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) {
serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input)
}`,
{
serviceId,
environmentId: stagingEnvId,
input: instanceInput,
},
),
sleepMs,
);
// serviceInstanceUpdate returns Boolean! — a `false`/`null` return means
// sleepApplication/healthcheck/image/creds were NOT applied (the service
// would run WITHOUT the intended always-on/healthcheck config) while the
// script reports success. Assert the result and only log "configured ..."
// AFTER it is verified.
assertMutationOk(
updated,
(r) => r.serviceInstanceUpdate,
"serviceInstanceUpdate",
target.serviceName,
serviceId,
);
log(
` configured sleep=false, healthcheck=${STARTER_HEALTHCHECK_PATH}, region=${STARTER_REGION}${
registryCredentials ? ", registry creds" : ""
}`,
);
// Redeploy so the pinned image ACTUALLY RUNS. serviceInstanceUpdate
// only pins source.image; without this the service has no running
// deployment and starter_smoke would never find it up. Wrapped in
// withRetry for the same instance-materialization race as the update.
const redeployed = await withRetry(
() =>
gql<ServiceInstanceRedeployResult>(
`mutation serviceInstanceRedeploy($serviceId: String!, $environmentId: String!) {
serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId)
}`,
{ serviceId, environmentId: stagingEnvId },
),
sleepMs,
);
// serviceInstanceRedeploy returns Boolean! (see the result-type comment
// for the verified contract + sources). A `false`/`null` return means
// Railway did NOT enqueue a deployment, so the image would never run —
// fail loud rather than report success. Routed through the same uniform
// mutation-result guard as serviceCreate / serviceInstanceUpdate; any
// truthy value is accepted defensively in case the contract ever widens.
assertMutationOk(
redeployed,
(r) => r.serviceInstanceRedeploy,
"serviceInstanceRedeploy",
target.serviceName,
serviceId,
"image will not run.",
);
log(` redeployed — image now running`);
}
// Domain: create only when none exists yet (idempotent).
let domain: string | undefined;
let domainAction: "created" | "existing" | "skipped" | "would-create";
if (prior?.hasStagingDomain) {
domainAction = "existing";
log(` staging domain already present — skipping create`);
} else if (dryRun) {
// Faithful preview: a real run WOULD create a domain here (the service
// has none yet), so report "would-create" rather than the misleading
// "skipped".
domainAction = "would-create";
log(` domain: would create (none present)`);
} else {
try {
const domainResult = await withRetry(
() =>
gql<ServiceDomainResult>(
`mutation serviceDomainCreate($input: ServiceDomainCreateInput!) {
serviceDomainCreate(input: $input) { domain }
}`,
{
input: { serviceId, environmentId: stagingEnvId },
},
),
sleepMs,
);
// Assert the create actually yielded a domain — a null/empty domain
// is a silent-success the script must not report as created.
assertMutationOk(
domainResult,
(r) => r.serviceDomainCreate?.domain,
"serviceDomainCreate",
target.serviceName,
serviceId,
);
domain = domainResult.serviceDomainCreate.domain;
domainAction = "created";
log(` domain: https://${domain}`);
} catch (e) {
// Idempotent convergence on a partial-failure re-run: if the domain
// already exists (the start-of-run snapshot missed it, or a prior run
// died mid-fleet), Railway rejects the create with a non-transient
// "already exists" error. That is benign — treat it as existing and
// KEEP GOING rather than aborting the remaining fleet. Any other
// error still aborts (fail loud).
const msg = e instanceof Error ? e.message : String(e);
if (!ALREADY_EXISTS_RE.test(msg)) throw e;
domainAction = "existing";
// Include the ACTUAL matched Railway message for a forensic trail —
// so a re-run log shows exactly which "already exists" wording was
// absorbed, not just a generic note.
log(
` staging domain already exists (per Railway) — treating as no-op: ${msg}`,
);
}
}
records.push({
slug: target.slug,
serviceName: target.serviceName,
image: target.image,
serviceId,
action,
domain,
domainAction,
});
}
return { records };
}
// ── Live GraphQL caller ─────────────────────────────────────────────────
/**
* Resolve the Railway token, normalizing the typed RailwayTokenError into a
* plain Error so main().catch owns the process exit (rather than a deep
* process.exit(1) inside the GraphQL boundary). main() validates this UP
* FRONT — before any provisioning — so a missing token fails fast instead of
* mid-mutation.
*/
function resolveTokenOrThrow(): string {
try {
return resolveRailwayToken().token;
} catch (e) {
if (e instanceof RailwayTokenError) {
throw new Error(e.message, { cause: e });
}
throw e;
}
}
/** Build a live GraphQL caller bound to a single, already-resolved token. */
function makeLiveGql(token: string): RailwayGqlFn {
return async function railwayGql<T = unknown>(
query: string,
variables: Record<string, unknown> = {},
): Promise<T> {
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;
};
}
// ── Subcommands ─────────────────────────────────────────────────────────
async function listStarterServices(gql: RailwayGqlFn): Promise<void> {
const existing = await fetchExistingServices(gql, PROJECT_ID, STAGING_ENV_ID);
const starters = [...existing.entries()]
.filter(([name]) => name.startsWith(STARTER_FLEET_PREFIX))
.sort(([a], [b]) => a.localeCompare(b));
console.log(`Starter-fleet services in staging (${starters.length}):\n`);
for (const [name, svc] of starters) {
console.log(
` ${name.padEnd(40)} ${svc.id} ${
svc.hasStagingDomain ? "[domain]" : "[no domain]"
}`,
);
}
}
const USAGE = `Usage:
npx tsx showcase/scripts/provision-starter-fleet.ts Provision all 12 starter-* services in staging
npx tsx showcase/scripts/provision-starter-fleet.ts --list List starter-* services in staging
npx tsx showcase/scripts/provision-starter-fleet.ts --dry-run Plan only (no mutations)
`;
export interface ParsedArgs {
help: boolean;
list: boolean;
dryRun: boolean;
}
/** The flags this script recognizes. */
const KNOWN_FLAGS = new Set(["--help", "--list", "--dry-run"]);
/**
* Parse argv into the recognized flags, REJECTING any unrecognized argument.
* Without this, a mistyped flag (e.g. `--dry-rn`) is silently ignored and —
* because dryRun stays false — the script proceeds to REAL live provisioning,
* the exact opposite of the operator's intent. Throws on any unknown arg so
* main().catch aborts before any mutation.
*
* Exported pure for unit testing.
*/
export function parseArgs(args: string[]): ParsedArgs {
for (const arg of args) {
if (!KNOWN_FLAGS.has(arg)) {
throw new Error(`Unknown argument: ${arg}\n${USAGE}`);
}
}
return {
help: args.includes("--help"),
list: args.includes("--list"),
dryRun: args.includes("--dry-run"),
};
}
async function main(): Promise<void> {
const parsed = parseArgs(process.argv.slice(2));
if (parsed.help) {
console.log(USAGE);
return;
}
// Validate the Railway token UP FRONT — before any provisioning — so a
// missing/unreadable token fails fast (and main().catch owns the exit)
// rather than blowing up mid-mutation on the first GraphQL call.
const token = resolveTokenOrThrow();
const gql = makeLiveGql(token);
if (parsed.list) {
await listStarterServices(gql);
return;
}
const dryRun = parsed.dryRun;
const targets = deriveStarterTargets();
// Validate registry credentials UP FRONT too (mirrors the existing
// resolveRegistryCredentials validation) so a token-without-username
// misconfig fails before any mutation. The throw propagates to
// main().catch — no deep process.exit here.
const registryCredentials = resolveRegistryCredentials();
if (!registryCredentials) {
if (dryRun) {
console.warn(
"\n WARNING: GITHUB_TOKEN not set. Registry credentials will NOT be configured — Railway cannot pull the private GHCR starter images until creds are added.\n",
);
} else {
// ABORT on the LIVE path: creating services that point at a PRIVATE
// GHCR image with no pull credentials yields a perpetual
// image-pull-backoff while the script reports "success". That is worse
// than failing — fail loud up front. warn-and-continue is only
// tolerable under --dry-run, where no service is actually created.
throw new Error(
"GITHUB_TOKEN is not set. The starter images are PRIVATE GHCR images; provisioning live services without registry credentials would leave every service in image-pull-backoff while reporting success. Set GITHUB_TOKEN (+ GHCR_USERNAME / GITHUB_ACTOR) and re-run, or use --dry-run to plan without mutations.",
);
}
}
console.log(
`Provisioning ${targets.length} always-on starter-* services in STAGING (env ${STAGING_ENV_ID})${
dryRun ? " [DRY RUN]" : ""
}\n`,
);
const summary = await provisionStarterFleet({
gql,
projectId: PROJECT_ID,
stagingEnvId: STAGING_ENV_ID,
targets,
registryCredentials,
dryRun,
log: (line) => console.log(line),
});
console.log(`\nDone. ${summary.records.length} services processed:\n`);
for (const r of summary.records) {
console.log(
` ${r.serviceName.padEnd(40)} ${r.serviceId} (${r.action}${
r.domain ? `, https://${r.domain}` : `, domain:${r.domainAction}`
})`,
);
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((e) => {
console.error(e);
process.exit(1);
});
}