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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
cloud-deploy.ts25.4 KB · 809 lines
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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
/**
 * Multi-cloud deploy integration (migration 0077).
 *
 * Supports push-triggered deploys to:
 *   - Fly.io     — Machines API deploy trigger
 *   - Railway    — GraphQL deploymentTrigger mutation
 *   - Render     — REST API POST /v1/services/:id/deploys
 *   - Vercel     — REST API POST /v13/deployments (git-source)
 *   - Netlify    — REST API POST /v1/sites/:id/builds
 *   - webhook    — Generic POST (covers Coolify, CapRover, Dokku, etc.)
 *
 * Each provider function returns a {deployId, logUrl?, deployUrl?} on
 * success or throws on hard error. Background polling updates the DB row
 * status every ~10s until a terminal state is reached.
 *
 * Token storage: API tokens are AES-256-GCM encrypted in the DB via
 * `server-targets-crypto.ts` (same key: SERVER_TARGETS_KEY).
 */

import { eq, and } from "drizzle-orm";
import { db } from "../db";
import { cloudDeployConfigs, cloudDeployments, repositories, users } from "../db/schema";
import { decryptValue } from "./server-targets-crypto";

// ─── Provider types ───────────────────────────────────────────────────────────

export type CloudProvider =
  | "fly"
  | "railway"
  | "render"
  | "vercel"
  | "netlify"
  | "webhook";

interface DeployResult {
  providerDeployId?: string;
  logUrl?: string;
  deployUrl?: string;
}

// ─── Fly.io ──────────────────────────────────────────────────────────────────

/**
 * Trigger a Fly.io deployment via the Fly Machines REST API.
 *
 * Uses the "create a new machine that immediately exits" approach:
 * creates a temp machine from the fly-builder image which triggers Fly's
 * built-in build + release pipeline. For most users the simpler approach is
 * to use flyctl, but we invoke the Machines API so we don't need the binary.
 *
 * Fly deploy token: generate with `flyctl tokens create deploy -a <app>`
 * and store encrypted in cloud_deploy_configs.api_token_encrypted.
 *
 * The appName is the Fly app name (e.g. "my-app").
 */
