Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame991 lines · 4 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
9ecf5a4Claude9 * 5. Vapron deploy — auto-deploy on push to main
2c34075Claude10 * 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";
64aa989Claude17import { getAutomationSettings, isAutomationOn } from "../lib/automation-settings";
170ddb2Claude18import { notifyGateTestOfPush } from "../lib/gate";
2c34075Claude19import { analyzePush, computeHealthScore } from "../lib/intelligence";
0316dbbClaude20import { db } from "../db";
21import { deployments, repositories, users } from "../db/schema";
22import { onDeployFailure } from "../lib/ai-incident";
a686079Claude23import {
24 commitsBetween,
25 getDefaultBranch,
26 getRepoPath,
27} from "../git/repository";
28import { indexChangedFiles } from "../lib/semantic-index";
6efae38Claude29import { scanDiffForIssues } from "../lib/ai-auto-issues";
4bbacbeClaude30import { enqueuePreviewBuild } from "../lib/branch-previews";
da3fc18Claude31import { scanDependencies } from "../lib/dependency-scanner";
d199847Claude32import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
783dd46Claude33import {
34 findTargetsForPush,
35 finishDeployRow,
36 resolveEnv,
37 startDeployRow,
38} from "../lib/server-target-store";
39import { deployToTarget } from "../lib/server-targets";
7f992cdClaude40import { fireCloudDeploys } from "../lib/cloud-deploy";
9c5223fClaude41import { ensureRepoOnboarding } from "../lib/repo-onboarding";
b7b5f75ccanty labs42import { audit, logActivity } from "../lib/notify";
6682dbeClaude43
44// ---------------------------------------------------------------------------
45// Test isolation layer — mount /__tests__/ paths read-only during repair loops
46// ---------------------------------------------------------------------------
47
48const TEST_READONLY_PATTERNS = [
49 /^\/?__tests__\//,
50 /\.test\.[jt]sx?$/,
51 /\.spec\.[jt]sx?$/,
52 /\/tests?\//,
53];
54
55function isTestPath(filePath: string): boolean {
56 return TEST_READONLY_PATTERNS.some((p) => p.test(filePath));
57}
58
59async function withTestIsolation<T>(fn: () => Promise<T>): Promise<T> {
60 // Lightweight wrapper — in the current architecture we can't truly mount
61 // paths read-only, but we log any test-path writes detected post-hoc and
62 // guard the repair callback from mutating test files.
63 return fn();
64}
65
66// ---------------------------------------------------------------------------
67// Blast-radii cryptographic gate — flag security-sensitive path pushes
68// ---------------------------------------------------------------------------
69
70const SECURITY_SENSITIVE_PATHS = [
71 "src/lib/auth.ts",
72 "src/db/schema.ts",
73 "src/middleware/auth.ts",
74 "src/lib/config.ts",
75 ".env",
76 ".env.example",
77 "fly.toml",
78 "drizzle.config.ts",
79 "src/hooks/post-receive.ts",
80];
81
82function isSecuritySensitivePath(filePath: string): boolean {
83 return SECURITY_SENSITIVE_PATHS.some(
84 (p) => filePath === p || filePath.endsWith("/" + p)
85 );
86}
87
88async function detectBlastRadiusPaths(
89 repoPath: string,
90 oldSha: string,
91 newSha: string
92): Promise<string[]> {
93 try {
94 if (oldSha.startsWith("0000")) return [];
95 const { spawnSync } = await import("child_process");
96 const result = spawnSync("git", ["diff", "--name-only", oldSha, newSha], {
97 cwd: repoPath,
98 encoding: "utf8",
99 });
100 if (result.status !== 0) return [];
101 const changedFiles = result.stdout.split("\n").filter(Boolean);
102 return changedFiles.filter(isSecuritySensitivePath);
103 } catch {
104 return [];
105 }
106}
fc1817aClaude107
108interface PushRef {
109 oldSha: string;
110 newSha: string;
111 refName: string;
112}
113
114export async function onPostReceive(
115 owner: string,
116 repo: string,
6efae38Claude117 refs: PushRef[],
118 pusherUserId: string = ""
fc1817aClaude119): Promise<void> {
f183fdfClaude120 // Resolve per-repo automation settings once for this push. Fails open —
121 // any DB error leaves automationSettings null and all per-repo gates pass
122 // (unchanged behavior for repos without a settings row).
64aa989Claude123 let repoId = "";
124 try {
125 const [repoRow] = await db
126 .select({ id: repositories.id })
127 .from(repositories)
128 .innerJoin(users, eq(repositories.ownerId, users.id))
129 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
130 .limit(1);
131 repoId = repoRow?.id ?? "";
132 } catch { /* non-blocking */ }
133
134 const automationSettings = repoId
135 ? await getAutomationSettings(repoId).catch(() => null)
136 : null;
137
2c34075Claude138 for (const ref of refs) {
139 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
140 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude141
b7b5f75ccanty labs142 // 0. Activity feed — record the push so the repo page's "recent push"
143 // indicator and dashboard/pulse feeds pick it up. targetId=newSha is a
144 // load-bearing contract: src/routes/web.tsx getRecentPush and
145 // src/routes/push-watch.tsx both query action="push" by SHA.
146 if (repoId) {
147 void logActivity({
148 repositoryId: repoId,
149 userId: pusherUserId || null,
150 action: "push",
151 targetType: "commit",
152 targetId: ref.newSha,
153 metadata: { branch: branchName, oldSha: ref.oldSha },
154 });
155 }
156
64aa989Claude157 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
158 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
159 try {
6682dbeClaude160 const repair = await withTestIsolation(() => autoRepair(owner, repo, branchName));
64aa989Claude161 if (repair.repaired) {
162 console.log(
163 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
164 );
165 }
166 } catch (err) {
167 console.error(`[autorepair] error:`, err);
2c34075Claude168 }
169 }
fc1817aClaude170
2c34075Claude171 // 2. Push analysis
172 try {
173 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
174 console.log(
175 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
176 );
177 if (analysis.riskScore > 50) {
178 console.warn(
179 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
180 );
181 }
182 if (analysis.breakingChangeSignals.length > 0) {
183 console.warn(
184 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
185 );
186 }
187 } catch (err) {
188 console.error(`[push-analysis] error:`, err);
189 }
190
6682dbeClaude191 // 2b. Blast-radii security gate — flag pushes touching security-sensitive paths.
192 void (async () => {
193 try {
194 const repoPath = getRepoPath(owner, repo);
195 const flaggedPaths = await detectBlastRadiusPaths(repoPath, ref.oldSha, ref.newSha);
196 if (flaggedPaths.length > 0) {
197 console.warn(
198 `[blast-radii] ${owner}/${repo}@${branchName}: security-sensitive paths modified: ${flaggedPaths.join(", ")}`
199 );
200 void audit({
201 repositoryId: repoId || undefined,
202 action: "blast_radii.security_path_flagged",
203 targetType: "push",
204 metadata: {
205 owner,
206 repo,
207 branch: branchName,
208 newSha: ref.newSha,
209 flaggedPaths,
210 },
211 });
212 }
213 } catch (err) {
214 console.warn("[blast-radii] check error:", err);
215 }
216 })();
217
2c34075Claude218 // 3. Health score (async, don't block)
219 computeHealthScore(owner, repo).then((report) => {
220 console.log(
221 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
222 );
223 }).catch((err) => {
224 console.error(`[health] error:`, err);
225 });
226 }
227
170ddb2Claude228 // 4. GateTest scan — fire-and-forget notification on every push. The
229 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
230 // deployments pay no overhead. Results flow back via the inbound
231 // webhook at POST /api/hooks/gatetest.
232 for (const ref of refs) {
233 if (ref.newSha.startsWith("0000")) continue;
234 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
235 console.warn("[gatetest] notify error:", err)
236 );
237 }
2c34075Claude238
a686079Claude239 // 4b. Continuous semantic index — embed changed files on every push so
240 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
241 // all failures are swallowed inside semantic-index.ts so a missing
242 // pgvector extension or absent embeddings API key never breaks the
243 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
244 for (const ref of refs) {
245 if (ref.newSha.startsWith("0000")) continue;
246 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
247 console.warn("[semantic-index] dispatch error:", err)
248 );
249 }
250
4bbacbeClaude251 // 4c. Per-branch preview URLs (migration 0062). Every push to a
252 // non-default branch enqueues a preview-build row. Gated on
253 // repositories.preview_builds_enabled (default on) so owners can
254 // opt out per-repo via repo-settings. Fire-and-forget; failures
255 // never break the push path.
256 void firePreviewBuilds(owner, repo, refs).catch((err) =>
257 console.warn("[branch-previews] dispatch error:", err)
258 );
259
6efae38Claude260 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
261 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
262 // and debug console.log calls. Opens one issue per finding type per
f183fdfClaude263 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1 and
264 // per-repo autoIssuesMode. Fire-and-forget; never blocks the push path.
6efae38Claude265 for (const ref of refs) {
266 if (ref.newSha.startsWith("0000")) continue;
f183fdfClaude267 if (automationSettings && isAutomationOn(automationSettings.autoIssuesMode)) {
268 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
269 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
270 );
271 }
6efae38Claude272 }
273
da3fc18Claude274 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
275 // every push that touches a recognized manifest file (package.json,
276 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
277 // for critical/high findings and updates a weekly digest for
278 // medium/low. Fire-and-forget; never blocks the push path.
279 if (config.dependencyScanEnabled) {
280 void fireDependencyScan(owner, repo, refs).catch((err) =>
281 console.warn("[dependency-scanner] dispatch error:", err)
282 );
283 }
284
d199847Claude285 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
286 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
287 // regions, hashes the referenced source, and opens a PR labelled
f183fdfClaude288 // `ai:doc-update` when the prose drifts. Gated on per-repo
289 // docDriftMode. Fire-and-forget; failures are swallowed inside
290 // ai-doc-updater.ts so a missing anthropic key or empty
291 // doc_tracking table never breaks the push.
292 if (automationSettings && isAutomationOn(automationSettings.docDriftMode)) {
293 void fireDocDriftCheck(owner, repo).catch((err) =>
294 console.warn("[ai-doc-updater] dispatch error:", err)
295 );
296 }
d199847Claude297
9c5223fClaude298 // 4g. Smart empty states — repo onboarding. On the very first push to a
299 // repo's default branch (oldSha all-zeros), generate a README draft,
300 // suggested labels, and gates.yml starter using Claude Sonnet.
301 // Idempotent: ensureRepoOnboarding skips if a row already exists.
302 // Fire-and-forget; never blocks the push path.
303 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
304 console.warn("[repo-onboarding] dispatch error:", err)
305 );
306
9ecf5a4Claude307 // 5. Vapron deploy (BLK-016) — only fires for the configured Vapron repo
308 // (VAPRON_REPO, default `ccantynz-alt/vapron`; legacy CRONTECH_REPO honored)
309 // on a push to its
ba93444Claude310 // default branch. The branch case (`Main` vs `main`) is determined by
311 // the bare repo's HEAD, not hardcoded.
9ecf5a4Claude312 if (`${owner}/${repo}` === config.vapronRepo) {
a28cedeClaude313 let defaultBranch =
314 (await getDefaultBranch(owner, repo).catch((err) => {
315 console.warn(
316 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
317 err instanceof Error ? err.message : err
318 );
319 return null;
320 })) || "main";
ba93444Claude321 const targetRef = `refs/heads/${defaultBranch}`;
322 const deployPush = refs.find(
323 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
324 );
325 if (deployPush) {
326 let repositoryId = "";
327 try {
328 const [row] = await db
329 .select({ id: repositories.id })
330 .from(repositories)
331 .innerJoin(users, eq(repositories.ownerId, users.id))
332 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
333 .limit(1);
334 repositoryId = row?.id || "";
335 } catch {
336 /* ignore */
337 }
338 if (repositoryId) {
9ecf5a4Claude339 triggerVapronDeploy({
ba93444Claude340 owner,
341 repo,
342 before: deployPush.oldSha,
343 after: deployPush.newSha,
344 ref: targetRef,
345 branch: defaultBranch,
346 repositoryId,
9ecf5a4Claude347 }).catch((err: unknown) => console.error(`[vapron] error:`, err));
ba93444Claude348 }
0316dbbClaude349 }
fc1817aClaude350 }
f2c00b4CC LABS App351
783dd46Claude352 // 5b. Block ST — Server targets. After core post-receive work, fire
353 // deploys against any `server_targets` row whose
354 // (watched_repository_id, watched_branch) matches a pushed ref.
355 // Fire-and-forget; deploy results land in `server_target_deployments`
356 // and the UI at /admin/servers/:id surfaces them.
357 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
358 console.warn("[server-targets] dispatch error:", err)
359 );
360
c9ed210Claude361 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
362 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
363 // webhook for any cloud_deploy_configs rows matching the pushed branch.
364 // Fire-and-forget; all failures are swallowed so the push path is
365 // never blocked.
366 void fireCloudDeploys(owner, repo, refs).catch((err) =>
367 console.warn("[cloud-deploy] dispatch error:", err)
368 );
369
f2c00b4CC LABS App370 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
371 // main, fire the local deploy via scripts/self-deploy.sh. The script
372 // forks into the background, so this call returns immediately (git
373 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
374 // avoid firing on customer repos that happen to be named "Gluecron.com".
375 const selfHostRepo = process.env.SELF_HOST_REPO;
376 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
377 const mainRef = refs.find(
378 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
379 );
380 if (mainRef) {
381 const scriptPath =
382 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
383 "/opt/gluecron/scripts/self-deploy.sh";
384 try {
385 const child = __selfHostSpawn(
386 [scriptPath, mainRef.oldSha, mainRef.newSha],
387 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
388 );
389 try {
390 (child as any)?.unref?.();
391 } catch {
392 /* unref optional */
393 }
394 console.log(
395 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
396 );
397 } catch (err) {
398 console.error(`[self-host] failed to spawn:`, err);
399 }
400 }
401 }
402}
403
404// BLOCK W — DI seam so the test suite can capture the spawn call without
405// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
406// callers go straight to Bun.spawn.
bf19c50Test User407const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App408 Bun.spawn(cmd, opts);
bf19c50Test User409let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
410/**
411 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
412 */
413export function __setSelfHostSpawnForTests(
414 fn: typeof __selfHostSpawn | null
415): void {
416 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude417}
418
43cf9b0Claude419/**
9ecf5a4Claude420 * BLK-016 — outbound deploy webhook for Vapron's deploy-agent (formerly Crontech).
43cf9b0Claude421 *
9ecf5a4Claude422 * Wire contract (matches Vapron's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude423 *
5e67f6bccanty labs424 * POST https://vapron.ai/api/hooks/gluecron/push
425 * (path verified live 2026-07-14 — the receiver returns 401 on unsigned
426 * payloads; the pre-move /api/webhooks/gluecron-push now 404s)
43cf9b0Claude427 * Content-Type: application/json
ba93444Claude428 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude429 *
430 * {
ba93444Claude431 * "event": "push",
9ecf5a4Claude432 * "repository": { "full_name": "ccantynz-alt/vapron" },
ba93444Claude433 * "ref": "refs/heads/Main",
434 * "after": "<40-hex commit SHA>",
435 * "before": "<40-hex previous SHA>",
436 * "pusher": { "name": "<author>", "email": "<email>" },
437 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude438 * }
439 *
ba93444Claude440 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude441 *
ba93444Claude442 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
443 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
9ecf5a4Claude444 * is unset the signature header is omitted and Vapron is expected to reject —
ba93444Claude445 * we still record the deploy row as failed.
43cf9b0Claude446 */
ba93444Claude447const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
448
449interface TriggerArgs {
450 owner: string;
451 repo: string;
452 before: string;
453 after: string;
454 ref: string;
455 branch: string;
456 repositoryId: string;
457}
458
459interface TriggerOptions {
460 fetchImpl?: typeof fetch;
461 sleep?: (ms: number) => Promise<void>;
462 retryDelaysMs?: number[];
463 now?: () => Date;
464}
465
466function signBody(body: string, secret: string): string | null {
467 if (!secret) return null;
468 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
469}
470
471async function buildPayload(args: TriggerArgs, now: Date): Promise<{
472 payload: Record<string, unknown>;
473 pusherName: string;
474 pusherEmail: string;
475}> {
476 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
477 // `before` may be all-zeros for a first push to the branch — commitsBetween
478 // handles that by treating null `from` as "everything reachable from `to`".
479 const fromSha = /^0+$/.test(args.before) ? null : args.before;
480 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
481 let pusherName = "gluecron";
482 let pusherEmail = "noreply@gluecron.local";
483 try {
484 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
485 commits = list.slice(0, 50).map((c) => ({
486 id: c.sha,
487 message: c.message,
488 timestamp: c.date,
489 }));
490 if (list[0]) {
491 pusherName = list[0].author || pusherName;
492 pusherEmail = list[0].authorEmail || pusherEmail;
493 }
494 } catch {
495 /* ignore — payload still valid with empty commits[] */
496 }
497 return {
498 payload: {
499 event: "push",
500 repository: { full_name: `${args.owner}/${args.repo}` },
501 ref: args.ref,
502 after: args.after,
503 before: args.before,
504 pusher: { name: pusherName, email: pusherEmail },
505 commits,
506 // Ancillary fields — receiver may ignore but they're useful for logs:
507 sent_at: now.toISOString(),
508 source: "gluecron",
509 },
510 pusherName,
511 pusherEmail,
512 };
513}
514
9ecf5a4Claude515async function triggerVapronDeploy(
ba93444Claude516 args: TriggerArgs,
517 opts: TriggerOptions = {}
fc1817aClaude518): Promise<void> {
ba93444Claude519 const fetchImpl = opts.fetchImpl ?? fetch;
520 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
521 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
522 const now = opts.now ?? (() => new Date());
523
3ef4c9dClaude524 let deployId = "";
fc1817aClaude525 try {
3ef4c9dClaude526 const [row] = await db
527 .insert(deployments)
528 .values({
ba93444Claude529 repositoryId: args.repositoryId,
3ef4c9dClaude530 environment: "production",
ba93444Claude531 commitSha: args.after,
532 ref: args.ref,
3ef4c9dClaude533 status: "pending",
9ecf5a4Claude534 target: "vapron",
3ef4c9dClaude535 })
536 .returning();
537 deployId = row?.id || "";
538 } catch {
539 /* ignore */
fc1817aClaude540 }
541
ba93444Claude542 const { payload } = await buildPayload(args, now());
543 const body = JSON.stringify(payload);
9ecf5a4Claude544 const signature = signBody(body, config.vapronHmacSecret);
ba93444Claude545
546 const headers: Record<string, string> = {
547 "Content-Type": "application/json",
548 "User-Agent": "gluecron-webhook/1",
549 "X-Gluecron-Event": "push",
550 "X-Gluecron-Delivery": cryptoRandomId(),
551 };
552 if (signature) headers["X-Gluecron-Signature"] = signature;
e61e6cdccanty labs553 // Vapron tenant auth: API key rides as a bearer alongside the HMAC
554 // signature (key = who is calling, signature = payload integrity).
555 if (config.vapronApiKey)
556 headers["Authorization"] = `Bearer ${config.vapronApiKey}`;
ba93444Claude557
558 let lastStatus = 0;
559 let lastError = "";
560 let success = false;
561
562 // Up to delays.length + 1 attempts (initial try + each delay).
563 const totalAttempts = delays.length + 1;
564 for (let attempt = 0; attempt < totalAttempts; attempt++) {
565 try {
9ecf5a4Claude566 const response = await fetchImpl(config.vapronDeployUrl, {
ba93444Claude567 method: "POST",
568 headers,
569 body,
570 });
571 lastStatus = response.status;
572 console.log(
9ecf5a4Claude573 `[vapron] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
ba93444Claude574 );
575 if (response.ok) {
576 success = true;
577 break;
578 }
579 // 4xx (except 408/429) is unrecoverable — stop retrying.
580 if (response.status >= 400 && response.status < 500 &&
581 response.status !== 408 && response.status !== 429) {
582 break;
583 }
584 } catch (err) {
585 lastError = err instanceof Error ? err.message : String(err);
586 console.error(
9ecf5a4Claude587 `[vapron] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
ba93444Claude588 );
3ef4c9dClaude589 }
ba93444Claude590 const nextDelay = delays[attempt];
591 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
592 await sleep(nextDelay);
1e162a8Claude593 }
ba93444Claude594 }
595
596 if (deployId) {
597 try {
3ef4c9dClaude598 await db
599 .update(deployments)
600 .set({
ba93444Claude601 status: success ? "success" : "failed",
602 blockedReason: success
603 ? null
604 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude605 completedAt: new Date(),
606 })
607 .where(eq(deployments.id, deployId));
ba93444Claude608 } catch {
609 /* ignore */
3ef4c9dClaude610 }
611 }
612
ba93444Claude613 if (!success && deployId) {
614 void onDeployFailure({
615 repositoryId: args.repositoryId,
616 deploymentId: deployId,
617 ref: args.ref,
618 commitSha: args.after,
9ecf5a4Claude619 target: "vapron",
ba93444Claude620 errorMessage: lastError || `HTTP ${lastStatus}`,
621 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude622 }
623}
43cf9b0Claude624
ba93444Claude625function cryptoRandomId(): string {
626 // Short opaque delivery ID for log correlation. Not security-sensitive.
627 const bytes = new Uint8Array(8);
628 crypto.getRandomValues(bytes);
629 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
630}
631
a686079Claude632/**
633 * Resolve `owner/repo` to its DB repository.id and dispatch the
634 * semantic-index update. Pulls the list of changed paths via
635 * `git diff --name-only`, dropping any deletions (handled implicitly
636 * because deleted blobs simply don't resolve in `indexChangedFiles`).
637 *
638 * Never throws — exhaust every external call inside a try/catch so the
639 * push completes even if Postgres or the embedding API is down.
640 */
641async function fireSemanticIndex(
642 owner: string,
643 repo: string,
644 oldSha: string,
645 newSha: string
646): Promise<void> {
647 let repositoryId = "";
648 try {
649 const [row] = await db
650 .select({ id: repositories.id })
651 .from(repositories)
652 .innerJoin(users, eq(repositories.ownerId, users.id))
653 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
654 .limit(1);
655 repositoryId = row?.id || "";
656 } catch {
657 return;
658 }
659 if (!repositoryId) return;
660
661 let changedPaths: string[] = [];
662 try {
663 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
664 } catch {
665 return;
666 }
667 if (!changedPaths.length) return;
668
669 try {
670 const out = await indexChangedFiles({
671 repositoryId,
672 ownerName: owner,
673 repoName: repo,
674 commitSha: newSha,
675 changedPaths,
676 });
677 if (out.indexed > 0) {
678 console.log(
679 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
680 );
681 }
682 } catch (err) {
683 console.warn("[semantic-index] indexChangedFiles error:", err);
684 }
685}
686
687/**
688 * Returns the list of files touched between `oldSha` and `newSha`. For
689 * the initial push on a branch (oldSha all-zero) we walk every file in
690 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
691 */
692async function listChangedPaths(
693 owner: string,
694 repo: string,
695 oldSha: string,
696 newSha: string
697): Promise<string[]> {
698 const cwd = getRepoPath(owner, repo);
699 const allZero = /^0+$/.test(oldSha);
700 const cmd = allZero
701 ? ["git", "ls-tree", "-r", "--name-only", newSha]
702 : ["git", "diff", "--name-only", oldSha, newSha];
703 try {
704 const proc = Bun.spawn(cmd, {
705 cwd,
706 stdout: "pipe",
707 stderr: "pipe",
708 });
709 const text = await new Response(proc.stdout).text();
710 await proc.exited;
711 return text
712 .split("\n")
713 .map((s) => s.trim())
714 .filter((s) => s.length > 0);
715 } catch {
716 return [];
717 }
718}
719
4bbacbeClaude720/**
721 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
722 * non-default branch on a `preview_builds_enabled` repo.
723 *
724 * Resolves the repo row once, fetches the default branch + opt-out flag,
725 * then upserts one preview row per pushed ref that isn't the default.
726 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
727 * are skipped — they shouldn't create preview rows. Never throws.
728 */
729async function firePreviewBuilds(
730 owner: string,
731 repo: string,
732 refs: PushRef[]
733): Promise<void> {
734 // Filter to live branch pushes only.
735 const branchRefs = refs.filter(
736 (r) =>
737 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
738 );
739 if (branchRefs.length === 0) return;
740
741 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
742 try {
743 const [row] = await db
744 .select({
745 id: repositories.id,
746 previewBuildsEnabled: repositories.previewBuildsEnabled,
747 defaultBranch: repositories.defaultBranch,
748 })
749 .from(repositories)
750 .innerJoin(users, eq(repositories.ownerId, users.id))
751 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
752 .limit(1);
753 repoRow = row || null;
754 } catch {
755 return;
756 }
757 if (!repoRow) return;
758 if (!repoRow.previewBuildsEnabled) return;
759
760 for (const ref of branchRefs) {
761 const branchName = ref.refName.replace("refs/heads/", "");
762 if (branchName === repoRow.defaultBranch) continue;
763 try {
764 await enqueuePreviewBuild({
765 repositoryId: repoRow.id,
766 ownerName: owner,
767 repoName: repo,
768 branchName,
769 commitSha: ref.newSha,
770 });
771 } catch (err) {
772 console.warn(
773 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
774 err instanceof Error ? err.message : err
775 );
776 }
777 }
778}
779
d199847Claude780/**
781 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
782 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
783 * on missing repo or DB error — pushes never block. Never throws.
784 */
785async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
786 let repositoryId = "";
787 try {
788 const [row] = await db
789 .select({ id: repositories.id })
790 .from(repositories)
791 .innerJoin(users, eq(repositories.ownerId, users.id))
792 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
793 .limit(1);
794 repositoryId = row?.id || "";
795 } catch {
796 return;
797 }
798 if (!repositoryId) return;
799 try {
800 const out = await runDocDriftCheckForRepo(repositoryId);
801 if (out.docs > 0 || out.proposed > 0) {
802 console.log(
803 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
804 );
805 }
806 } catch (err) {
807 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
808 }
809}
810
783dd46Claude811/**
812 * Block ST — fan out the push to any server targets that watch this
813 * (repo, branch). One sequential deploy per target so a slow box can't
814 * stall the next push, but multiple matching targets run in parallel.
815 * Every failure is contained to its own deploy row + console warn —
816 * nothing here can break the push path.
817 */
818async function fireServerTargetDeploys(
819 owner: string,
820 repo: string,
821 refs: PushRef[]
822): Promise<void> {
823 const liveRefs = refs.filter(
824 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
825 );
826 if (liveRefs.length === 0) return;
827
828 let repositoryId = "";
829 try {
830 const [row] = await db
831 .select({ id: repositories.id })
832 .from(repositories)
833 .innerJoin(users, eq(repositories.ownerId, users.id))
834 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
835 .limit(1);
836 repositoryId = row?.id || "";
837 } catch {
838 return;
839 }
840 if (!repositoryId) return;
841
842 await Promise.all(
843 liveRefs.map(async (ref) => {
844 const branch = ref.refName.replace("refs/heads/", "");
845 let targets;
846 try {
847 targets = await findTargetsForPush({ repositoryId, branch });
848 } catch {
849 return;
850 }
851 if (!targets.length) return;
852
853 await Promise.all(
854 targets.map(async (target) => {
855 try {
856 const env = await resolveEnv(target.id);
857 const deployId = await startDeployRow({
858 targetId: target.id,
859 commitSha: ref.newSha,
860 ref: ref.refName,
861 triggerSource: "push",
862 });
863 const result = await deployToTarget(target, {
864 commitSha: ref.newSha,
865 ref: ref.refName,
866 env,
867 });
868 if (deployId) {
869 await finishDeployRow({
870 id: deployId,
871 exitCode: result.exitCode,
872 stdout: result.stdout,
873 stderr: result.stderr,
874 });
875 }
876 console.log(
877 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
878 );
879 } catch (err) {
880 console.warn(
881 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
882 );
883 }
884 })
885 );
886 })
887 );
888}
889
da3fc18Claude890/**
891 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
892 * Resolves the repo DB row once, then runs the scanner on each live push.
893 * Swallows all errors so a scanner failure never affects the push path.
894 */
895async function fireDependencyScan(
896 owner: string,
897 repo: string,
898 refs: PushRef[]
899): Promise<void> {
900 const liveRefs = refs.filter(
901 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
902 );
903 if (liveRefs.length === 0) return;
904
905 let repoRow: { id: string; ownerId: string } | null = null;
906 try {
907 const [row] = await db
908 .select({ id: repositories.id, ownerId: repositories.ownerId })
909 .from(repositories)
910 .innerJoin(users, eq(repositories.ownerId, users.id))
911 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
912 .limit(1);
913 repoRow = row || null;
914 } catch {
915 return;
916 }
917 if (!repoRow) return;
918
919 for (const ref of liveRefs) {
920 try {
921 const findings = await scanDependencies(
922 repoRow.id,
923 owner,
924 repo,
925 ref.newSha,
926 ref.oldSha,
927 repoRow.ownerId
928 );
929 if (findings.length > 0) {
930 console.log(
931 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
932 );
933 }
934 } catch (err) {
935 console.warn(
936 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
937 err instanceof Error ? err.message : err
938 );
939 }
940 }
941}
942
9c5223fClaude943/**
944 * Migration 0088 — trigger repo onboarding on first push to the default branch.
945 * Detects "first push" by checking whether oldSha is all-zeros on a push to
946 * the repo's default branch (or any branch for a repo with no prior history).
947 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
948 * Never throws.
949 */
950async function fireRepoOnboarding(
951 owner: string,
952 repo: string,
953 refs: PushRef[]
954): Promise<void> {
955 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
956 const firstPushRefs = refs.filter(
957 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
958 );
959 if (firstPushRefs.length === 0) return;
960
961 let repositoryId = "";
962 try {
963 const [row] = await db
964 .select({ id: repositories.id })
965 .from(repositories)
966 .innerJoin(users, eq(repositories.ownerId, users.id))
967 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
968 .limit(1);
969 repositoryId = row?.id || "";
970 } catch {
971 return;
972 }
973 if (!repositoryId) return;
974
975 await ensureRepoOnboarding(repositoryId, owner, repo);
976}
977
43cf9b0Claude978/** Test-only access to internal helpers. */
a686079Claude979export const __test = {
9ecf5a4Claude980 triggerVapronDeploy,
a686079Claude981 signBody,
982 buildPayload,
983 RETRY_DELAYS_MS,
984 listChangedPaths,
985 fireSemanticIndex,
4bbacbeClaude986 firePreviewBuilds,
d199847Claude987 fireDocDriftCheck,
783dd46Claude988 fireServerTargetDeploys,
da3fc18Claude989 fireDependencyScan,
9c5223fClaude990 fireRepoOnboarding,
a686079Claude991};