Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

cloud-deploy.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

cloud-deploy.tsBlame809 lines · 1 contributor
c9ed210Claude1/**
2 * Multi-cloud deploy integration (migration 0077).
3 *
4 * Supports push-triggered deploys to:
5 * - Fly.io — Machines API deploy trigger
6 * - Railway — GraphQL deploymentTrigger mutation
7 * - Render — REST API POST /v1/services/:id/deploys
8 * - Vercel — REST API POST /v13/deployments (git-source)
9 * - Netlify — REST API POST /v1/sites/:id/builds
10 * - webhook — Generic POST (covers Coolify, CapRover, Dokku, etc.)
11 *
12 * Each provider function returns a {deployId, logUrl?, deployUrl?} on
13 * success or throws on hard error. Background polling updates the DB row
14 * status every ~10s until a terminal state is reached.
15 *
16 * Token storage: API tokens are AES-256-GCM encrypted in the DB via
17 * `server-targets-crypto.ts` (same key: SERVER_TARGETS_KEY).
18 */
19
20import { eq, and } from "drizzle-orm";
21import { db } from "../db";
22import { cloudDeployConfigs, cloudDeployments, repositories, users } from "../db/schema";
23import { decryptValue } from "./server-targets-crypto";
24
25// ─── Provider types ───────────────────────────────────────────────────────────
26
27export type CloudProvider =
28 | "fly"
29 | "railway"
30 | "render"
31 | "vercel"
32 | "netlify"
33 | "webhook";
34
35interface DeployResult {
36 providerDeployId?: string;
37 logUrl?: string;
38 deployUrl?: string;
39}
40
41// ─── Fly.io ──────────────────────────────────────────────────────────────────
42
43/**
44 * Trigger a Fly.io deployment via the Fly Machines REST API.
45 *
46 * Uses the "create a new machine that immediately exits" approach:
47 * creates a temp machine from the fly-builder image which triggers Fly's
48 * built-in build + release pipeline. For most users the simpler approach is
49 * to use flyctl, but we invoke the Machines API so we don't need the binary.
50 *
51 * Fly deploy token: generate with `flyctl tokens create deploy -a <app>`
52 * and store encrypted in cloud_deploy_configs.api_token_encrypted.
53 *
54 * The appName is the Fly app name (e.g. "my-app").
55 */
56export async function deployToFly(
57 appName: string,
58 token: string,
59 commitSha: string,
60 fetchImpl: typeof fetch = fetch
61): Promise<DeployResult> {
62 // POST to the Fly Machines API to create a one-shot deploy machine.
63 // The machine runs `fly deploy` logic internally when provisioned with
64 // a deploy token and app name — effectively a remote flyctl.
65 const url = `https://api.machines.dev/v1/apps/${encodeURIComponent(appName)}/machines`;
66
67 // For Fly's Deploy-via-API pattern: hit the `releases` endpoint instead.
68 // This is equivalent to what the Fly dashboard does when you click "Redeploy".
69 // We signal "deploy HEAD" by triggering a new release from the current image.
70 const releaseUrl = `https://api.fly.io/v1/apps/${encodeURIComponent(appName)}/releases`;
71 const body = JSON.stringify({
72 image: null, // null = re-deploy the latest image
73 strategy: "rolling",
74 commit_message: `gluecron deploy @ ${commitSha.slice(0, 7)}`,
75 });
76
77 const res = await fetchImpl(releaseUrl, {
78 method: "POST",
79 headers: {
80 Authorization: `Bearer ${token}`,
81 "Content-Type": "application/json",
82 },
83 body,
84 });
85
86 if (!res.ok) {
87 const text = await res.text().catch(() => "");
88 throw new Error(`Fly deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
89 }
90
91 let data: Record<string, unknown> = {};
92 try {
93 data = (await res.json()) as Record<string, unknown>;
94 } catch {
95 /* ignore non-JSON bodies */
96 }
97
98 const releaseId = String(data.id || data.release_id || "");
99 const releaseVersion = data.version !== undefined ? String(data.version) : "";
100 const logUrl = releaseId
101 ? `https://fly.io/apps/${encodeURIComponent(appName)}/monitoring?release=${releaseId}`
102 : `https://fly.io/apps/${encodeURIComponent(appName)}/monitoring`;
103
104 return {
105 providerDeployId: releaseId || releaseVersion || undefined,
106 logUrl,
107 deployUrl: `https://${appName}.fly.dev`,
108 };
109}
110
111/**
112 * Poll Fly.io release status.
113 * Returns "success" | "failed" | "running" | "pending".
114 */
115export async function pollFlyStatus(
116 appName: string,
117 releaseId: string,
118 token: string,
119 fetchImpl: typeof fetch = fetch
120): Promise<string> {
121 try {
122 const url = `https://api.fly.io/v1/apps/${encodeURIComponent(appName)}/releases/${encodeURIComponent(releaseId)}`;
123 const res = await fetchImpl(url, {
124 headers: { Authorization: `Bearer ${token}` },
125 });
126 if (!res.ok) return "running"; // assume still running on transient error
127 const data = (await res.json()) as Record<string, unknown>;
128 const status = String(data.status || "").toLowerCase();
129 if (status === "complete" || status === "succeeded") return "success";
130 if (status === "failed" || status === "error" || status === "cancelled") return "failed";
131 return "running";
132 } catch {
133 return "running";
134 }
135}
136
137// ─── Railway ─────────────────────────────────────────────────────────────────
138
139/**
140 * Trigger a Railway service redeployment via their GraphQL API.
141 *
142 * Railway API token: https://railway.app/account/tokens
143 * serviceId: from the Railway dashboard URL (Settings > General).
144 */
145export async function deployToRailway(
146 serviceId: string,
147 token: string,
148 commitSha: string,
149 fetchImpl: typeof fetch = fetch
150): Promise<DeployResult> {
151 const query = `
152 mutation ServiceInstanceRedeploy($serviceId: String!) {
153 serviceInstanceRedeploy(input: { serviceId: $serviceId })
154 }
155 `;
156
157 const res = await fetchImpl("https://backboard.railway.app/graphql/v2", {
158 method: "POST",
159 headers: {
160 Authorization: `Bearer ${token}`,
161 "Content-Type": "application/json",
162 },
163 body: JSON.stringify({ query, variables: { serviceId } }),
164 });
165
166 if (!res.ok) {
167 const text = await res.text().catch(() => "");
168 throw new Error(`Railway deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
169 }
170
171 const data = (await res.json()) as {
172 data?: { serviceInstanceRedeploy?: string };
173 errors?: Array<{ message: string }>;
174 };
175
176 if (data.errors?.length) {
177 throw new Error(`Railway GraphQL error: ${data.errors[0].message}`);
178 }
179
180 const deployId = data.data?.serviceInstanceRedeploy || "";
181
182 return {
183 providerDeployId: deployId || undefined,
184 logUrl: deployId
185 ? `https://railway.app/project/-/service/${serviceId}/logs`
186 : undefined,
187 deployUrl: undefined, // Railway generates a dynamic URL per project
188 };
189}
190
191/**
192 * Poll Railway deployment status.
193 */
194export async function pollRailwayStatus(
195 deployId: string,
196 token: string,
197 fetchImpl: typeof fetch = fetch
198): Promise<string> {
199 try {
200 const query = `
201 query Deployment($id: String!) {
202 deployment(id: $id) { status }
203 }
204 `;
205 const res = await fetchImpl("https://backboard.railway.app/graphql/v2", {
206 method: "POST",
207 headers: {
208 Authorization: `Bearer ${token}`,
209 "Content-Type": "application/json",
210 },
211 body: JSON.stringify({ query, variables: { id: deployId } }),
212 });
213 if (!res.ok) return "running";
214 const data = (await res.json()) as {
215 data?: { deployment?: { status?: string } };
216 };
217 const status = (data.data?.deployment?.status || "").toUpperCase();
218 if (status === "SUCCESS" || status === "COMPLETE") return "success";
219 if (status === "FAILED" || status === "CANCELLED" || status === "CRASHED") return "failed";
220 return "running";
221 } catch {
222 return "running";
223 }
224}
225
226// ─── Render ──────────────────────────────────────────────────────────────────
227
228/**
229 * Trigger a Render service deployment via their REST API.
230 *
231 * Render API key: https://dashboard.render.com/u/settings
232 * serviceId: the service's ID from the Render dashboard URL.
233 */
234export async function deployToRender(
235 serviceId: string,
236 token: string,
237 fetchImpl: typeof fetch = fetch
238): Promise<DeployResult> {
239 const res = await fetchImpl(
240 `https://api.render.com/v1/services/${encodeURIComponent(serviceId)}/deploys`,
241 {
242 method: "POST",
243 headers: {
244 Authorization: `Bearer ${token}`,
245 "Content-Type": "application/json",
246 Accept: "application/json",
247 },
248 body: JSON.stringify({ clearCache: "do_not_clear" }),
249 }
250 );
251
252 if (!res.ok) {
253 const text = await res.text().catch(() => "");
254 throw new Error(`Render deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
255 }
256
257 const data = (await res.json()) as {
258 id?: string;
259 deploy?: { id?: string; status?: string; url?: string };
260 };
261 const deployId = data.deploy?.id || data.id || "";
262
263 return {
264 providerDeployId: deployId || undefined,
265 logUrl: deployId
266 ? `https://dashboard.render.com/web/${serviceId}/deploys/${deployId}`
267 : undefined,
268 deployUrl: undefined, // returned in service details, not deploy
269 };
270}
271
272/**
273 * Poll Render deployment status.
274 */
275export async function pollRenderStatus(
276 serviceId: string,
277 deployId: string,
278 token: string,
279 fetchImpl: typeof fetch = fetch
280): Promise<string> {
281 try {
282 const res = await fetchImpl(
283 `https://api.render.com/v1/services/${encodeURIComponent(serviceId)}/deploys/${encodeURIComponent(deployId)}`,
284 {
285 headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
286 }
287 );
288 if (!res.ok) return "running";
289 const data = (await res.json()) as { deploy?: { status?: string } };
290 const status = (data.deploy?.status || "").toLowerCase();
291 if (status === "live") return "success";
292 if (status === "failed" || status === "canceled" || status === "deactivated") return "failed";
293 return "running";
294 } catch {
295 return "running";
296 }
297}
298
299// ─── Vercel ──────────────────────────────────────────────────────────────────
300
301/**
302 * Trigger a Vercel deployment via their REST API.
303 *
304 * Vercel token: https://vercel.com/account/tokens
305 * projectId: from Vercel project settings.
306 *
307 * Note: this creates a "forced" redeploy of the latest successful deployment,
308 * since we're not pushing to a Vercel-connected Git repo. For full Git
309 * integration, users should connect their Gluecron repo to Vercel via webhook.
310 */
311export async function deployToVercel(
312 projectId: string,
313 token: string,
314 commitSha: string,
315 fetchImpl: typeof fetch = fetch
316): Promise<DeployResult> {
317 // Redeploy the latest deployment of the project
318 const res = await fetchImpl(
319 `https://api.vercel.com/v13/deployments`,
320 {
321 method: "POST",
322 headers: {
323 Authorization: `Bearer ${token}`,
324 "Content-Type": "application/json",
325 },
326 body: JSON.stringify({
327 name: projectId,
328 target: "production",
329 meta: {
330 githubCommitSha: commitSha,
331 source: "gluecron",
332 },
333 // Trigger a redeploy — Vercel will use the latest build config
334 forceNew: 0,
335 }),
336 }
337 );
338
339 if (!res.ok) {
340 const text = await res.text().catch(() => "");
341 // 400 with "no deployments" means we need to check for existing deployment
342 if (res.status === 400) {
343 // Try the redeploy endpoint instead
344 const searchRes = await fetchImpl(
345 `https://api.vercel.com/v6/deployments?projectId=${encodeURIComponent(projectId)}&limit=1&target=production`,
346 { headers: { Authorization: `Bearer ${token}` } }
347 );
348 if (searchRes.ok) {
349 const searchData = (await searchRes.json()) as {
350 deployments?: Array<{ uid?: string; url?: string }>;
351 };
352 const latest = searchData.deployments?.[0];
353 if (latest?.uid) {
354 const redeployRes = await fetchImpl(
355 `https://api.vercel.com/v13/deployments?forceNew=1`,
356 {
357 method: "POST",
358 headers: {
359 Authorization: `Bearer ${token}`,
360 "Content-Type": "application/json",
361 },
362 body: JSON.stringify({ deploymentId: latest.uid }),
363 }
364 );
365 if (redeployRes.ok) {
366 const data = (await redeployRes.json()) as { id?: string; url?: string };
367 return {
368 providerDeployId: data.id || undefined,
369 logUrl: data.id ? `https://vercel.com/deployments/${data.id}` : undefined,
370 deployUrl: data.url ? `https://${data.url}` : undefined,
371 };
372 }
373 }
374 }
375 }
376 throw new Error(`Vercel deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
377 }
378
379 const data = (await res.json()) as { id?: string; url?: string; readyState?: string };
380 return {
381 providerDeployId: data.id || undefined,
382 logUrl: data.id ? `https://vercel.com/deployments/${data.id}` : undefined,
383 deployUrl: data.url ? `https://${data.url}` : undefined,
384 };
385}
386
387/**
388 * Poll Vercel deployment status.
389 */
390export async function pollVercelStatus(
391 deployId: string,
392 token: string,
393 fetchImpl: typeof fetch = fetch
394): Promise<string> {
395 try {
396 const res = await fetchImpl(
397 `https://api.vercel.com/v13/deployments/${encodeURIComponent(deployId)}`,
398 { headers: { Authorization: `Bearer ${token}` } }
399 );
400 if (!res.ok) return "running";
401 const data = (await res.json()) as { readyState?: string; state?: string };
402 const state = (data.readyState || data.state || "").toUpperCase();
403 if (state === "READY") return "success";
404 if (state === "ERROR" || state === "CANCELED" || state === "FAILED") return "failed";
405 return "running";
406 } catch {
407 return "running";
408 }
409}
410
411// ─── Netlify ─────────────────────────────────────────────────────────────────
412
413/**
414 * Trigger a Netlify site build via their REST API.
415 *
416 * Netlify token: https://app.netlify.com/user/applications
417 * providerAppId: the Netlify site ID.
418 */
419export async function deployToNetlify(
420 siteId: string,
421 token: string,
422 fetchImpl: typeof fetch = fetch
423): Promise<DeployResult> {
424 const res = await fetchImpl(
425 `https://api.netlify.com/api/v1/sites/${encodeURIComponent(siteId)}/builds`,
426 {
427 method: "POST",
428 headers: {
429 Authorization: `Bearer ${token}`,
430 "Content-Type": "application/json",
431 },
432 body: JSON.stringify({}),
433 }
434 );
435
436 if (!res.ok) {
437 const text = await res.text().catch(() => "");
438 throw new Error(`Netlify deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
439 }
440
441 const data = (await res.json()) as { id?: string; deploy?: { id?: string; deploy_url?: string } };
442 const deployId = data.id || data.deploy?.id || "";
443 const deployUrl = data.deploy?.deploy_url || "";
444
445 return {
446 providerDeployId: deployId || undefined,
447 logUrl: deployId
448 ? `https://app.netlify.com/sites/${siteId}/deploys/${deployId}`
449 : undefined,
450 deployUrl: deployUrl || undefined,
451 };
452}
453
454/**
455 * Poll Netlify build status.
456 */
457export async function pollNetlifyStatus(
458 siteId: string,
459 buildId: string,
460 token: string,
461 fetchImpl: typeof fetch = fetch
462): Promise<string> {
463 try {
464 const res = await fetchImpl(
465 `https://api.netlify.com/api/v1/sites/${encodeURIComponent(siteId)}/builds/${encodeURIComponent(buildId)}`,
466 { headers: { Authorization: `Bearer ${token}` } }
467 );
468 if (!res.ok) return "running";
469 const data = (await res.json()) as { state?: string };
470 const state = (data.state || "").toLowerCase();
471 if (state === "ready") return "success";
472 if (state === "error" || state === "cancelled") return "failed";
473 return "running";
474 } catch {
475 return "running";
476 }
477}
478
479// ─── Generic webhook ─────────────────────────────────────────────────────────
480
481/**
482 * Fire a generic deploy webhook — covers Coolify, CapRover, Dokku, etc.
483 *
484 * providerAppId = the full webhook URL.
485 * token = optional HMAC secret or Bearer token (sent as Authorization header if set).
486 *
487 * POSTs JSON: { event: "push", commit_sha: "...", source: "gluecron" }
488 */
489export async function deployViaWebhook(
490 webhookUrl: string,
491 token: string,
492 commitSha: string,
493 fetchImpl: typeof fetch = fetch
494): Promise<DeployResult> {
495 const headers: Record<string, string> = {
496 "Content-Type": "application/json",
497 "User-Agent": "gluecron-deploy/1",
498 };
499 if (token) headers["Authorization"] = `Bearer ${token}`;
500
501 const body = JSON.stringify({
502 event: "push",
503 commit_sha: commitSha,
504 source: "gluecron",
505 });
506
507 const res = await fetchImpl(webhookUrl, { method: "POST", headers, body });
508
509 if (!res.ok) {
510 const text = await res.text().catch(() => "");
511 throw new Error(`Webhook deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
512 }
513
514 return {}; // webhooks are fire-and-forget — no deploy ID to poll
515}
516
517// ─── Dispatch + polling orchestration ────────────────────────────────────────
518
519/**
520 * Dispatch a single cloud deploy config — creates the DB row, fires the
521 * provider API, then polls in a background async loop until terminal state.
522 *
523 * Never throws — all errors are caught and recorded in the DB row.
524 */
525export async function dispatchCloudDeploy(
526 config: {
527 id: string;
528 repoId: string;
529 provider: string;
530 providerAppId: string;
531 apiTokenEncrypted: string;
532 },
533 commitSha: string,
534 opts: { fetchImpl?: typeof fetch; pollIntervalMs?: number } = {}
535): Promise<void> {
536 const fetchImpl = opts.fetchImpl ?? fetch;
537 const pollIntervalMs = opts.pollIntervalMs ?? 10_000;
538
539 // Decrypt the API token
540 const tokenResult = decryptValue(config.apiTokenEncrypted);
541 if (!tokenResult.ok) {
542 console.warn(`[cloud-deploy] cannot decrypt token for config ${config.id}: ${tokenResult.error}`);
543 return;
544 }
545 const apiToken = tokenResult.plaintext;
546
547 // Create the deployment row
548 let deployRowId = "";
549 try {
550 const [row] = await db
551 .insert(cloudDeployments)
552 .values({
553 configId: config.id,
554 repoId: config.repoId,
555 commitSha,
556 status: "pending",
557 })
558 .returning({ id: cloudDeployments.id });
559 deployRowId = row?.id || "";
560 } catch (err) {
561 console.warn("[cloud-deploy] failed to create deployment row:", err);
562 return;
563 }
564
565 const updateRow = async (patch: Partial<{
566 status: string;
567 providerDeployId: string | null;
568 logUrl: string | null;
569 deployUrl: string | null;
570 errorMessage: string | null;
571 completedAt: Date | null;
572 durationMs: number | null;
573 }>) => {
574 try {
575 await db
576 .update(cloudDeployments)
577 // eslint-disable-next-line @typescript-eslint/no-explicit-any
578 .set(patch as any)
579 .where(eq(cloudDeployments.id, deployRowId));
580 } catch {
581 /* ignore */
582 }
583 };
584
585 const startedAt = Date.now();
586
587 // Mark as running
588 await updateRow({ status: "running" });
589
590 let result: DeployResult = {};
591 let providerError = "";
592
593 try {
594 switch (config.provider) {
595 case "fly":
596 result = await deployToFly(config.providerAppId, apiToken, commitSha, fetchImpl);
597 break;
598 case "railway":
599 result = await deployToRailway(config.providerAppId, apiToken, commitSha, fetchImpl);
600 break;
601 case "render":
602 result = await deployToRender(config.providerAppId, apiToken, fetchImpl);
603 break;
604 case "vercel":
605 result = await deployToVercel(config.providerAppId, apiToken, commitSha, fetchImpl);
606 break;
607 case "netlify":
608 result = await deployToNetlify(config.providerAppId, apiToken, fetchImpl);
609 break;
610 case "webhook":
611 result = await deployViaWebhook(config.providerAppId, apiToken, commitSha, fetchImpl);
612 break;
613 default:
614 throw new Error(`Unknown provider: ${config.provider}`);
615 }
616 } catch (err) {
617 providerError = err instanceof Error ? err.message : String(err);
618 console.warn(`[cloud-deploy] ${config.provider} trigger error:`, providerError);
619 await updateRow({
620 status: "failed",
621 errorMessage: providerError,
622 completedAt: new Date(),
623 durationMs: Date.now() - startedAt,
624 });
625 return;
626 }
627
628 // Update with initial deploy info
629 await updateRow({
630 providerDeployId: result.providerDeployId ?? null,
631 logUrl: result.logUrl ?? null,
632 deployUrl: result.deployUrl ?? null,
633 });
634
635 // For webhooks or when we have no deploy ID, mark success immediately
636 if (!result.providerDeployId || config.provider === "webhook") {
637 await updateRow({
638 status: "success",
639 completedAt: new Date(),
640 durationMs: Date.now() - startedAt,
641 });
642 console.log(`[cloud-deploy] ${config.provider} ${config.providerAppId}: delivered (no polling)`);
643 return;
644 }
645
646 // Poll until terminal state (max 10 minutes)
647 const maxPolls = Math.floor(600_000 / pollIntervalMs);
648 let polls = 0;
649 let finalStatus = "running";
650
651 while (polls < maxPolls) {
652 await new Promise((r) => setTimeout(r, pollIntervalMs));
653 polls++;
654
655 try {
656 switch (config.provider) {
657 case "fly":
658 finalStatus = await pollFlyStatus(
659 config.providerAppId,
660 result.providerDeployId,
661 apiToken,
662 fetchImpl
663 );
664 break;
665 case "railway":
666 finalStatus = await pollRailwayStatus(
667 result.providerDeployId,
668 apiToken,
669 fetchImpl
670 );
671 break;
672 case "render":
673 finalStatus = await pollRenderStatus(
674 config.providerAppId,
675 result.providerDeployId,
676 apiToken,
677 fetchImpl
678 );
679 break;
680 case "vercel":
681 finalStatus = await pollVercelStatus(
682 result.providerDeployId,
683 apiToken,
684 fetchImpl
685 );
686 break;
687 case "netlify":
688 finalStatus = await pollNetlifyStatus(
689 config.providerAppId,
690 result.providerDeployId,
691 apiToken,
692 fetchImpl
693 );
694 break;
695 default:
696 finalStatus = "success";
697 }
698 } catch {
699 /* poll error — keep retrying */
700 }
701
702 if (finalStatus === "success" || finalStatus === "failed") {
703 break;
704 }
705 }
706
707 // If we exhausted polls without terminal state, mark failed
708 if (finalStatus !== "success" && finalStatus !== "failed") {
709 finalStatus = "failed";
710 providerError = "Timed out waiting for deployment to complete";
711 }
712
713 await updateRow({
714 status: finalStatus,
715 errorMessage: finalStatus === "failed" && !providerError ? "Deploy failed" : providerError || null,
716 completedAt: new Date(),
717 durationMs: Date.now() - startedAt,
718 });
719
720 console.log(
721 `[cloud-deploy] ${config.provider} ${config.providerAppId}@${commitSha.slice(0, 7)}: ${finalStatus} (${Math.round((Date.now() - startedAt) / 1000)}s)`
722 );
723}
724
725// ─── Post-receive integration ─────────────────────────────────────────────────
726
727interface PushRef {
728 oldSha: string;
729 newSha: string;
730 refName: string;
731}
732
733/**
734 * Called from post-receive. Looks up cloud_deploy_configs for this repo
735 * and fires a deploy for every config whose trigger_branch matches a pushed ref.
736 * Runs all matching deploys in parallel. Never throws.
737 */
738export async function fireCloudDeploys(
739 owner: string,
740 repoName: string,
741 refs: PushRef[]
742): Promise<void> {
743 const liveRefs = refs.filter(
744 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
745 );
746 if (liveRefs.length === 0) return;
747
748 // Resolve repo ID
749 let repoId = "";
750 try {
751 const [row] = await db
752 .select({ id: repositories.id })
753 .from(repositories)
754 .innerJoin(users, eq(repositories.ownerId, users.id))
755 .where(and(eq(users.username, owner), eq(repositories.name, repoName)))
756 .limit(1);
757 repoId = row?.id || "";
758 } catch {
759 return;
760 }
761 if (!repoId) return;
762
763 // Load all enabled configs for this repo
764 let configs: Array<{
765 id: string;
766 repoId: string;
767 provider: string;
768 providerAppId: string;
769 apiTokenEncrypted: string;
770 triggerBranch: string;
771 }> = [];
772 try {
773 configs = await db
774 .select({
775 id: cloudDeployConfigs.id,
776 repoId: cloudDeployConfigs.repoId,
777 provider: cloudDeployConfigs.provider,
778 providerAppId: cloudDeployConfigs.providerAppId,
779 apiTokenEncrypted: cloudDeployConfigs.apiTokenEncrypted,
780 triggerBranch: cloudDeployConfigs.triggerBranch,
781 })
782 .from(cloudDeployConfigs)
783 .where(eq(cloudDeployConfigs.repoId, repoId));
784 configs = configs.filter((c) => (c as any).enabled !== false);
785 } catch {
786 return;
787 }
788
789 if (!configs.length) return;
790
791 // Match pushed branches to configs
792 const dispatches: Array<Promise<void>> = [];
793 for (const ref of liveRefs) {
794 const branch = ref.refName.replace("refs/heads/", "");
795 for (const cfg of configs) {
796 if (cfg.triggerBranch === branch) {
797 dispatches.push(
798 dispatchCloudDeploy(cfg, ref.newSha).catch((err) =>
799 console.warn(`[cloud-deploy] dispatch error for config ${cfg.id}:`, err)
800 )
801 );
802 }
803 }
804 }
805
806 if (dispatches.length > 0) {
807 await Promise.all(dispatches);
808 }
809}