export async function deployToFly(
  appName: string,
  token: string,
  commitSha: string,
  fetchImpl: typeof fetch = fetch
): Promise<DeployResult> {
  // POST to the Fly Machines API to create a one-shot deploy machine.
  // The machine runs `fly deploy` logic internally when provisioned with
  // a deploy token and app name — effectively a remote flyctl.
  const url = `https://api.machines.dev/v1/apps/${encodeURIComponent(appName)}/machines`;

  // For Fly's Deploy-via-API pattern: hit the `releases` endpoint instead.
  // This is equivalent to what the Fly dashboard does when you click "Redeploy".
  // We signal "deploy HEAD" by triggering a new release from the current image.
  const releaseUrl = `https://api.fly.io/v1/apps/${encodeURIComponent(appName)}/releases`;
  const body = JSON.stringify({
    image: null, // null = re-deploy the latest image
    strategy: "rolling",
    commit_message: `gluecron deploy @ ${commitSha.slice(0, 7)}`,
  });

  const res = await fetchImpl(releaseUrl, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body,
  });

  if (!res.ok) {
    const text = await res.text().catch(() => "");
    throw new Error(`Fly deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
  }

  let data: Record<string, unknown> = {};
  try {
    data = (await res.json()) as Record<string, unknown>;
  } catch {
    /* ignore non-JSON bodies */
  }

  const releaseId = String(data.id || data.release_id || "");
  const releaseVersion = data.version !== undefined ? String(data.version) : "";
  const logUrl = releaseId
    ? `https://fly.io/apps/${encodeURIComponent(appName)}/monitoring?release=${releaseId}`
    : `https://fly.io/apps/${encodeURIComponent(appName)}/monitoring`;

  return {
    providerDeployId: releaseId || releaseVersion || undefined,
    logUrl,
    deployUrl: `https://${appName}.fly.dev`,
  };
}

/**
 * Poll Fly.io release status.
 * Returns "success" | "failed" | "running" | "pending".
 */
export async function pollFlyStatus(
  appName: string,
  releaseId: string,
  token: string,
  fetchImpl: typeof fetch = fetch
): Promise<string> {
  try {
    const url = `https://api.fly.io/v1/apps/${encodeURIComponent(appName)}/releases/${encodeURIComponent(releaseId)}`;
    const res = await fetchImpl(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!res.ok) return "running"; // assume still running on transient error
    const data = (await res.json()) as Record<string, unknown>;
    const status = String(data.status || "").toLowerCase();
    if (status === "complete" || status === "succeeded") return "success";
    if (status === "failed" || status === "error" || status === "cancelled") return "failed";
    return "running";
  } catch {
    return "running";
  }
}

// ─── Railway ─────────────────────────────────────────────────────────────────

/**
 * Trigger a Railway service redeployment via their GraphQL API.
 *
 * Railway API token: https://railway.app/account/tokens
 * serviceId: from the Railway dashboard URL (Settings > General).
 */
export async function deployToRailway(
  serviceId: string,
  token: string,
  commitSha: string,
  fetchImpl: typeof fetch = fetch
): Promise<DeployResult> {
  const query = `
    mutation ServiceInstanceRedeploy($serviceId: String!) {
      serviceInstanceRedeploy(input: { serviceId: $serviceId })
    }
  `;

  const res = await fetchImpl("https://backboard.railway.app/graphql/v2", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query, variables: { serviceId } }),
  });

  if (!res.ok) {
    const text = await res.text().catch(() => "");
    throw new Error(`Railway deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
  }

  const data = (await res.json()) as {
    data?: { serviceInstanceRedeploy?: string };
    errors?: Array<{ message: string }>;
  };

  if (data.errors?.length) {
    throw new Error(`Railway GraphQL error: ${data.errors[0].message}`);
  }

  const deployId = data.data?.serviceInstanceRedeploy || "";

  return {
    providerDeployId: deployId || undefined,
    logUrl: deployId
      ? `https://railway.app/project/-/service/${serviceId}/logs`
      : undefined,
    deployUrl: undefined, // Railway generates a dynamic URL per project
  };
}

/**
 * Poll Railway deployment status.
 */
export async function pollRailwayStatus(
  deployId: string,
  token: string,
  fetchImpl: typeof fetch = fetch
): Promise<string> {
  try {
    const query = `
      query Deployment($id: String!) {
        deployment(id: $id) { status }
      }
    `;
    const res = await fetchImpl("https://backboard.railway.app/graphql/v2", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ query, variables: { id: deployId } }),
    });
    if (!res.ok) return "running";
    const data = (await res.json()) as {
      data?: { deployment?: { status?: string } };
    };
    const status = (data.data?.deployment?.status || "").toUpperCase();
    if (status === "SUCCESS" || status === "COMPLETE") return "success";
    if (status === "FAILED" || status === "CANCELLED" || status === "CRASHED") return "failed";
    return "running";
  } catch {
    return "running";
  }
}

// ─── Render ──────────────────────────────────────────────────────────────────

/**
 * Trigger a Render service deployment via their REST API.
 *
 * Render API key: https://dashboard.render.com/u/settings
 * serviceId: the service's ID from the Render dashboard URL.
 */
export async function deployToRender(
  serviceId: string,
  token: string,
  fetchImpl: typeof fetch = fetch
): Promise<DeployResult> {
  const res = await fetchImpl(
    `https://api.render.com/v1/services/${encodeURIComponent(serviceId)}/deploys`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify({ clearCache: "do_not_clear" }),
    }
  );

  if (!res.ok) {
    const text = await res.text().catch(() => "");
    throw new Error(`Render deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
  }

  const data = (await res.json()) as {
    id?: string;
    deploy?: { id?: string; status?: string; url?: string };
  };
  const deployId = data.deploy?.id || data.id || "";

  return {
    providerDeployId: deployId || undefined,
    logUrl: deployId
      ? `https://dashboard.render.com/web/${serviceId}/deploys/${deployId}`
      : undefined,
    deployUrl: undefined, // returned in service details, not deploy
  };
}

