Commitc9ed210unknown_key
feat: multi-cloud deploy integration — push to main deploys to Fly/Railway/Render/Vercel
feat: multi-cloud deploy integration — push to main deploys to Fly/Railway/Render/Vercel Adds push-triggered cloud deploy support to Gluecron. Repo owners configure one or more integrations (Fly.io, Railway, Render, Vercel, Netlify, or a generic webhook) and every push to the configured branch fires the provider API automatically — no GitHub Actions, no external CI/CD. Key components: - drizzle/0077_cloud_deploys.sql: cloud_deploy_configs + cloud_deployments tables - src/lib/cloud-deploy.ts: per-provider deploy + status-poll logic; fireCloudDeploys() for post-receive integration - src/hooks/post-receive.ts: step 5c calls fireCloudDeploys() fire-and-forget - src/routes/deployments.tsx: /settings/deployments config UI, /cloud-deployments history list, cancel endpoint - src/db/schema.ts: cloudDeployConfigs + cloudDeployments Drizzle tables - src/views/components.tsx: "Deployments" nav link + union type https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
6 files changed+1517−1c9ed2109f1fbbc308d6211a06dc6499b943dbb51
6 changed files+1517−1
Addeddrizzle/0095_cloud_deploys.sql+32−0View fileUnifiedSplit
@@ -0,0 +1,32 @@
1-- Migration 0077: Multi-cloud deploy integration
2-- Adds cloud_deploy_configs and cloud_deployments tables for push-triggered
3-- deploys to Fly.io, Railway, Render, Vercel, Netlify, and generic webhooks.
4
5CREATE TABLE IF NOT EXISTS cloud_deploy_configs (
6 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
8 provider text NOT NULL, -- 'fly' | 'railway' | 'render' | 'vercel' | 'netlify' | 'webhook'
9 provider_app_id text NOT NULL, -- Fly app name, Railway service ID, Render service ID, Vercel project ID, webhook URL
10 api_token_encrypted text NOT NULL, -- AES-256-GCM encrypted via SERVER_TARGETS_KEY
11 trigger_branch text NOT NULL DEFAULT 'main',
12 enabled boolean NOT NULL DEFAULT true,
13 created_at timestamp DEFAULT now()
14);
15
16CREATE TABLE IF NOT EXISTS cloud_deployments (
17 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
18 config_id uuid NOT NULL REFERENCES cloud_deploy_configs(id) ON DELETE CASCADE,
19 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
20 commit_sha text NOT NULL,
21 status text NOT NULL DEFAULT 'pending', -- pending | running | success | failed | cancelled
22 provider_deploy_id text, -- provider's deployment ID for tracking
23 log_url text,
24 deploy_url text, -- live URL after success
25 error_message text,
26 started_at timestamp DEFAULT now(),
27 completed_at timestamp,
28 duration_ms integer
29);
30
31CREATE INDEX IF NOT EXISTS cloud_deployments_repo ON cloud_deployments(repo_id, started_at DESC);
32CREATE INDEX IF NOT EXISTS cloud_deployments_config ON cloud_deployments(config_id, started_at DESC);
Modifiedsrc/db/schema.ts+63−0View fileUnifiedSplit
@@ -4408,3 +4408,66 @@ export type NewIncidentHookConfig = typeof incidentHookConfigs.$inferInsert;
44084408
44094409
44104410>>>>>>> 3a845e4 (feat: enterprise SSO (SAML 2.0 + OIDC) and SCIM user provisioning)
4411
4412/**
4413 * Cloud deploy configurations — per-repo settings for push-triggered deploys
4414 * to Fly.io, Railway, Render, Vercel, Netlify, or a generic webhook URL.
4415 * Migration 0077.
4416 */
4417export const cloudDeployConfigs = pgTable(
4418 "cloud_deploy_configs",
4419 {
4420 id: uuid("id").primaryKey().defaultRandom(),
4421 repoId: uuid("repo_id")
4422 .notNull()
4423 .references(() => repositories.id, { onDelete: "cascade" }),
4424 // 'fly' | 'railway' | 'render' | 'vercel' | 'netlify' | 'webhook'
4425 provider: text("provider").notNull(),
4426 // Fly app name, Railway service ID, Render service ID, Vercel project ID, webhook URL
4427 providerAppId: text("provider_app_id").notNull(),
4428 // AES-256-GCM encrypted via SERVER_TARGETS_KEY
4429 apiTokenEncrypted: text("api_token_encrypted").notNull(),
4430 triggerBranch: text("trigger_branch").notNull().default("main"),
4431 enabled: boolean("enabled").notNull().default(true),
4432 createdAt: timestamp("created_at").defaultNow(),
4433 },
4434 (table) => [index("cloud_deploy_configs_repo").on(table.repoId)]
4435);
4436
4437/**
4438 * Cloud deployment runs — one row per triggered deployment attempt.
4439 * Status transitions: pending -> running -> success | failed | cancelled.
4440 * Migration 0077.
4441 */
4442export const cloudDeployments = pgTable(
4443 "cloud_deployments",
4444 {
4445 id: uuid("id").primaryKey().defaultRandom(),
4446 configId: uuid("config_id")
4447 .notNull()
4448 .references(() => cloudDeployConfigs.id, { onDelete: "cascade" }),
4449 repoId: uuid("repo_id")
4450 .notNull()
4451 .references(() => repositories.id, { onDelete: "cascade" }),
4452 commitSha: text("commit_sha").notNull(),
4453 // pending | running | success | failed | cancelled
4454 status: text("status").notNull().default("pending"),
4455 providerDeployId: text("provider_deploy_id"),
4456 logUrl: text("log_url"),
4457 deployUrl: text("deploy_url"),
4458 errorMessage: text("error_message"),
4459 startedAt: timestamp("started_at").defaultNow(),
4460 completedAt: timestamp("completed_at"),
4461 durationMs: integer("duration_ms"),
4462 },
4463 (table) => [
4464 index("cloud_deployments_repo").on(table.repoId, table.startedAt),
4465 index("cloud_deployments_config").on(table.configId, table.startedAt),
4466 ]
4467);
4468
4469export type CloudDeployConfig = typeof cloudDeployConfigs.$inferSelect;
4470export type NewCloudDeployConfig = typeof cloudDeployConfigs.$inferInsert;
4471export type CloudDeployment = typeof cloudDeployments.$inferSelect;
4472export type NewCloudDeployment = typeof cloudDeployments.$inferInsert;
4473>>>>>>> b11ffa9 (feat: multi-cloud deploy integration — push to main deploys to Fly/Railway/Render/Vercel)
Modifiedsrc/hooks/post-receive.ts+11−0View fileUnifiedSplit
@@ -36,6 +36,7 @@ import {
3636 startDeployRow,
3737} from "../lib/server-target-store";
3838import { deployToTarget } from "../lib/server-targets";
39import { fireCloudDeploys } from "../lib/cloud-deploy";
3940
4041interface PushRef {
4142 oldSha: string;
@@ -213,6 +214,15 @@ export async function onPostReceive(
213214 console.warn("[server-targets] dispatch error:", err)
214215 );
215216
217 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
218 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
219 // webhook for any cloud_deploy_configs rows matching the pushed branch.
220 // Fire-and-forget; all failures are swallowed so the push path is
221 // never blocked.
222 void fireCloudDeploys(owner, repo, refs).catch((err) =>
223 console.warn("[cloud-deploy] dispatch error:", err)
224 );
225
216226 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
217227 // main, fire the local deploy via scripts/self-deploy.sh. The script
218228 // forks into the background, so this call returns immediately (git
@@ -792,4 +802,5 @@ export const __test = {
792802 fireDocDriftCheck,
793803 fireServerTargetDeploys,
794804 fireDependencyScan,
805 fireCloudDeploys,
795806};
Addedsrc/lib/cloud-deploy.ts+809−0View fileUnifiedSplit
@@ -0,0 +1,809 @@
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
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}
Modifiedsrc/routes/deployments.tsx+595−1View fileUnifiedSplit
@@ -24,12 +24,13 @@
2424import { Hono } from "hono";
2525import { desc, eq, and } from "drizzle-orm";
2626import { db } from "../db";
27import { deployments, repositories, users } from "../db/schema";
27import { deployments, repositories, users, cloudDeployConfigs, cloudDeployments } from "../db/schema";
2828import type { AuthEnv } from "../middleware/auth";
2929import { softAuth, requireAuth } from "../middleware/auth";
3030import { Layout } from "../views/layout";
3131import { RepoHeader } from "../views/components";
3232import { onDeployFailure } from "../lib/ai-incident";
33import { encryptValue } from "../lib/server-targets-crypto";
3334
3435const dep = new Hono<AuthEnv>();
3536
@@ -798,4 +799,597 @@ dep.post(
798799 }
799800);
800801
802/* ─────────────────────────────────────────────────────────────────────────────
803 * Cloud deploy integration routes (migration 0077)
804 * ───────────────────────────────────────────────────────────────────────── */
805
806const PROVIDER_LABELS: Record<string, string> = {
807 fly: "Fly.io",
808 railway: "Railway",
809 render: "Render",
810 vercel: "Vercel",
811 netlify: "Netlify",
812 webhook: "Generic Webhook",
813};
814
815const cloudDeploySettingsStyles = `
816 .cds-wrap { max-width: 900px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
817 .cds-head { margin-bottom: var(--space-5); }
818 .cds-title {
819 font-size: clamp(20px,2.8vw,28px); font-family: var(--font-display); font-weight: 800;
820 letter-spacing: -0.022em; margin: 0 0 var(--space-2); color: var(--text-strong);
821 }
822 .cds-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.55; }
823 .cds-card {
824 background: var(--bg-elevated); border: 1px solid var(--border);
825 border-radius: 14px; overflow: hidden; margin-bottom: var(--space-4);
826 }
827 .cds-card-head {
828 padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border);
829 display: flex; align-items: center; justify-content: space-between;
830 background: rgba(255,255,255,0.012);
831 }
832 .cds-card-title { margin: 0; font-size: 14px; font-weight: 700; color: var(--text-strong); }
833 .cds-config-row {
834 display: grid; grid-template-columns: 120px 1fr 120px 80px auto;
835 align-items: center; gap: 12px; padding: 10px var(--space-4);
836 border-top: 1px solid rgba(255,255,255,0.04); font-size: 13px;
837 }
838 .cds-config-row:first-child { border-top: none; }
839 .cds-badge {
840 display: inline-flex; align-items: center; padding: 2px 8px;
841 border-radius: 6px; font-size: 11px; font-weight: 700;
842 background: rgba(140,109,255,0.14); color: #b69dff;
843 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.3);
844 }
845 .cds-mono { font-family: var(--font-mono); font-size: 12px; }
846 .cds-form label { display: block; font-size: 12.5px; font-weight: 600; color: var(--text-muted); margin-bottom: 4px; }
847 .cds-form input, .cds-form select {
848 width: 100%; padding: 8px 10px; background: var(--bg-tertiary);
849 border: 1px solid var(--border); border-radius: 8px; color: var(--text);
850 font-family: inherit; font-size: 13px;
851 }
852 .cds-form input:focus, .cds-form select:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
853 .cds-grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-3); }
854 .cds-btn {
855 display: inline-flex; align-items: center; gap: 6px;
856 padding: 8px 16px; font-size: 13px; font-weight: 600;
857 border-radius: 8px; cursor: pointer; font-family: inherit;
858 border: 1px solid transparent;
859 transition: background 120ms ease, border-color 120ms ease;
860 }
861 .cds-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
862 .cds-btn-primary:hover { filter: brightness(1.1); }
863 .cds-btn-ghost { background: rgba(255,255,255,0.03); border-color: var(--border); color: var(--text); }
864 .cds-btn-ghost:hover { background: rgba(255,255,255,0.06); border-color: var(--border-strong); }
865 .cds-btn-danger { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.35); color: #fca5a5; }
866 .cds-btn-danger:hover { background: rgba(239,68,68,0.2); }
867 .cds-footer { margin-top: var(--space-4); display: flex; justify-content: flex-end; }
868 .cds-empty { padding: var(--space-5); text-align: center; color: var(--text-muted); font-size: 13px; }
869 .cds-status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 700;
870 padding: 2px 8px; border-radius: 9999px; }
871 .cds-status.is-success { background: rgba(52,211,153,0.14); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); }
872 .cds-status.is-failed { background: rgba(248,113,113,0.14); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); }
873 .cds-status.is-running, .cds-status.is-pending { background: rgba(96,165,250,0.14); color: #bfdbfe; box-shadow: inset 0 0 0 1px rgba(96,165,250,0.32); }
874 .cds-status.is-cancelled { background: rgba(107,114,128,0.16); color: #d1d5db; box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32); }
875 .cds-runs { display: flex; flex-direction: column; gap: 0; }
876 .cds-run-row {
877 display: grid; grid-template-columns: 80px 90px 1fr auto auto;
878 align-items: center; gap: 12px; padding: 10px var(--space-4);
879 border-top: 1px solid rgba(255,255,255,0.04); font-size: 13px;
880 }
881 .cds-run-row:first-child { border-top: none; }
882 .cds-run-row:hover { background: rgba(255,255,255,0.02); }
883 .cds-sha { font-family: var(--font-mono); font-size: 12px; background: rgba(255,255,255,0.04);
884 border: 1px solid var(--border); padding: 2px 7px; border-radius: 6px; }
885 .cds-link { color: var(--accent); text-decoration: none; font-size: 12px; }
886 .cds-link:hover { text-decoration: underline; }
887 @keyframes cds-spin { to { transform: rotate(360deg); } }
888 .cds-spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid rgba(96,165,250,0.3);
889 border-top-color: #93c5fd; border-radius: 50%; animation: cds-spin 0.8s linear infinite; }
890`;
891
892function cdStatusClass(status: string): string {
893 switch (status) {
894 case "success":
895 return "cds-status is-success";
896 case "failed":
897 return "cds-status is-failed";
898 case "running":
899 return "cds-status is-running";
900 case "pending":
901 return "cds-status is-pending";
902 case "cancelled":
903 return "cds-status is-cancelled";
904 default:
905 return "cds-status is-cancelled";
906 }
907}
908
909/** GET /:owner/:repo/settings/deployments — cloud deploy config page */
910dep.get("/:owner/:repo/settings/deployments", requireAuth, async (c) => {
911 const { owner, repo } = c.req.param();
912 const user = c.get("user")!;
913 const repoRow = await resolveRepo(owner, repo);
914 if (!repoRow) return c.notFound();
915 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}`);
916
917 let configs: Array<typeof cloudDeployConfigs.$inferSelect> = [];
918 try {
919 configs = await db
920 .select()
921 .from(cloudDeployConfigs)
922 .where(eq(cloudDeployConfigs.repoId, repoRow.id))
923 .orderBy(desc(cloudDeployConfigs.createdAt));
924 } catch {
925 /* ignore */
926 }
927
928 const flash = c.req.query("flash");
929
930 return c.html(
931 <Layout title={`${owner}/${repo} — cloud deploy settings`} user={user}>
932 <RepoHeader owner={owner} repo={repo} />
933 <div class="cds-wrap">
934 <header class="cds-head">
935 <h2 class="cds-title">Cloud Deploy Integrations</h2>
936 <p class="cds-sub">
937 Push to a branch and Gluecron automatically deploys to your cloud
938 provider. Supports Fly.io, Railway, Render, Vercel, Netlify, and any
939 webhook-based platform.
940 </p>
941 </header>
942
943 {flash === "added" && (
944 <div style="background:rgba(52,211,153,0.1);border:1px solid rgba(52,211,153,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#6ee7b7;margin-bottom:var(--space-4);">
945 Integration added successfully.
946 </div>
947 )}
948 {flash === "deleted" && (
949 <div style="background:rgba(248,113,113,0.1);border:1px solid rgba(248,113,113,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;margin-bottom:var(--space-4);">
950 Integration removed.
951 </div>
952 )}
953 {flash === "error" && (
954 <div style="background:rgba(248,113,113,0.1);border:1px solid rgba(248,113,113,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;margin-bottom:var(--space-4);">
955 Error: SERVER_TARGETS_KEY env var is not set. Cloud deploy tokens cannot
956 be encrypted.
957 </div>
958 )}
959
960 <div class="cds-card">
961 <div class="cds-card-head">
962 <h3 class="cds-card-title">Active integrations</h3>
963 </div>
964 {configs.length === 0 ? (
965 <div class="cds-empty">
966 No integrations configured yet. Add one below.
967 </div>
968 ) : (
969 <div style="padding:0;">
970 {configs.map((cfg) => (
971 <div class="cds-config-row">
972 <span class="cds-badge">
973 {PROVIDER_LABELS[cfg.provider] ?? cfg.provider}
974 </span>
975 <span class="cds-mono">{cfg.providerAppId}</span>
976 <span style="font-size:12px;color:var(--text-muted);">
977 branch:{" "}
978 <code class="cds-mono">{cfg.triggerBranch}</code>
979 </span>
980 <span style="font-size:12px;color:var(--text-muted);">
981 {cfg.enabled ? "enabled" : "disabled"}
982 </span>
983 <form
984 method="post"
985 action={`/${owner}/${repo}/settings/deployments/${cfg.id}/delete`}
986 >
987 <button
988 type="submit"
989 class="cds-btn cds-btn-danger"
990 style="padding:4px 10px;font-size:12px;"
991 >
992 Remove
993 </button>
994 </form>
995 </div>
996 ))}
997 </div>
998 )}
999 </div>
1000
1001 <div class="cds-card">
1002 <div class="cds-card-head">
1003 <h3 class="cds-card-title">Add integration</h3>
1004 </div>
1005 <div style="padding:var(--space-4);">
1006 <form
1007 method="post"
1008 action={`/${owner}/${repo}/settings/deployments`}
1009 class="cds-form"
1010 >
1011 <div
1012 class="cds-grid-3"
1013 style="margin-bottom:var(--space-3);"
1014 >
1015 <div>
1016 <label for="provider">Provider</label>
1017 <select id="provider" name="provider" required>
1018 <option value="fly">Fly.io</option>
1019 <option value="railway">Railway</option>
1020 <option value="render">Render</option>
1021 <option value="vercel">Vercel</option>
1022 <option value="netlify">Netlify</option>
1023 <option value="webhook">Generic Webhook</option>
1024 </select>
1025 </div>
1026 <div>
1027 <label for="provider_app_id">
1028 App / Service ID (or webhook URL)
1029 </label>
1030 <input
1031 type="text"
1032 id="provider_app_id"
1033 name="provider_app_id"
1034 placeholder="e.g. my-app, srv-xxx, webhook URL"
1035 required
1036 />
1037 </div>
1038 <div>
1039 <label for="trigger_branch">Trigger branch</label>
1040 <input
1041 type="text"
1042 id="trigger_branch"
1043 name="trigger_branch"
1044 placeholder="main"
1045 value="main"
1046 required
1047 />
1048 </div>
1049 </div>
1050 <div style="margin-bottom:var(--space-4);">
1051 <label for="api_token">API token (stored encrypted)</label>
1052 <input
1053 type="password"
1054 id="api_token"
1055 name="api_token"
1056 placeholder="Paste your provider API token"
1057 required
1058 />
1059 <p style="font-size:11.5px;color:var(--text-muted);margin:4px 0 0;">
1060 Token is encrypted with AES-256-GCM before storing. Never
1061 logged.
1062 </p>
1063 </div>
1064 <div class="cds-footer">
1065 <button type="submit" class="cds-btn cds-btn-primary">
1066 Add integration
1067 </button>
1068 </div>
1069 </form>
1070 </div>
1071 </div>
1072
1073 <div style="text-align:center;margin-top:var(--space-3);">
1074 <a
1075 href={`/${owner}/${repo}/cloud-deployments`}
1076 class="cds-link"
1077 >
1078 View deployment history →
1079 </a>
1080 </div>
1081 </div>
1082 <style dangerouslySetInnerHTML={{ __html: cloudDeploySettingsStyles }} />
1083 </Layout>
1084 );
1085});
1086
1087/** POST /:owner/:repo/settings/deployments — add new cloud deploy config */
1088dep.post(
1089 "/:owner/:repo/settings/deployments",
1090 requireAuth,
1091 async (c) => {
1092 const { owner, repo } = c.req.param();
1093 const user = c.get("user")!;
1094 const repoRow = await resolveRepo(owner, repo);
1095 const back = `/${owner}/${repo}/settings/deployments`;
1096 if (!repoRow) return c.notFound();
1097 if (repoRow.ownerId !== user.id) return c.redirect(back);
1098
1099 const form = await c.req.formData();
1100 const provider = String(form.get("provider") || "").trim();
1101 const providerAppId = String(form.get("provider_app_id") || "").trim();
1102 const triggerBranch = String(
1103 form.get("trigger_branch") || "main"
1104 ).trim();
1105 const apiToken = String(form.get("api_token") || "").trim();
1106
1107 const validProviders = [
1108 "fly",
1109 "railway",
1110 "render",
1111 "vercel",
1112 "netlify",
1113 "webhook",
1114 ];
1115 if (
1116 !validProviders.includes(provider) ||
1117 !providerAppId ||
1118 !triggerBranch ||
1119 !apiToken
1120 ) {
1121 return c.redirect(`${back}?flash=error`);
1122 }
1123
1124 const encResult = encryptValue(apiToken);
1125 if (!encResult.ok) {
1126 return c.redirect(`${back}?flash=error`);
1127 }
1128
1129 try {
1130 await db.insert(cloudDeployConfigs).values({
1131 repoId: repoRow.id,
1132 provider,
1133 providerAppId,
1134 apiTokenEncrypted: encResult.ciphertext,
1135 triggerBranch,
1136 enabled: true,
1137 });
1138 } catch (err) {
1139 console.error("[cloud-deploy] insert config:", err);
1140 return c.redirect(`${back}?flash=error`);
1141 }
1142
1143 return c.redirect(`${back}?flash=added`);
1144 }
1145);
1146
1147/** POST /:owner/:repo/settings/deployments/:configId/delete */
1148dep.post(
1149 "/:owner/:repo/settings/deployments/:configId/delete",
1150 requireAuth,
1151 async (c) => {
1152 const { owner, repo, configId } = c.req.param();
1153 const user = c.get("user")!;
1154 const repoRow = await resolveRepo(owner, repo);
1155 const back = `/${owner}/${repo}/settings/deployments`;
1156 if (!repoRow) return c.notFound();
1157 if (repoRow.ownerId !== user.id) return c.redirect(back);
1158
1159 try {
1160 await db
1161 .delete(cloudDeployConfigs)
1162 .where(
1163 and(
1164 eq(cloudDeployConfigs.id, configId),
1165 eq(cloudDeployConfigs.repoId, repoRow.id)
1166 )
1167 );
1168 } catch (err) {
1169 console.error("[cloud-deploy] delete config:", err);
1170 }
1171
1172 return c.redirect(`${back}?flash=deleted`);
1173 }
1174);
1175
1176/** GET /:owner/:repo/cloud-deployments — list recent cloud deploy runs */
1177dep.get("/:owner/:repo/cloud-deployments", softAuth, async (c) => {
1178 const { owner, repo } = c.req.param();
1179 const user = c.get("user");
1180 const repoRow = await resolveRepo(owner, repo);
1181 if (!repoRow) return c.notFound();
1182
1183 let runs: Array<{
1184 id: string;
1185 configId: string;
1186 commitSha: string;
1187 status: string;
1188 providerDeployId: string | null;
1189 logUrl: string | null;
1190 deployUrl: string | null;
1191 errorMessage: string | null;
1192 startedAt: Date | null;
1193 completedAt: Date | null;
1194 durationMs: number | null;
1195 provider: string;
1196 providerAppId: string;
1197 triggerBranch: string;
1198 }> = [];
1199 try {
1200 runs = await db
1201 .select({
1202 id: cloudDeployments.id,
1203 configId: cloudDeployments.configId,
1204 commitSha: cloudDeployments.commitSha,
1205 status: cloudDeployments.status,
1206 providerDeployId: cloudDeployments.providerDeployId,
1207 logUrl: cloudDeployments.logUrl,
1208 deployUrl: cloudDeployments.deployUrl,
1209 errorMessage: cloudDeployments.errorMessage,
1210 startedAt: cloudDeployments.startedAt,
1211 completedAt: cloudDeployments.completedAt,
1212 durationMs: cloudDeployments.durationMs,
1213 provider: cloudDeployConfigs.provider,
1214 providerAppId: cloudDeployConfigs.providerAppId,
1215 triggerBranch: cloudDeployConfigs.triggerBranch,
1216 })
1217 .from(cloudDeployments)
1218 .innerJoin(
1219 cloudDeployConfigs,
1220 eq(cloudDeployments.configId, cloudDeployConfigs.id)
1221 )
1222 .where(eq(cloudDeployments.repoId, repoRow.id))
1223 .orderBy(desc(cloudDeployments.startedAt))
1224 .limit(100);
1225 } catch (err) {
1226 console.error("[cloud-deployments] list:", err);
1227 }
1228
1229 return c.html(
1230 <Layout title={`${owner}/${repo} — cloud deployments`} user={user}>
1231 <RepoHeader owner={owner} repo={repo} />
1232 <div class="cds-wrap">
1233 <header
1234 class="cds-head"
1235 style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;"
1236 >
1237 <div>
1238 <h2 class="cds-title">Cloud Deployments</h2>
1239 <p class="cds-sub">
1240 Recent push-triggered deploys across all configured integrations.
1241 </p>
1242 </div>
1243 {user?.id === repoRow.ownerId && (
1244 <a
1245 href={`/${owner}/${repo}/settings/deployments`}
1246 class="cds-btn cds-btn-ghost"
1247 style="text-decoration:none;"
1248 >
1249 Configure integrations
1250 </a>
1251 )}
1252 </header>
1253
1254 <div class="cds-card">
1255 {runs.length === 0 ? (
1256 <div class="cds-empty">
1257 No cloud deployments yet.{" "}
1258 {user?.id === repoRow.ownerId ? (
1259 <a
1260 href={`/${owner}/${repo}/settings/deployments`}
1261 class="cds-link"
1262 >
1263 Add an integration
1264 </a>
1265 ) : (
1266 "Ask the repo owner to configure a cloud deploy integration."
1267 )}
1268 </div>
1269 ) : (
1270 <div class="cds-runs">
1271 {runs.map((run) => {
1272 const durMs = run.durationMs;
1273 const dur =
1274 durMs != null
1275 ? durMs >= 60_000
1276 ? `${Math.round(durMs / 60_000)}m ${Math.round((durMs % 60_000) / 1000)}s`
1277 : `${Math.round(durMs / 1000)}s`
1278 : null;
1279 const isRunning =
1280 run.status === "running" || run.status === "pending";
1281 return (
1282 <div class="cds-run-row">
1283 <span class={cdStatusClass(run.status)}>
1284 {isRunning ? (
1285 <span
1286 class="cds-spinner"
1287 aria-label="deploying"
1288 />
1289 ) : null}
1290 {run.status}
1291 </span>
1292 <code class="cds-sha">
1293 {run.commitSha.slice(0, 7)}
1294 </code>
1295 <div
1296 style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;"
1297 >
1298 <span class="cds-badge">
1299 {PROVIDER_LABELS[run.provider] ?? run.provider}
1300 </span>
1301 <span
1302 class="cds-mono"
1303 style="font-size:12px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
1304 >
1305 {run.providerAppId}
1306 </span>
1307 {run.deployUrl && run.status === "success" && (
1308 <a
1309 href={run.deployUrl}
1310 target="_blank"
1311 rel="noopener noreferrer"
1312 class="cds-link"
1313 >
1314 {run.deployUrl
1315 .replace(/^https?:\/\//, "")
1316 .slice(0, 40)}
1317 </a>
1318 )}
1319 {run.status === "failed" && run.errorMessage && (
1320 <span
1321 style="font-size:11.5px;color:#fca5a5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
1322 title={run.errorMessage}
1323 >
1324 {run.errorMessage.slice(0, 60)}
1325 </span>
1326 )}
1327 </div>
1328 <span
1329 style="font-size:12px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums;"
1330 >
1331 {dur
1332 ? run.status === "success"
1333 ? `Deployed in ${dur}`
1334 : run.status === "failed"
1335 ? `Failed after ${dur}`
1336 : dur
1337 : dkRelativeTime(run.startedAt)}
1338 </span>
1339 <div style="display:flex;gap:6px;align-items:center;">
1340 {run.logUrl && (
1341 <a
1342 href={run.logUrl}
1343 target="_blank"
1344 rel="noopener noreferrer"
1345 class="cds-link"
1346 >
1347 Logs
1348 </a>
1349 )}
1350 </div>
1351 </div>
1352 );
1353 })}
1354 </div>
1355 )}
1356 </div>
1357 </div>
1358 <style
1359 dangerouslySetInnerHTML={{ __html: cloudDeploySettingsStyles }}
1360 />
1361 </Layout>
1362 );
1363});
1364
1365/** POST /:owner/:repo/deployments/:deployId/cancel — cancel a running cloud deployment */
1366dep.post(
1367 "/:owner/:repo/deployments/:deployId/cancel",
1368 requireAuth,
1369 async (c) => {
1370 const { owner, repo, deployId } = c.req.param();
1371 const user = c.get("user")!;
1372 const repoRow = await resolveRepo(owner, repo);
1373 const back = `/${owner}/${repo}/cloud-deployments`;
1374 if (!repoRow) return c.notFound();
1375 if (repoRow.ownerId !== user.id) return c.redirect(back);
1376
1377 try {
1378 await db
1379 .update(cloudDeployments)
1380 .set({ status: "cancelled", completedAt: new Date() })
1381 .where(
1382 and(
1383 eq(cloudDeployments.id, deployId),
1384 eq(cloudDeployments.repoId, repoRow.id)
1385 )
1386 );
1387 } catch (err) {
1388 console.error("[cloud-deploy] cancel:", err);
1389 }
1390
1391 return c.redirect(back);
1392 }
1393);
1394
8011395export default dep;
Modifiedsrc/views/components.tsx+7−0View fileUnifiedSplit
@@ -150,6 +150,7 @@ export const RepoNav: FC<{
150150 | "agents"
151151 | "discussions"
152152 | "security"
153 | "deployments"
153154 | "settings";
154155}> = ({ owner, repo, active }) => (
155156 <div class="repo-nav">
@@ -216,6 +217,12 @@ export const RepoNav: FC<{
216217 >
217218 Security
218219 </a>
220 <a
221 href={`/${owner}/${repo}/cloud-deployments`}
222 class={active === "deployments" ? "active" : ""}
223 >
224 Deployments
225 </a>
219226 <a
220227 href={`/${owner}/${repo}/insights`}
221228 class={active === "insights" ? "active" : ""}
222229