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.
| c9ed210 | 1 | /** |
| 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 | ||
| 20 | import { eq, and } from "drizzle-orm"; | |
| 21 | import { db } from "../db"; | |
| 22 | import { cloudDeployConfigs, cloudDeployments, repositories, users } from "../db/schema"; | |
| 23 | import { decryptValue } from "./server-targets-crypto"; | |
| 24 | ||
| 25 | // ─── Provider types ─────────────────────────────────────────────────────────── | |
| 26 | ||
| 27 | export type CloudProvider = | |
| 28 | | "fly" | |
| 29 | | "railway" | |
| 30 | | "render" | |
| 31 | | "vercel" | |
| 32 | | "netlify" | |
| 33 | | "webhook"; | |
| 34 | ||
| 35 | interface 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 | */ | |
| 56 | export 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 | */ | |
| 115 | export 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 | */ | |
| 145 | export 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 | */ | |
| 194 | export 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 | */ | |
| 234 | export 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 | */ | |
| 275 | export 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 | */ | |
| 311 | export 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 | */ | |
| 390 | export 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 | */ | |
| 419 | export 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 | */ | |
| 457 | export 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 | */ | |
| 489 | export 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 | */ | |
| 525 | export 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 | ||
| 727 | interface 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 | */ | |
| 738 | export 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 | } |