/**
 * Poll Render deployment status.
 */
export async function pollRenderStatus(
  serviceId: string,
  deployId: string,
  token: string,
  fetchImpl: typeof fetch = fetch
): Promise<string> {
  try {
    const res = await fetchImpl(
      `https://api.render.com/v1/services/${encodeURIComponent(serviceId)}/deploys/${encodeURIComponent(deployId)}`,
      {
        headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
      }
    );
    if (!res.ok) return "running";
    const data = (await res.json()) as { deploy?: { status?: string } };
    const status = (data.deploy?.status || "").toLowerCase();
    if (status === "live") return "success";
    if (status === "failed" || status === "canceled" || status === "deactivated") return "failed";
    return "running";
  } catch {
    return "running";
  }
}

// ─── Vercel ──────────────────────────────────────────────────────────────────

/**
 * Trigger a Vercel deployment via their REST API.
 *
 * Vercel token: https://vercel.com/account/tokens
 * projectId: from Vercel project settings.
 *
 * Note: this creates a "forced" redeploy of the latest successful deployment,
 * since we're not pushing to a Vercel-connected Git repo. For full Git
 * integration, users should connect their Gluecron repo to Vercel via webhook.
 */
export async function deployToVercel(
  projectId: string,
  token: string,
  commitSha: string,
  fetchImpl: typeof fetch = fetch
): Promise<DeployResult> {
  // Redeploy the latest deployment of the project
  const res = await fetchImpl(
    `https://api.vercel.com/v13/deployments`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        name: projectId,
        target: "production",
        meta: {
          githubCommitSha: commitSha,
          source: "gluecron",
        },
        // Trigger a redeploy — Vercel will use the latest build config
        forceNew: 0,
      }),
    }
  );

  if (!res.ok) {
    const text = await res.text().catch(() => "");
    // 400 with "no deployments" means we need to check for existing deployment
    if (res.status === 400) {
      // Try the redeploy endpoint instead
      const searchRes = await fetchImpl(
        `https://api.vercel.com/v6/deployments?projectId=${encodeURIComponent(projectId)}&limit=1&target=production`,
        { headers: { Authorization: `Bearer ${token}` } }
      );
      if (searchRes.ok) {
        const searchData = (await searchRes.json()) as {
          deployments?: Array<{ uid?: string; url?: string }>;
        };
        const latest = searchData.deployments?.[0];
        if (latest?.uid) {
          const redeployRes = await fetchImpl(
            `https://api.vercel.com/v13/deployments?forceNew=1`,
            {
              method: "POST",
              headers: {
                Authorization: `Bearer ${token}`,
                "Content-Type": "application/json",
              },
              body: JSON.stringify({ deploymentId: latest.uid }),
            }
          );
          if (redeployRes.ok) {
            const data = (await redeployRes.json()) as { id?: string; url?: string };
            return {
              providerDeployId: data.id || undefined,
              logUrl: data.id ? `https://vercel.com/deployments/${data.id}` : undefined,
              deployUrl: data.url ? `https://${data.url}` : undefined,
            };
          }
        }
      }
    }
    throw new Error(`Vercel deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
  }

  const data = (await res.json()) as { id?: string; url?: string; readyState?: string };
  return {
    providerDeployId: data.id || undefined,
    logUrl: data.id ? `https://vercel.com/deployments/${data.id}` : undefined,
    deployUrl: data.url ? `https://${data.url}` : undefined,
  };
}

/**
 * Poll Vercel deployment status.
 */
