forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathredeploy-env.ts
More file actions
405 lines (379 loc) · 13.8 KB
/
Copy pathredeploy-env.ts
File metadata and controls
405 lines (379 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
#!/usr/bin/env npx tsx
/**
* redeploy-env.ts — Trigger Railway `serviceInstanceRedeploy` for the
* CI-built showcase services in the named environment.
*
* Usage:
* npx tsx showcase/scripts/redeploy-env.ts <env> [--services <csv>]
*
* npx tsx showcase/scripts/redeploy-env.ts staging
* → redeploys all 25 CI_BUILT_SERVICES (default scope excludes
* pocketbase/webhooks; explicit --services can still target them)
*
* npx tsx showcase/scripts/redeploy-env.ts staging --services mastra,ag2
* → redeploys only the listed services (CSV of SSOT keys OR
* showcase_build.yml dispatch_names; mixed is fine).
*
* Behavior:
* - Default target set: CI_BUILT_SERVICES (25 of 27 SSOT entries).
* pocketbase and webhooks are first-party but released by their own
* repos — they are excluded from the default scope. An explicit
* `--services pocketbase` (or webhooks) WILL still redeploy them;
* resolveTargetServices honors any SSOT key the caller asks for.
* - When `--services` is provided, each entry is resolved via
* resolveTargetServices() against SSOT keys + dispatch_names.
* - Per-service Railway failures (including the all-services-fail case)
* are logged to stderr and $GITHUB_STEP_SUMMARY but DO NOT fail the
* process for staging. Staging is not a release gate; the
* verify-deploy workflow is what fails on bad images. For env=prod,
* per-service failures DO yield a non-zero exitCode (fail loud).
* - Operator/config errors (bad env name, unknown service, missing or
* malformed token) ALWAYS fail loud with a non-zero exit.
*
* Auth: RAILWAY_TOKEN env var or ~/.railway/config.json.
* Exit code: 0 on staging even when per-service redeploys fail; non-zero
* for prod per-service failures and for any operator/config error.
*/
import fs from "fs";
import { fileURLToPath } from "url";
import {
CI_BUILT_SERVICES,
PRODUCTION_ENV_ID,
SERVICES,
STAGING_ENV_ID,
resolveEnv,
serviceForDispatchName,
} from "./railway-envs";
import type { EnvName } from "./railway-envs";
import {
RAILWAY_GRAPHQL_ENDPOINT,
sanitizeErrorBody,
} from "./lib/railway-graphql";
import { RailwayTokenError, resolveRailwayToken } from "./lib/railway-token";
const RAILWAY_API = RAILWAY_GRAPHQL_ENDPOINT;
/**
* 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.
*/
function getToken(): string {
try {
return resolveRailwayToken().token;
} catch (e) {
if (e instanceof RailwayTokenError) {
console.error(e.message);
process.exit(1);
}
throw e;
}
}
export interface RedeployResult {
ok: true;
}
export interface RedeployFailure {
ok: false;
error: string;
}
export type RedeployOutcome = RedeployResult | RedeployFailure;
export type RedeployFn = (
serviceId: string,
environmentId: string,
) => Promise<RedeployOutcome>;
/**
* Build a `liveRedeploy` RedeployFn bound to a single resolved token, so
* token resolution happens once per process rather than once per service.
*/
function makeLiveRedeploy(token: string): RedeployFn {
return async function liveRedeploy(
serviceId: string,
environmentId: string,
): Promise<RedeployOutcome> {
const res = await fetch(RAILWAY_API, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `mutation serviceInstanceRedeploy($serviceId: String!, $environmentId: String!) {
serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId)
}`,
variables: { serviceId, environmentId },
}),
});
if (!res.ok) {
const body = sanitizeErrorBody(await res.text());
return { ok: false, error: `HTTP ${res.status}: ${body}` };
}
const json = (await res.json()) as {
data?: { serviceInstanceRedeploy?: boolean };
errors?: Array<{ message: string }>;
};
if (json.errors?.length) {
return { ok: false, error: json.errors.map((e) => e.message).join("; ") };
}
if (json.data?.serviceInstanceRedeploy !== true) {
return {
ok: false,
error: `serviceInstanceRedeploy returned ${JSON.stringify(json.data?.serviceInstanceRedeploy)}`,
};
}
return { ok: true };
};
}
export interface RunRedeployOpts {
env: EnvName;
redeploy: RedeployFn;
appendSummary: (line: string) => void;
/**
* Explicit service list. Each entry may be either an SSOT key
* (e.g. `showcase-mastra`) or a `showcase_build.yml` dispatch_name
* (e.g. `mastra`, `shell-dashboard`, `showcase-aimock`).
*
* When undefined, the default scope is `CI_BUILT_SERVICES` (the 25
* services that `showcase_build.yml` actually builds). pocketbase
* and webhooks are NEVER in the default scope.
*/
services?: string[];
}
export interface RunRedeploySummary {
exitCode: number;
attempted: number;
succeeded: number;
failed: number;
}
/**
* Normalize a caller-supplied service list (CSV of SSOT keys and/or
* dispatch_names, in any mix) into a deduped list of SSOT keys.
*
* Ordering is intentionally split by branch:
* - When `input` is undefined, returns the default `CI_BUILT_SERVICES`
* set sorted alphabetically (never includes pocketbase/webhooks).
* - When `input` is provided, returns the resolved SSOT keys in the
* caller's INSERTION order (deduped). `runRedeploy` then sorts before
* iterating, so the user-visible iteration order is alphabetical in
* both cases, but this function preserves insertion order so callers
* that want it can opt in.
*
* Exported for direct unit testing.
*/
export function resolveTargetServices(input: string[] | undefined): string[] {
if (input === undefined) {
return [...CI_BUILT_SERVICES].sort();
}
const resolved = new Set<string>();
for (const raw of input) {
const name = raw.trim();
if (!name) continue;
if (SERVICES[name]) {
resolved.add(name);
continue;
}
const viaDispatch = serviceForDispatchName(name);
if (viaDispatch) {
resolved.add(viaDispatch);
continue;
}
throw new Error(
`Unknown service "${name}" — not an SSOT key or dispatch_name in railway-envs.ts. Add it to SERVICES (with a dispatchName) or fix the caller.`,
);
}
return [...resolved];
}
/**
* Pure argv parser. Accepts either `--services x,y,z` or `--services=x,y,z`.
* Throws if `--services` is provided with a missing/empty value (silent
* no-op in CI is worse than a loud failure). Throws on unknown args or
* empty argv. Exported for direct unit testing.
*/
export function parseArgs(argv: string[]): {
env: string;
services?: string[];
} {
if (argv.length === 0) {
throw new Error(
"Usage: redeploy-env.ts <env> [--services <csv>] (env: prod | production | staging)",
);
}
const env = argv[0];
let services: string[] | undefined;
const ensureNonEmpty = (raw: string | undefined): string[] => {
if (raw === undefined || raw === "") {
throw new Error("--services requires a non-empty comma-separated value");
}
const parts = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (parts.length === 0) {
throw new Error("--services requires a non-empty comma-separated value");
}
return parts;
};
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
if (a === "--services") {
services = ensureNonEmpty(argv[++i]);
} else if (a.startsWith("--services=")) {
services = ensureNonEmpty(a.slice("--services=".length));
} else {
throw new Error(`Unknown argument: ${a}`);
}
}
return services === undefined ? { env } : { env, services };
}
export async function runRedeploy(
opts: RunRedeployOpts,
): Promise<RunRedeploySummary> {
const { env, redeploy, appendSummary, services } = opts;
if (env !== "prod" && env !== "staging") {
throw new Error(
`Unknown env: ${String(env)} (expected "prod" or "staging")`,
);
}
const envId = env === "prod" ? PRODUCTION_ENV_ID : STAGING_ENV_ID;
const names = resolveTargetServices(services).sort();
const failures: Array<{ service: string; error: string }> = [];
// Per-service structured records — cross-workstream contract consumed
// by showcase_deploy.yml's `enforce-redeploy-gate` (A.7) via the
// REDEPLOY_SUMMARY_JSON artifact. Shape:
// Array<{ service: string; status: "ok" | "error"; error?: string }>
// Built in parallel with the existing `failures`/`succeeded` tallies so
// PR #5093's exit-code computation below is untouched.
const records: Array<{
service: string;
status: "ok" | "error";
error?: string;
}> = [];
let succeeded = 0;
appendSummary(`## Railway redeploy — env=${env}`);
appendSummary("");
for (const name of names) {
const entry = SERVICES[name];
process.stdout.write(` ${name.padEnd(36)} `);
try {
const outcome = await redeploy(entry.serviceId, envId);
if (outcome.ok) {
succeeded++;
records.push({ service: name, status: "ok" });
process.stdout.write("OK\n");
} else {
failures.push({ service: name, error: outcome.error });
records.push({ service: name, status: "error", error: outcome.error });
process.stdout.write(`FAIL: ${outcome.error}\n`);
}
} catch (e: unknown) {
const error = e instanceof Error ? e.message : String(e);
failures.push({ service: name, error });
records.push({ service: name, status: "error", error });
process.stdout.write(`FAIL (threw): ${error}\n`);
}
}
const attempted = names.length;
const failed = failures.length;
appendSummary(`- attempted: **${attempted}**`);
appendSummary(`- succeeded: **${succeeded}**`);
appendSummary(`- ${failed} failed`);
appendSummary("");
if (failures.length > 0) {
appendSummary("### Failures");
appendSummary("");
appendSummary("| service | status | error |");
appendSummary("| --- | --- | --- |");
for (const f of failures) {
const safeErr = f.error.replace(/\|/g, "\\|").replace(/\n/g, " ");
appendSummary(`| \`${f.service}\` | FAIL | ${safeErr} |`);
}
appendSummary("");
if (env === "staging") {
appendSummary(
"Staging redeploys are non-fatal — the verify-deploy workflow is the gate.",
);
}
}
// A.4: optional per-service JSON summary for showcase_deploy.yml's
// `enforce-redeploy-gate` job. Atomic write (stage to .tmp, rename) so
// a CI consumer racing the writer never sees a partial file. A failure
// here is warn-only — PR #5093's exit-code semantics MUST NOT regress
// on a disk hiccup.
const jsonPath = process.env.REDEPLOY_SUMMARY_JSON;
if (jsonPath) {
try {
const tmp = `${jsonPath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(records, null, 2) + "\n");
fs.renameSync(tmp, jsonPath);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`warning: failed to write REDEPLOY_SUMMARY_JSON=${jsonPath} (${msg})\n`,
);
// Non-fatal: best-effort CI summary write; do NOT regress
// PR #5093's exit semantics on a disk hiccup.
}
}
// Staging: per-service failures are non-fatal (the verify-deploy
// workflow is the real release gate). Prod: per-service failures must
// surface as a non-zero exit so an operator notices.
const exitCode = env === "prod" && failed > 0 ? 1 : 0;
return { exitCode, attempted, succeeded, failed };
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) {
console.error(
"Usage: npx tsx showcase/scripts/redeploy-env.ts <env> [--services <csv>]",
);
console.error(" env: prod | production | staging");
console.error(" --services: optional CSV of SSOT keys or dispatch_names");
process.exit(2);
}
const parsed = parseArgs(argv);
const { env } = resolveEnv(parsed.env);
const services = parsed.services;
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
const appendSummary = (line: string) => {
if (summaryFile) {
try {
fs.appendFileSync(summaryFile, line + "\n");
} catch (e) {
// Best-effort: a non-writable $GITHUB_STEP_SUMMARY must not abort
// the run. Mirror to stderr so the failure is at least visible.
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`warning: failed to append to GITHUB_STEP_SUMMARY (${msg})\n`,
);
}
}
// Also mirror to stderr so local runs see it.
process.stderr.write(line + "\n");
};
// Resolve the Railway token ONCE for the whole run, then thread it
// through to liveRedeploy. Missing/malformed creds exit non-zero from
// getToken() before we ever enter the per-service loop.
const token = getToken();
const redeploy = makeLiveRedeploy(token);
const result = await runRedeploy({
env,
redeploy,
appendSummary,
services,
});
console.log(
`\n${result.succeeded}/${result.attempted} redeploys triggered (${result.failed} failed)`,
);
process.exit(result.exitCode);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((e) => {
console.error(e);
// Fail loud on operator/config errors (bad env name, unknown service,
// missing/malformed token, parseArgs rejection, etc.). Per-service
// Railway failures are caught INSIDE runRedeploy and reflected in
// result.exitCode — they never reach this catch — so reaching here
// means something is wrong with how the script was invoked or
// configured, and CI should see a red run instead of a silent no-op.
process.exit(1);
});
}