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

post-receive.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.

post-receive.tsBlame715 lines · 3 contributors
fc1817aClaude1/**
2 * Post-receive hook logic.
2c34075Claude3 *
4 * Called after every successful git push. This is gluecron's intelligence layer:
5 * 1. Auto-repair — fix common issues and commit automatically
6 * 2. Push analysis — detect breaking changes, security issues
7 * 3. Health score — recompute repo health
8 * 4. GateTest scan — external security scanning
9 * 5. Crontech deploy — auto-deploy on push to main
10 * 6. Webhooks — fire registered webhook URLs
fc1817aClaude11 */
12
ba93444Claude13import { createHmac } from "crypto";
3ef4c9dClaude14import { and, eq } from "drizzle-orm";
fc1817aClaude15import { config } from "../lib/config";
2c34075Claude16import { autoRepair } from "../lib/autorepair";
170ddb2Claude17import { notifyGateTestOfPush } from "../lib/gate";
2c34075Claude18import { analyzePush, computeHealthScore } from "../lib/intelligence";
0316dbbClaude19import { db } from "../db";
20import { deployments, repositories, users } from "../db/schema";
21import { onDeployFailure } from "../lib/ai-incident";
a686079Claude22import {
23 commitsBetween,
24 getDefaultBranch,
25 getRepoPath,
26} from "../git/repository";
27import { indexChangedFiles } from "../lib/semantic-index";
4bbacbeClaude28import { enqueuePreviewBuild } from "../lib/branch-previews";
d199847Claude29import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
783dd46Claude30import {
31 findTargetsForPush,
32 finishDeployRow,
33 resolveEnv,
34 startDeployRow,
35} from "../lib/server-target-store";
36import { deployToTarget } from "../lib/server-targets";
fc1817aClaude37
38interface PushRef {
39 oldSha: string;
40 newSha: string;
41 refName: string;
42}
43
44export async function onPostReceive(
45 owner: string,
46 repo: string,
47 refs: PushRef[]
48): Promise<void> {
2c34075Claude49 for (const ref of refs) {
50 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
51 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude52
2c34075Claude53 // 1. Auto-repair (runs first, may create a new commit)
54 try {
55 const repair = await autoRepair(owner, repo, branchName);
56 if (repair.repaired) {
57 console.log(
58 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
59 );
60 }
61 } catch (err) {
62 console.error(`[autorepair] error:`, err);
63 }
fc1817aClaude64
2c34075Claude65 // 2. Push analysis
66 try {
67 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
68 console.log(
69 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
70 );
71 if (analysis.riskScore > 50) {
72 console.warn(
73 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
74 );
75 }
76 if (analysis.breakingChangeSignals.length > 0) {
77 console.warn(
78 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
79 );
80 }
81 } catch (err) {
82 console.error(`[push-analysis] error:`, err);
83 }
84
85 // 3. Health score (async, don't block)
86 computeHealthScore(owner, repo).then((report) => {
87 console.log(
88 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
89 );
90 }).catch((err) => {
91 console.error(`[health] error:`, err);
92 });
93 }
94
170ddb2Claude95 // 4. GateTest scan — fire-and-forget notification on every push. The
96 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
97 // deployments pay no overhead. Results flow back via the inbound
98 // webhook at POST /api/hooks/gatetest.
99 for (const ref of refs) {
100 if (ref.newSha.startsWith("0000")) continue;
101 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
102 console.warn("[gatetest] notify error:", err)
103 );
104 }
2c34075Claude105
a686079Claude106 // 4b. Continuous semantic index — embed changed files on every push so
107 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
108 // all failures are swallowed inside semantic-index.ts so a missing
109 // pgvector extension or absent embeddings API key never breaks the
110 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
111 for (const ref of refs) {
112 if (ref.newSha.startsWith("0000")) continue;
113 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
114 console.warn("[semantic-index] dispatch error:", err)
115 );
116 }
117
4bbacbeClaude118 // 4c. Per-branch preview URLs (migration 0062). Every push to a
119 // non-default branch enqueues a preview-build row. Gated on
120 // repositories.preview_builds_enabled (default on) so owners can
121 // opt out per-repo via repo-settings. Fire-and-forget; failures
122 // never break the push path.
123 void firePreviewBuilds(owner, repo, refs).catch((err) =>
124 console.warn("[branch-previews] dispatch error:", err)
125 );
126
d199847Claude127 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
128 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
129 // regions, hashes the referenced source, and opens a PR labelled
130 // `ai:doc-update` when the prose drifts. Fire-and-forget; failures
131 // are swallowed inside ai-doc-updater.ts so a missing anthropic key
132 // or empty doc_tracking table never breaks the push.
133 void fireDocDriftCheck(owner, repo).catch((err) =>
134 console.warn("[ai-doc-updater] dispatch error:", err)
135 );
136
ba93444Claude137 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
138 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
139 // default branch. The branch case (`Main` vs `main`) is determined by
140 // the bare repo's HEAD, not hardcoded.
141 if (`${owner}/${repo}` === config.crontechRepo) {
a28cedeClaude142 let defaultBranch =
143 (await getDefaultBranch(owner, repo).catch((err) => {
144 console.warn(
145 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
146 err instanceof Error ? err.message : err
147 );
148 return null;
149 })) || "main";
ba93444Claude150 const targetRef = `refs/heads/${defaultBranch}`;
151 const deployPush = refs.find(
152 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
153 );
154 if (deployPush) {
155 let repositoryId = "";
156 try {
157 const [row] = await db
158 .select({ id: repositories.id })
159 .from(repositories)
160 .innerJoin(users, eq(repositories.ownerId, users.id))
161 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
162 .limit(1);
163 repositoryId = row?.id || "";
164 } catch {
165 /* ignore */
166 }
167 if (repositoryId) {
168 triggerCrontechDeploy({
169 owner,
170 repo,
171 before: deployPush.oldSha,
172 after: deployPush.newSha,
173 ref: targetRef,
174 branch: defaultBranch,
175 repositoryId,
176 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
177 }
0316dbbClaude178 }
fc1817aClaude179 }
f2c00b4CC LABS App180
783dd46Claude181 // 5b. Block ST — Server targets. After core post-receive work, fire
182 // deploys against any `server_targets` row whose
183 // (watched_repository_id, watched_branch) matches a pushed ref.
184 // Fire-and-forget; deploy results land in `server_target_deployments`
185 // and the UI at /admin/servers/:id surfaces them.
186 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
187 console.warn("[server-targets] dispatch error:", err)
188 );
189
f2c00b4CC LABS App190 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
191 // main, fire the local deploy via scripts/self-deploy.sh. The script
192 // forks into the background, so this call returns immediately (git
193 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
194 // avoid firing on customer repos that happen to be named "Gluecron.com".
195 const selfHostRepo = process.env.SELF_HOST_REPO;
196 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
197 const mainRef = refs.find(
198 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
199 );
200 if (mainRef) {
201 const scriptPath =
202 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
203 "/opt/gluecron/scripts/self-deploy.sh";
204 try {
205 const child = __selfHostSpawn(
206 [scriptPath, mainRef.oldSha, mainRef.newSha],
207 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
208 );
209 try {
210 (child as any)?.unref?.();
211 } catch {
212 /* unref optional */
213 }
214 console.log(
215 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
216 );
217 } catch (err) {
218 console.error(`[self-host] failed to spawn:`, err);
219 }
220 }
221 }
222}
223
224// BLOCK W — DI seam so the test suite can capture the spawn call without
225// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
226// callers go straight to Bun.spawn.
bf19c50Test User227const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App228 Bun.spawn(cmd, opts);
bf19c50Test User229let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
230/**
231 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
232 */
233export function __setSelfHostSpawnForTests(
234 fn: typeof __selfHostSpawn | null
235): void {
236 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude237}
238
43cf9b0Claude239/**
ba93444Claude240 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude241 *
ba93444Claude242 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude243 *
ba93444Claude244 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude245 * Content-Type: application/json
ba93444Claude246 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude247 *
248 * {
ba93444Claude249 * "event": "push",
250 * "repository": { "full_name": "ccantynz-alt/crontech" },
251 * "ref": "refs/heads/Main",
252 * "after": "<40-hex commit SHA>",
253 * "before": "<40-hex previous SHA>",
254 * "pusher": { "name": "<author>", "email": "<email>" },
255 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude256 * }
257 *
ba93444Claude258 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude259 *
ba93444Claude260 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
261 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
262 * is unset the signature header is omitted and Crontech is expected to reject —
263 * we still record the deploy row as failed.
43cf9b0Claude264 */
ba93444Claude265const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
266
267interface TriggerArgs {
268 owner: string;
269 repo: string;
270 before: string;
271 after: string;
272 ref: string;
273 branch: string;
274 repositoryId: string;
275}
276
277interface TriggerOptions {
278 fetchImpl?: typeof fetch;
279 sleep?: (ms: number) => Promise<void>;
280 retryDelaysMs?: number[];
281 now?: () => Date;
282}
283
284function signBody(body: string, secret: string): string | null {
285 if (!secret) return null;
286 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
287}
288
289async function buildPayload(args: TriggerArgs, now: Date): Promise<{
290 payload: Record<string, unknown>;
291 pusherName: string;
292 pusherEmail: string;
293}> {
294 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
295 // `before` may be all-zeros for a first push to the branch — commitsBetween
296 // handles that by treating null `from` as "everything reachable from `to`".
297 const fromSha = /^0+$/.test(args.before) ? null : args.before;
298 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
299 let pusherName = "gluecron";
300 let pusherEmail = "noreply@gluecron.local";
301 try {
302 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
303 commits = list.slice(0, 50).map((c) => ({
304 id: c.sha,
305 message: c.message,
306 timestamp: c.date,
307 }));
308 if (list[0]) {
309 pusherName = list[0].author || pusherName;
310 pusherEmail = list[0].authorEmail || pusherEmail;
311 }
312 } catch {
313 /* ignore — payload still valid with empty commits[] */
314 }
315 return {
316 payload: {
317 event: "push",
318 repository: { full_name: `${args.owner}/${args.repo}` },
319 ref: args.ref,
320 after: args.after,
321 before: args.before,
322 pusher: { name: pusherName, email: pusherEmail },
323 commits,
324 // Ancillary fields — receiver may ignore but they're useful for logs:
325 sent_at: now.toISOString(),
326 source: "gluecron",
327 },
328 pusherName,
329 pusherEmail,
330 };
331}
332
fc1817aClaude333async function triggerCrontechDeploy(
ba93444Claude334 args: TriggerArgs,
335 opts: TriggerOptions = {}
fc1817aClaude336): Promise<void> {
ba93444Claude337 const fetchImpl = opts.fetchImpl ?? fetch;
338 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
339 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
340 const now = opts.now ?? (() => new Date());
341
3ef4c9dClaude342 let deployId = "";
fc1817aClaude343 try {
3ef4c9dClaude344 const [row] = await db
345 .insert(deployments)
346 .values({
ba93444Claude347 repositoryId: args.repositoryId,
3ef4c9dClaude348 environment: "production",
ba93444Claude349 commitSha: args.after,
350 ref: args.ref,
3ef4c9dClaude351 status: "pending",
352 target: "crontech",
353 })
354 .returning();
355 deployId = row?.id || "";
356 } catch {
357 /* ignore */
fc1817aClaude358 }
359
ba93444Claude360 const { payload } = await buildPayload(args, now());
361 const body = JSON.stringify(payload);
362 const signature = signBody(body, config.gluecronWebhookSecret);
363
364 const headers: Record<string, string> = {
365 "Content-Type": "application/json",
366 "User-Agent": "gluecron-webhook/1",
367 "X-Gluecron-Event": "push",
368 "X-Gluecron-Delivery": cryptoRandomId(),
369 };
370 if (signature) headers["X-Gluecron-Signature"] = signature;
371
372 let lastStatus = 0;
373 let lastError = "";
374 let success = false;
375
376 // Up to delays.length + 1 attempts (initial try + each delay).
377 const totalAttempts = delays.length + 1;
378 for (let attempt = 0; attempt < totalAttempts; attempt++) {
379 try {
380 const response = await fetchImpl(config.crontechDeployUrl, {
381 method: "POST",
382 headers,
383 body,
384 });
385 lastStatus = response.status;
386 console.log(
387 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
388 );
389 if (response.ok) {
390 success = true;
391 break;
392 }
393 // 4xx (except 408/429) is unrecoverable — stop retrying.
394 if (response.status >= 400 && response.status < 500 &&
395 response.status !== 408 && response.status !== 429) {
396 break;
397 }
398 } catch (err) {
399 lastError = err instanceof Error ? err.message : String(err);
400 console.error(
401 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
402 );
3ef4c9dClaude403 }
ba93444Claude404 const nextDelay = delays[attempt];
405 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
406 await sleep(nextDelay);
1e162a8Claude407 }
ba93444Claude408 }
409
410 if (deployId) {
411 try {
3ef4c9dClaude412 await db
413 .update(deployments)
414 .set({
ba93444Claude415 status: success ? "success" : "failed",
416 blockedReason: success
417 ? null
418 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude419 completedAt: new Date(),
420 })
421 .where(eq(deployments.id, deployId));
ba93444Claude422 } catch {
423 /* ignore */
3ef4c9dClaude424 }
425 }
426
ba93444Claude427 if (!success && deployId) {
428 void onDeployFailure({
429 repositoryId: args.repositoryId,
430 deploymentId: deployId,
431 ref: args.ref,
432 commitSha: args.after,
433 target: "crontech",
434 errorMessage: lastError || `HTTP ${lastStatus}`,
435 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude436 }
437}
43cf9b0Claude438
ba93444Claude439function cryptoRandomId(): string {
440 // Short opaque delivery ID for log correlation. Not security-sensitive.
441 const bytes = new Uint8Array(8);
442 crypto.getRandomValues(bytes);
443 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
444}
445
a686079Claude446/**
447 * Resolve `owner/repo` to its DB repository.id and dispatch the
448 * semantic-index update. Pulls the list of changed paths via
449 * `git diff --name-only`, dropping any deletions (handled implicitly
450 * because deleted blobs simply don't resolve in `indexChangedFiles`).
451 *
452 * Never throws — exhaust every external call inside a try/catch so the
453 * push completes even if Postgres or the embedding API is down.
454 */
455async function fireSemanticIndex(
456 owner: string,
457 repo: string,
458 oldSha: string,
459 newSha: string
460): Promise<void> {
461 let repositoryId = "";
462 try {
463 const [row] = await db
464 .select({ id: repositories.id })
465 .from(repositories)
466 .innerJoin(users, eq(repositories.ownerId, users.id))
467 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
468 .limit(1);
469 repositoryId = row?.id || "";
470 } catch {
471 return;
472 }
473 if (!repositoryId) return;
474
475 let changedPaths: string[] = [];
476 try {
477 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
478 } catch {
479 return;
480 }
481 if (!changedPaths.length) return;
482
483 try {
484 const out = await indexChangedFiles({
485 repositoryId,
486 ownerName: owner,
487 repoName: repo,
488 commitSha: newSha,
489 changedPaths,
490 });
491 if (out.indexed > 0) {
492 console.log(
493 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
494 );
495 }
496 } catch (err) {
497 console.warn("[semantic-index] indexChangedFiles error:", err);
498 }
499}
500
501/**
502 * Returns the list of files touched between `oldSha` and `newSha`. For
503 * the initial push on a branch (oldSha all-zero) we walk every file in
504 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
505 */
506async function listChangedPaths(
507 owner: string,
508 repo: string,
509 oldSha: string,
510 newSha: string
511): Promise<string[]> {
512 const cwd = getRepoPath(owner, repo);
513 const allZero = /^0+$/.test(oldSha);
514 const cmd = allZero
515 ? ["git", "ls-tree", "-r", "--name-only", newSha]
516 : ["git", "diff", "--name-only", oldSha, newSha];
517 try {
518 const proc = Bun.spawn(cmd, {
519 cwd,
520 stdout: "pipe",
521 stderr: "pipe",
522 });
523 const text = await new Response(proc.stdout).text();
524 await proc.exited;
525 return text
526 .split("\n")
527 .map((s) => s.trim())
528 .filter((s) => s.length > 0);
529 } catch {
530 return [];
531 }
532}
533
4bbacbeClaude534/**
535 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
536 * non-default branch on a `preview_builds_enabled` repo.
537 *
538 * Resolves the repo row once, fetches the default branch + opt-out flag,
539 * then upserts one preview row per pushed ref that isn't the default.
540 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
541 * are skipped — they shouldn't create preview rows. Never throws.
542 */
543async function firePreviewBuilds(
544 owner: string,
545 repo: string,
546 refs: PushRef[]
547): Promise<void> {
548 // Filter to live branch pushes only.
549 const branchRefs = refs.filter(
550 (r) =>
551 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
552 );
553 if (branchRefs.length === 0) return;
554
555 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
556 try {
557 const [row] = await db
558 .select({
559 id: repositories.id,
560 previewBuildsEnabled: repositories.previewBuildsEnabled,
561 defaultBranch: repositories.defaultBranch,
562 })
563 .from(repositories)
564 .innerJoin(users, eq(repositories.ownerId, users.id))
565 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
566 .limit(1);
567 repoRow = row || null;
568 } catch {
569 return;
570 }
571 if (!repoRow) return;
572 if (!repoRow.previewBuildsEnabled) return;
573
574 for (const ref of branchRefs) {
575 const branchName = ref.refName.replace("refs/heads/", "");
576 if (branchName === repoRow.defaultBranch) continue;
577 try {
578 await enqueuePreviewBuild({
579 repositoryId: repoRow.id,
580 ownerName: owner,
581 repoName: repo,
582 branchName,
583 commitSha: ref.newSha,
584 });
585 } catch (err) {
586 console.warn(
587 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
588 err instanceof Error ? err.message : err
589 );
590 }
591 }
592}
593
d199847Claude594/**
595 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
596 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
597 * on missing repo or DB error — pushes never block. Never throws.
598 */
599async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
600 let repositoryId = "";
601 try {
602 const [row] = await db
603 .select({ id: repositories.id })
604 .from(repositories)
605 .innerJoin(users, eq(repositories.ownerId, users.id))
606 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
607 .limit(1);
608 repositoryId = row?.id || "";
609 } catch {
610 return;
611 }
612 if (!repositoryId) return;
613 try {
614 const out = await runDocDriftCheckForRepo(repositoryId);
615 if (out.docs > 0 || out.proposed > 0) {
616 console.log(
617 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
618 );
619 }
620 } catch (err) {
621 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
622 }
623}
624
783dd46Claude625/**
626 * Block ST — fan out the push to any server targets that watch this
627 * (repo, branch). One sequential deploy per target so a slow box can't
628 * stall the next push, but multiple matching targets run in parallel.
629 * Every failure is contained to its own deploy row + console warn —
630 * nothing here can break the push path.
631 */
632async function fireServerTargetDeploys(
633 owner: string,
634 repo: string,
635 refs: PushRef[]
636): Promise<void> {
637 const liveRefs = refs.filter(
638 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
639 );
640 if (liveRefs.length === 0) return;
641
642 let repositoryId = "";
643 try {
644 const [row] = await db
645 .select({ id: repositories.id })
646 .from(repositories)
647 .innerJoin(users, eq(repositories.ownerId, users.id))
648 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
649 .limit(1);
650 repositoryId = row?.id || "";
651 } catch {
652 return;
653 }
654 if (!repositoryId) return;
655
656 await Promise.all(
657 liveRefs.map(async (ref) => {
658 const branch = ref.refName.replace("refs/heads/", "");
659 let targets;
660 try {
661 targets = await findTargetsForPush({ repositoryId, branch });
662 } catch {
663 return;
664 }
665 if (!targets.length) return;
666
667 await Promise.all(
668 targets.map(async (target) => {
669 try {
670 const env = await resolveEnv(target.id);
671 const deployId = await startDeployRow({
672 targetId: target.id,
673 commitSha: ref.newSha,
674 ref: ref.refName,
675 triggerSource: "push",
676 });
677 const result = await deployToTarget(target, {
678 commitSha: ref.newSha,
679 ref: ref.refName,
680 env,
681 });
682 if (deployId) {
683 await finishDeployRow({
684 id: deployId,
685 exitCode: result.exitCode,
686 stdout: result.stdout,
687 stderr: result.stderr,
688 });
689 }
690 console.log(
691 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
692 );
693 } catch (err) {
694 console.warn(
695 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
696 );
697 }
698 })
699 );
700 })
701 );
702}
703
43cf9b0Claude704/** Test-only access to internal helpers. */
a686079Claude705export const __test = {
706 triggerCrontechDeploy,
707 signBody,
708 buildPayload,
709 RETRY_DELAYS_MS,
710 listChangedPaths,
711 fireSemanticIndex,
4bbacbeClaude712 firePreviewBuilds,
d199847Claude713 fireDocDriftCheck,
783dd46Claude714 fireServerTargetDeploys,
a686079Claude715};