export async function pollVercelStatus(
  deployId: string,
  token: string,
  fetchImpl: typeof fetch = fetch
): Promise<string> {
  try {
    const res = await fetchImpl(
      `https://api.vercel.com/v13/deployments/${encodeURIComponent(deployId)}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    if (!res.ok) return "running";
    const data = (await res.json()) as { readyState?: string; state?: string };
    const state = (data.readyState || data.state || "").toUpperCase();
    if (state === "READY") return "success";
    if (state === "ERROR" || state === "CANCELED" || state === "FAILED") return "failed";
    return "running";
  } catch {
    return "running";
  }
}

// ─── Netlify ─────────────────────────────────────────────────────────────────

/**
 * Trigger a Netlify site build via their REST API.
 *
 * Netlify token: https://app.netlify.com/user/applications
 * providerAppId: the Netlify site ID.
 */
export async function deployToNetlify(
  siteId: string,
  token: string,
  fetchImpl: typeof fetch = fetch
): Promise<DeployResult> {
  const res = await fetchImpl(
    `https://api.netlify.com/api/v1/sites/${encodeURIComponent(siteId)}/builds`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({}),
    }
  );

  if (!res.ok) {
    const text = await res.text().catch(() => "");
    throw new Error(`Netlify deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
  }

  const data = (await res.json()) as { id?: string; deploy?: { id?: string; deploy_url?: string } };
  const deployId = data.id || data.deploy?.id || "";
  const deployUrl = data.deploy?.deploy_url || "";

  return {
    providerDeployId: deployId || undefined,
    logUrl: deployId
      ? `https://app.netlify.com/sites/${siteId}/deploys/${deployId}`
      : undefined,
    deployUrl: deployUrl || undefined,
  };
}

/**
 * Poll Netlify build status.
 */
export async function pollNetlifyStatus(
  siteId: string,
  buildId: string,
  token: string,
  fetchImpl: typeof fetch = fetch
): Promise<string> {
  try {
    const res = await fetchImpl(
      `https://api.netlify.com/api/v1/sites/${encodeURIComponent(siteId)}/builds/${encodeURIComponent(buildId)}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    if (!res.ok) return "running";
    const data = (await res.json()) as { state?: string };
    const state = (data.state || "").toLowerCase();
    if (state === "ready") return "success";
    if (state === "error" || state === "cancelled") return "failed";
    return "running";
  } catch {
    return "running";
  }
}

// ─── Generic webhook ─────────────────────────────────────────────────────────

/**
 * Fire a generic deploy webhook — covers Coolify, CapRover, Dokku, etc.
 *
 * providerAppId = the full webhook URL.
 * token = optional HMAC secret or Bearer token (sent as Authorization header if set).
 *
 * POSTs JSON: { event: "push", commit_sha: "...", source: "gluecron" }
 */
export async function deployViaWebhook(
  webhookUrl: string,
  token: string,
  commitSha: string,
  fetchImpl: typeof fetch = fetch
): Promise<DeployResult> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    "User-Agent": "gluecron-deploy/1",
  };
  if (token) headers["Authorization"] = `Bearer ${token}`;

  const body = JSON.stringify({
    event: "push",
    commit_sha: commitSha,
    source: "gluecron",
  });

  const res = await fetchImpl(webhookUrl, { method: "POST", headers, body });

  if (!res.ok) {
    const text = await res.text().catch(() => "");
    throw new Error(`Webhook deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
  }

  return {}; // webhooks are fire-and-forget — no deploy ID to poll
}

// ─── Dispatch + polling orchestration ────────────────────────────────────────

/**
 * Dispatch a single cloud deploy config — creates the DB row, fires the
 * provider API, then polls in a background async loop until terminal state.
 *
 * Never throws — all errors are caught and recorded in the DB row.
 */
export async function dispatchCloudDeploy(
  config: {
    id: string;
    repoId: string;
    provider: string;
    providerAppId: string;
    apiTokenEncrypted: string;
  },
  commitSha: string,
  opts: { fetchImpl?: typeof fetch; pollIntervalMs?: number } = {}
): Promise<void> {
  const fetchImpl = opts.fetchImpl ?? fetch;
  const pollIntervalMs = opts.pollIntervalMs ?? 10_000;

  // Decrypt the API token
  const tokenResult = decryptValue(config.apiTokenEncrypted);
  if (!tokenResult.ok) {
    console.warn(`[cloud-deploy] cannot decrypt token for config ${config.id}: ${tokenResult.error}`);
    return;
  }
  const apiToken = tokenResult.plaintext;

  // Create the deployment row
  let deployRowId = "";
  try {
    const [row] = await db
      .insert(cloudDeployments)
      .values({
        configId: config.id,
        repoId: config.repoId,
        commitSha,
        status: "pending",
      })
      .returning({ id: cloudDeployments.id });
    deployRowId = row?.id || "";
  } catch (err) {
    console.warn("[cloud-deploy] failed to create deployment row:", err);
    return;
  }

  const updateRow = async (patch: Partial<{
    status: string;
    providerDeployId: string | null;
    logUrl: string | null;
    deployUrl: string | null;
    errorMessage: string | null;
    completedAt: Date | null;
    durationMs: number | null;
  }>) => {
    try {
      await db
        .update(cloudDeployments)
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        .set(patch as any)
        .where(eq(cloudDeployments.id, deployRowId));
    } catch {
      /* ignore */
    }
  };

  const startedAt = Date.now();

  // Mark as running
  await updateRow({ status: "running" });

  let result: DeployResult = {};
  let providerError = "";

  try {
    switch (config.provider) {
      case "fly":
        result = await deployToFly(config.providerAppId, apiToken, commitSha, fetchImpl);
        break;
      case "railway":
        result = await deployToRailway(config.providerAppId, apiToken, commitSha, fetchImpl);
        break;
      case "render":
        result = await deployToRender(config.providerAppId, apiToken, fetchImpl);
        break;
      case "vercel":
        result = await deployToVercel(config.providerAppId, apiToken, commitSha, fetchImpl);
        break;
      case "netlify":
        result = await deployToNetlify(config.providerAppId, apiToken, fetchImpl);
        break;
      case "webhook":
        result = await deployViaWebhook(config.providerAppId, apiToken, commitSha, fetchImpl);
        break;
      default:
        throw new Error(`Unknown provider: ${config.provider}`);
    }
  } catch (err) {
    providerError = err instanceof Error ? err.message : String(err);
    console.warn(`[cloud-deploy] ${config.provider} trigger error:`, providerError);
    await updateRow({
      status: "failed",
      errorMessage: providerError,
      completedAt: new Date(),
      durationMs: Date.now() - startedAt,
    });
    return;
  }

  // Update with initial deploy info
  await updateRow({
    providerDeployId: result.providerDeployId ?? null,
    logUrl: result.logUrl ?? null,
    deployUrl: result.deployUrl ?? null,
  });

  // For webhooks or when we have no deploy ID, mark success immediately
  if (!result.providerDeployId || config.provider === "webhook") {
    await updateRow({
      status: "success",
      completedAt: new Date(),
      durationMs: Date.now() - startedAt,
    });
    console.log(`[cloud-deploy] ${config.provider} ${config.providerAppId}: delivered (no polling)`);
    return;
  }

  // Poll until terminal state (max 10 minutes)
  const maxPolls = Math.floor(600_000 / pollIntervalMs);
  let polls = 0;
  let finalStatus = "running";

  while (polls < maxPolls) {
    await new Promise((r) => setTimeout(r, pollIntervalMs));
    polls++;

    try {
      switch (config.provider) {
        case "fly":
          finalStatus = await pollFlyStatus(
            config.providerAppId,
            result.providerDeployId,
            apiToken,
            fetchImpl
          );
          break;
        case "railway":
          finalStatus = await pollRailwayStatus(
            result.providerDeployId,
            apiToken,
            fetchImpl
          );
          break;
        case "render":
          finalStatus = await pollRenderStatus(
            config.providerAppId,
            result.providerDeployId,
            apiToken,
            fetchImpl
          );
          break;
        case "vercel":
          finalStatus = await pollVercelStatus(
            result.providerDeployId,
            apiToken,
            fetchImpl
          );
          break;
        case "netlify":
          finalStatus = await pollNetlifyStatus(
            config.providerAppId,
            result.providerDeployId,
            apiToken,
            fetchImpl
          );
          break;
        default:
          finalStatus = "success";
      }
    } catch {
      /* poll error — keep retrying */
    }

    if (finalStatus === "success" || finalStatus === "failed") {
      break;
    }
  }

  // If we exhausted polls without terminal state, mark failed
  if (finalStatus !== "success" && finalStatus !== "failed") {
    finalStatus = "failed";
    providerError = "Timed out waiting for deployment to complete";
  }

  await updateRow({
    status: finalStatus,
    errorMessage: finalStatus === "failed" && !providerError ? "Deploy failed" : providerError || null,
    completedAt: new Date(),
    durationMs: Date.now() - startedAt,
  });

  console.log(
    `[cloud-deploy] ${config.provider} ${config.providerAppId}@${commitSha.slice(0, 7)}: ${finalStatus} (${Math.round((Date.now() - startedAt) / 1000)}s)`
  );
}

// ─── Post-receive integration ─────────────────────────────────────────────────

interface PushRef {
  oldSha: string;
  newSha: string;
  refName: string;
}

/**
 * Called from post-receive. Looks up cloud_deploy_configs for this repo
 * and fires a deploy for every config whose trigger_branch matches a pushed ref.
 * Runs all matching deploys in parallel. Never throws.
 */
export async function fireCloudDeploys(
  owner: string,
  repoName: string,
  refs: PushRef[]
): Promise<void> {
  const liveRefs = refs.filter(
    (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
  );
  if (liveRefs.length === 0) return;

  // Resolve repo ID
  let repoId = "";
  try {
    const [row] = await db
      .select({ id: repositories.id })
      .from(repositories)
      .innerJoin(users, eq(repositories.ownerId, users.id))
      .where(and(eq(users.username, owner), eq(repositories.name, repoName)))
      .limit(1);
    repoId = row?.id || "";
  } catch {
    return;
  }
  if (!repoId) return;

  // Load all enabled configs for this repo
  let configs: Array<{
    id: string;
    repoId: string;
    provider: string;
    providerAppId: string;
    apiTokenEncrypted: string;
    triggerBranch: string;
  }> = [];
  try {
    configs = await db
      .select({
        id: cloudDeployConfigs.id,
        repoId: cloudDeployConfigs.repoId,
        provider: cloudDeployConfigs.provider,
        providerAppId: cloudDeployConfigs.providerAppId,
        apiTokenEncrypted: cloudDeployConfigs.apiTokenEncrypted,
        triggerBranch: cloudDeployConfigs.triggerBranch,
      })
      .from(cloudDeployConfigs)
      .where(eq(cloudDeployConfigs.repoId, repoId));
    configs = configs.filter((c) => (c as any).enabled !== false);
  } catch {
    return;
  }

  if (!configs.length) return;

  // Match pushed branches to configs
  const dispatches: Array<Promise<void>> = [];
  for (const ref of liveRefs) {
    const branch = ref.refName.replace("refs/heads/", "");
    for (const cfg of configs) {
      if (cfg.triggerBranch === branch) {
        dispatches.push(
          dispatchCloudDeploy(cfg, ref.newSha).catch((err) =>
            console.warn(`[cloud-deploy] dispatch error for config ${cfg.id}:`, err)
          )
        );
      }
    }
  }

  if (dispatches.length > 0) {
    await Promise.all(dispatches);
  }
}