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.tsBlame976 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";
6682dbeClaude42import { audit } from "../lib/notify";
43
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
64aa989Claude142 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
143 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
144 try {
6682dbeClaude145 const repair = await withTestIsolation(() => autoRepair(owner, repo, branchName));
64aa989Claude146 if (repair.repaired) {
147 console.log(
148 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
149 );
150 }
151 } catch (err) {
152 console.error(`[autorepair] error:`, err);
2c34075Claude153 }
154 }
fc1817aClaude155
2c34075Claude156 // 2. Push analysis
157 try {
158 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
159 console.log(
160 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
161 );
162 if (analysis.riskScore > 50) {
163 console.warn(
164 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
165 );
166 }
167 if (analysis.breakingChangeSignals.length > 0) {
168 console.warn(
169 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
170 );
171 }
172 } catch (err) {
173 console.error(`[push-analysis] error:`, err);
174 }
175
6682dbeClaude176 // 2b. Blast-radii security gate — flag pushes touching security-sensitive paths.
177 void (async () => {
178 try {
179 const repoPath = getRepoPath(owner, repo);
180 const flaggedPaths = await detectBlastRadiusPaths(repoPath, ref.oldSha, ref.newSha);
181 if (flaggedPaths.length > 0) {
182 console.warn(
183 `[blast-radii] ${owner}/${repo}@${branchName}: security-sensitive paths modified: ${flaggedPaths.join(", ")}`
184 );
185 void audit({
186 repositoryId: repoId || undefined,
187 action: "blast_radii.security_path_flagged",
188 targetType: "push",
189 metadata: {
190 owner,
191 repo,
192 branch: branchName,
193 newSha: ref.newSha,
194 flaggedPaths,
195 },
196 });
197 }
198 } catch (err) {
199 console.warn("[blast-radii] check error:", err);
200 }
201 })();
202
2c34075Claude203 // 3. Health score (async, don't block)
204 computeHealthScore(owner, repo).then((report) => {
205 console.log(
206 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
207 );
208 }).catch((err) => {
209 console.error(`[health] error:`, err);
210 });
211 }
212
170ddb2Claude213 // 4. GateTest scan — fire-and-forget notification on every push. The
214 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
215 // deployments pay no overhead. Results flow back via the inbound
216 // webhook at POST /api/hooks/gatetest.
217 for (const ref of refs) {
218 if (ref.newSha.startsWith("0000")) continue;
219 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
220 console.warn("[gatetest] notify error:", err)
221 );
222 }
2c34075Claude223
a686079Claude224 // 4b. Continuous semantic index — embed changed files on every push so
225 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
226 // all failures are swallowed inside semantic-index.ts so a missing
227 // pgvector extension or absent embeddings API key never breaks the
228 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
229 for (const ref of refs) {
230 if (ref.newSha.startsWith("0000")) continue;
231 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
232 console.warn("[semantic-index] dispatch error:", err)
233 );
234 }
235
4bbacbeClaude236 // 4c. Per-branch preview URLs (migration 0062). Every push to a
237 // non-default branch enqueues a preview-build row. Gated on
238 // repositories.preview_builds_enabled (default on) so owners can
239 // opt out per-repo via repo-settings. Fire-and-forget; failures
240 // never break the push path.
241 void firePreviewBuilds(owner, repo, refs).catch((err) =>
242 console.warn("[branch-previews] dispatch error:", err)
243 );
244
6efae38Claude245 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
246 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
247 // and debug console.log calls. Opens one issue per finding type per
f183fdfClaude248 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1 and
249 // per-repo autoIssuesMode. Fire-and-forget; never blocks the push path.
6efae38Claude250 for (const ref of refs) {
251 if (ref.newSha.startsWith("0000")) continue;
f183fdfClaude252 if (automationSettings && isAutomationOn(automationSettings.autoIssuesMode)) {
253 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
254 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
255 );
256 }
6efae38Claude257 }
258
da3fc18Claude259 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
260 // every push that touches a recognized manifest file (package.json,
261 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
262 // for critical/high findings and updates a weekly digest for
263 // medium/low. Fire-and-forget; never blocks the push path.
264 if (config.dependencyScanEnabled) {
265 void fireDependencyScan(owner, repo, refs).catch((err) =>
266 console.warn("[dependency-scanner] dispatch error:", err)
267 );
268 }
269
d199847Claude270 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
271 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
272 // regions, hashes the referenced source, and opens a PR labelled
f183fdfClaude273 // `ai:doc-update` when the prose drifts. Gated on per-repo
274 // docDriftMode. Fire-and-forget; failures are swallowed inside
275 // ai-doc-updater.ts so a missing anthropic key or empty
276 // doc_tracking table never breaks the push.
277 if (automationSettings && isAutomationOn(automationSettings.docDriftMode)) {
278 void fireDocDriftCheck(owner, repo).catch((err) =>
279 console.warn("[ai-doc-updater] dispatch error:", err)
280 );
281 }
d199847Claude282
9c5223fClaude283 // 4g. Smart empty states — repo onboarding. On the very first push to a
284 // repo's default branch (oldSha all-zeros), generate a README draft,
285 // suggested labels, and gates.yml starter using Claude Sonnet.
286 // Idempotent: ensureRepoOnboarding skips if a row already exists.
287 // Fire-and-forget; never blocks the push path.
288 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
289 console.warn("[repo-onboarding] dispatch error:", err)
290 );
291
9ecf5a4Claude292 // 5. Vapron deploy (BLK-016) — only fires for the configured Vapron repo
293 // (VAPRON_REPO, default `ccantynz-alt/vapron`; legacy CRONTECH_REPO honored)
294 // on a push to its
ba93444Claude295 // default branch. The branch case (`Main` vs `main`) is determined by
296 // the bare repo's HEAD, not hardcoded.
9ecf5a4Claude297 if (`${owner}/${repo}` === config.vapronRepo) {
a28cedeClaude298 let defaultBranch =
299 (await getDefaultBranch(owner, repo).catch((err) => {
300 console.warn(
301 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
302 err instanceof Error ? err.message : err
303 );
304 return null;
305 })) || "main";
ba93444Claude306 const targetRef = `refs/heads/${defaultBranch}`;
307 const deployPush = refs.find(
308 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
309 );
310 if (deployPush) {
311 let repositoryId = "";
312 try {
313 const [row] = await db
314 .select({ id: repositories.id })
315 .from(repositories)
316 .innerJoin(users, eq(repositories.ownerId, users.id))
317 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
318 .limit(1);
319 repositoryId = row?.id || "";
320 } catch {
321 /* ignore */
322 }
323 if (repositoryId) {
9ecf5a4Claude324 triggerVapronDeploy({
ba93444Claude325 owner,
326 repo,
327 before: deployPush.oldSha,
328 after: deployPush.newSha,
329 ref: targetRef,
330 branch: defaultBranch,
331 repositoryId,
9ecf5a4Claude332 }).catch((err: unknown) => console.error(`[vapron] error:`, err));
ba93444Claude333 }
0316dbbClaude334 }
fc1817aClaude335 }
f2c00b4CC LABS App336
783dd46Claude337 // 5b. Block ST — Server targets. After core post-receive work, fire
338 // deploys against any `server_targets` row whose
339 // (watched_repository_id, watched_branch) matches a pushed ref.
340 // Fire-and-forget; deploy results land in `server_target_deployments`
341 // and the UI at /admin/servers/:id surfaces them.
342 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
343 console.warn("[server-targets] dispatch error:", err)
344 );
345
c9ed210Claude346 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
347 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
348 // webhook for any cloud_deploy_configs rows matching the pushed branch.
349 // Fire-and-forget; all failures are swallowed so the push path is
350 // never blocked.
351 void fireCloudDeploys(owner, repo, refs).catch((err) =>
352 console.warn("[cloud-deploy] dispatch error:", err)
353 );
354
f2c00b4CC LABS App355 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
356 // main, fire the local deploy via scripts/self-deploy.sh. The script
357 // forks into the background, so this call returns immediately (git
358 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
359 // avoid firing on customer repos that happen to be named "Gluecron.com".
360 const selfHostRepo = process.env.SELF_HOST_REPO;
361 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
362 const mainRef = refs.find(
363 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
364 );
365 if (mainRef) {
366 const scriptPath =
367 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
368 "/opt/gluecron/scripts/self-deploy.sh";
369 try {
370 const child = __selfHostSpawn(
371 [scriptPath, mainRef.oldSha, mainRef.newSha],
372 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
373 );
374 try {
375 (child as any)?.unref?.();
376 } catch {
377 /* unref optional */
378 }
379 console.log(
380 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
381 );
382 } catch (err) {
383 console.error(`[self-host] failed to spawn:`, err);
384 }
385 }
386 }
387}
388
389// BLOCK W — DI seam so the test suite can capture the spawn call without
390// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
391// callers go straight to Bun.spawn.
bf19c50Test User392const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App393 Bun.spawn(cmd, opts);
bf19c50Test User394let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
395/**
396 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
397 */
398export function __setSelfHostSpawnForTests(
399 fn: typeof __selfHostSpawn | null
400): void {
401 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude402}
403
43cf9b0Claude404/**
9ecf5a4Claude405 * BLK-016 — outbound deploy webhook for Vapron's deploy-agent (formerly Crontech).
43cf9b0Claude406 *
9ecf5a4Claude407 * Wire contract (matches Vapron's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude408 *
5e67f6bccanty labs409 * POST https://vapron.ai/api/hooks/gluecron/push
410 * (path verified live 2026-07-14 — the receiver returns 401 on unsigned
411 * payloads; the pre-move /api/webhooks/gluecron-push now 404s)
43cf9b0Claude412 * Content-Type: application/json
ba93444Claude413 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude414 *
415 * {
ba93444Claude416 * "event": "push",
9ecf5a4Claude417 * "repository": { "full_name": "ccantynz-alt/vapron" },
ba93444Claude418 * "ref": "refs/heads/Main",
419 * "after": "<40-hex commit SHA>",
420 * "before": "<40-hex previous SHA>",
421 * "pusher": { "name": "<author>", "email": "<email>" },
422 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude423 * }
424 *
ba93444Claude425 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude426 *
ba93444Claude427 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
428 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
9ecf5a4Claude429 * is unset the signature header is omitted and Vapron is expected to reject —
ba93444Claude430 * we still record the deploy row as failed.
43cf9b0Claude431 */
ba93444Claude432const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
433
434interface TriggerArgs {
435 owner: string;
436 repo: string;
437 before: string;
438 after: string;
439 ref: string;
440 branch: string;
441 repositoryId: string;
442}
443
444interface TriggerOptions {
445 fetchImpl?: typeof fetch;
446 sleep?: (ms: number) => Promise<void>;
447 retryDelaysMs?: number[];
448 now?: () => Date;
449}
450
451function signBody(body: string, secret: string): string | null {
452 if (!secret) return null;
453 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
454}
455
456async function buildPayload(args: TriggerArgs, now: Date): Promise<{
457 payload: Record<string, unknown>;
458 pusherName: string;
459 pusherEmail: string;
460}> {
461 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
462 // `before` may be all-zeros for a first push to the branch — commitsBetween
463 // handles that by treating null `from` as "everything reachable from `to`".
464 const fromSha = /^0+$/.test(args.before) ? null : args.before;
465 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
466 let pusherName = "gluecron";
467 let pusherEmail = "noreply@gluecron.local";
468 try {
469 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
470 commits = list.slice(0, 50).map((c) => ({
471 id: c.sha,
472 message: c.message,
473 timestamp: c.date,
474 }));
475 if (list[0]) {
476 pusherName = list[0].author || pusherName;
477 pusherEmail = list[0].authorEmail || pusherEmail;
478 }
479 } catch {
480 /* ignore — payload still valid with empty commits[] */
481 }
482 return {
483 payload: {
484 event: "push",
485 repository: { full_name: `${args.owner}/${args.repo}` },
486 ref: args.ref,
487 after: args.after,
488 before: args.before,
489 pusher: { name: pusherName, email: pusherEmail },
490 commits,
491 // Ancillary fields — receiver may ignore but they're useful for logs:
492 sent_at: now.toISOString(),
493 source: "gluecron",
494 },
495 pusherName,
496 pusherEmail,
497 };
498}
499
9ecf5a4Claude500async function triggerVapronDeploy(
ba93444Claude501 args: TriggerArgs,
502 opts: TriggerOptions = {}
fc1817aClaude503): Promise<void> {
ba93444Claude504 const fetchImpl = opts.fetchImpl ?? fetch;
505 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
506 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
507 const now = opts.now ?? (() => new Date());
508
3ef4c9dClaude509 let deployId = "";
fc1817aClaude510 try {
3ef4c9dClaude511 const [row] = await db
512 .insert(deployments)
513 .values({
ba93444Claude514 repositoryId: args.repositoryId,
3ef4c9dClaude515 environment: "production",
ba93444Claude516 commitSha: args.after,
517 ref: args.ref,
3ef4c9dClaude518 status: "pending",
9ecf5a4Claude519 target: "vapron",
3ef4c9dClaude520 })
521 .returning();
522 deployId = row?.id || "";
523 } catch {
524 /* ignore */
fc1817aClaude525 }
526
ba93444Claude527 const { payload } = await buildPayload(args, now());
528 const body = JSON.stringify(payload);
9ecf5a4Claude529 const signature = signBody(body, config.vapronHmacSecret);
ba93444Claude530
531 const headers: Record<string, string> = {
532 "Content-Type": "application/json",
533 "User-Agent": "gluecron-webhook/1",
534 "X-Gluecron-Event": "push",
535 "X-Gluecron-Delivery": cryptoRandomId(),
536 };
537 if (signature) headers["X-Gluecron-Signature"] = signature;
e61e6cdccanty labs538 // Vapron tenant auth: API key rides as a bearer alongside the HMAC
539 // signature (key = who is calling, signature = payload integrity).
540 if (config.vapronApiKey)
541 headers["Authorization"] = `Bearer ${config.vapronApiKey}`;
ba93444Claude542
543 let lastStatus = 0;
544 let lastError = "";
545 let success = false;
546
547 // Up to delays.length + 1 attempts (initial try + each delay).
548 const totalAttempts = delays.length + 1;
549 for (let attempt = 0; attempt < totalAttempts; attempt++) {
550 try {
9ecf5a4Claude551 const response = await fetchImpl(config.vapronDeployUrl, {
ba93444Claude552 method: "POST",
553 headers,
554 body,
555 });
556 lastStatus = response.status;
557 console.log(
9ecf5a4Claude558 `[vapron] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
ba93444Claude559 );
560 if (response.ok) {
561 success = true;
562 break;
563 }
564 // 4xx (except 408/429) is unrecoverable — stop retrying.
565 if (response.status >= 400 && response.status < 500 &&
566 response.status !== 408 && response.status !== 429) {
567 break;
568 }
569 } catch (err) {
570 lastError = err instanceof Error ? err.message : String(err);
571 console.error(
9ecf5a4Claude572 `[vapron] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
ba93444Claude573 );
3ef4c9dClaude574 }
ba93444Claude575 const nextDelay = delays[attempt];
576 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
577 await sleep(nextDelay);
1e162a8Claude578 }
ba93444Claude579 }
580
581 if (deployId) {
582 try {
3ef4c9dClaude583 await db
584 .update(deployments)
585 .set({
ba93444Claude586 status: success ? "success" : "failed",
587 blockedReason: success
588 ? null
589 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude590 completedAt: new Date(),
591 })
592 .where(eq(deployments.id, deployId));
ba93444Claude593 } catch {
594 /* ignore */
3ef4c9dClaude595 }
596 }
597
ba93444Claude598 if (!success && deployId) {
599 void onDeployFailure({
600 repositoryId: args.repositoryId,
601 deploymentId: deployId,
602 ref: args.ref,
603 commitSha: args.after,
9ecf5a4Claude604 target: "vapron",
ba93444Claude605 errorMessage: lastError || `HTTP ${lastStatus}`,
606 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude607 }
608}
43cf9b0Claude609
ba93444Claude610function cryptoRandomId(): string {
611 // Short opaque delivery ID for log correlation. Not security-sensitive.
612 const bytes = new Uint8Array(8);
613 crypto.getRandomValues(bytes);
614 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
615}
616
a686079Claude617/**
618 * Resolve `owner/repo` to its DB repository.id and dispatch the
619 * semantic-index update. Pulls the list of changed paths via
620 * `git diff --name-only`, dropping any deletions (handled implicitly
621 * because deleted blobs simply don't resolve in `indexChangedFiles`).
622 *
623 * Never throws — exhaust every external call inside a try/catch so the
624 * push completes even if Postgres or the embedding API is down.
625 */
626async function fireSemanticIndex(
627 owner: string,
628 repo: string,
629 oldSha: string,
630 newSha: string
631): Promise<void> {
632 let repositoryId = "";
633 try {
634 const [row] = await db
635 .select({ id: repositories.id })
636 .from(repositories)
637 .innerJoin(users, eq(repositories.ownerId, users.id))
638 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
639 .limit(1);
640 repositoryId = row?.id || "";
641 } catch {
642 return;
643 }
644 if (!repositoryId) return;
645
646 let changedPaths: string[] = [];
647 try {
648 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
649 } catch {
650 return;
651 }
652 if (!changedPaths.length) return;
653
654 try {
655 const out = await indexChangedFiles({
656 repositoryId,
657 ownerName: owner,
658 repoName: repo,
659 commitSha: newSha,
660 changedPaths,
661 });
662 if (out.indexed > 0) {
663 console.log(
664 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
665 );
666 }
667 } catch (err) {
668 console.warn("[semantic-index] indexChangedFiles error:", err);
669 }
670}
671
672/**
673 * Returns the list of files touched between `oldSha` and `newSha`. For
674 * the initial push on a branch (oldSha all-zero) we walk every file in
675 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
676 */
677async function listChangedPaths(
678 owner: string,
679 repo: string,
680 oldSha: string,
681 newSha: string
682): Promise<string[]> {
683 const cwd = getRepoPath(owner, repo);
684 const allZero = /^0+$/.test(oldSha);
685 const cmd = allZero
686 ? ["git", "ls-tree", "-r", "--name-only", newSha]
687 : ["git", "diff", "--name-only", oldSha, newSha];
688 try {
689 const proc = Bun.spawn(cmd, {
690 cwd,
691 stdout: "pipe",
692 stderr: "pipe",
693 });
694 const text = await new Response(proc.stdout).text();
695 await proc.exited;
696 return text
697 .split("\n")
698 .map((s) => s.trim())
699 .filter((s) => s.length > 0);
700 } catch {
701 return [];
702 }
703}
704
4bbacbeClaude705/**
706 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
707 * non-default branch on a `preview_builds_enabled` repo.
708 *
709 * Resolves the repo row once, fetches the default branch + opt-out flag,
710 * then upserts one preview row per pushed ref that isn't the default.
711 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
712 * are skipped — they shouldn't create preview rows. Never throws.
713 */
714async function firePreviewBuilds(
715 owner: string,
716 repo: string,
717 refs: PushRef[]
718): Promise<void> {
719 // Filter to live branch pushes only.
720 const branchRefs = refs.filter(
721 (r) =>
722 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
723 );
724 if (branchRefs.length === 0) return;
725
726 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
727 try {
728 const [row] = await db
729 .select({
730 id: repositories.id,
731 previewBuildsEnabled: repositories.previewBuildsEnabled,
732 defaultBranch: repositories.defaultBranch,
733 })
734 .from(repositories)
735 .innerJoin(users, eq(repositories.ownerId, users.id))
736 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
737 .limit(1);
738 repoRow = row || null;
739 } catch {
740 return;
741 }
742 if (!repoRow) return;
743 if (!repoRow.previewBuildsEnabled) return;
744
745 for (const ref of branchRefs) {
746 const branchName = ref.refName.replace("refs/heads/", "");
747 if (branchName === repoRow.defaultBranch) continue;
748 try {
749 await enqueuePreviewBuild({
750 repositoryId: repoRow.id,
751 ownerName: owner,
752 repoName: repo,
753 branchName,
754 commitSha: ref.newSha,
755 });
756 } catch (err) {
757 console.warn(
758 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
759 err instanceof Error ? err.message : err
760 );
761 }
762 }
763}
764
d199847Claude765/**
766 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
767 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
768 * on missing repo or DB error — pushes never block. Never throws.
769 */
770async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
771 let repositoryId = "";
772 try {
773 const [row] = await db
774 .select({ id: repositories.id })
775 .from(repositories)
776 .innerJoin(users, eq(repositories.ownerId, users.id))
777 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
778 .limit(1);
779 repositoryId = row?.id || "";
780 } catch {
781 return;
782 }
783 if (!repositoryId) return;
784 try {
785 const out = await runDocDriftCheckForRepo(repositoryId);
786 if (out.docs > 0 || out.proposed > 0) {
787 console.log(
788 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
789 );
790 }
791 } catch (err) {
792 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
793 }
794}
795
783dd46Claude796/**
797 * Block ST — fan out the push to any server targets that watch this
798 * (repo, branch). One sequential deploy per target so a slow box can't
799 * stall the next push, but multiple matching targets run in parallel.
800 * Every failure is contained to its own deploy row + console warn —
801 * nothing here can break the push path.
802 */
803async function fireServerTargetDeploys(
804 owner: string,
805 repo: string,
806 refs: PushRef[]
807): Promise<void> {
808 const liveRefs = refs.filter(
809 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
810 );
811 if (liveRefs.length === 0) return;
812
813 let repositoryId = "";
814 try {
815 const [row] = await db
816 .select({ id: repositories.id })
817 .from(repositories)
818 .innerJoin(users, eq(repositories.ownerId, users.id))
819 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
820 .limit(1);
821 repositoryId = row?.id || "";
822 } catch {
823 return;
824 }
825 if (!repositoryId) return;
826
827 await Promise.all(
828 liveRefs.map(async (ref) => {
829 const branch = ref.refName.replace("refs/heads/", "");
830 let targets;
831 try {
832 targets = await findTargetsForPush({ repositoryId, branch });
833 } catch {
834 return;
835 }
836 if (!targets.length) return;
837
838 await Promise.all(
839 targets.map(async (target) => {
840 try {
841 const env = await resolveEnv(target.id);
842 const deployId = await startDeployRow({
843 targetId: target.id,
844 commitSha: ref.newSha,
845 ref: ref.refName,
846 triggerSource: "push",
847 });
848 const result = await deployToTarget(target, {
849 commitSha: ref.newSha,
850 ref: ref.refName,
851 env,
852 });
853 if (deployId) {
854 await finishDeployRow({
855 id: deployId,
856 exitCode: result.exitCode,
857 stdout: result.stdout,
858 stderr: result.stderr,
859 });
860 }
861 console.log(
862 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
863 );
864 } catch (err) {
865 console.warn(
866 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
867 );
868 }
869 })
870 );
871 })
872 );
873}
874
da3fc18Claude875/**
876 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
877 * Resolves the repo DB row once, then runs the scanner on each live push.
878 * Swallows all errors so a scanner failure never affects the push path.
879 */
880async function fireDependencyScan(
881 owner: string,
882 repo: string,
883 refs: PushRef[]
884): Promise<void> {
885 const liveRefs = refs.filter(
886 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
887 );
888 if (liveRefs.length === 0) return;
889
890 let repoRow: { id: string; ownerId: string } | null = null;
891 try {
892 const [row] = await db
893 .select({ id: repositories.id, ownerId: repositories.ownerId })
894 .from(repositories)
895 .innerJoin(users, eq(repositories.ownerId, users.id))
896 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
897 .limit(1);
898 repoRow = row || null;
899 } catch {
900 return;
901 }
902 if (!repoRow) return;
903
904 for (const ref of liveRefs) {
905 try {
906 const findings = await scanDependencies(
907 repoRow.id,
908 owner,
909 repo,
910 ref.newSha,
911 ref.oldSha,
912 repoRow.ownerId
913 );
914 if (findings.length > 0) {
915 console.log(
916 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
917 );
918 }
919 } catch (err) {
920 console.warn(
921 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
922 err instanceof Error ? err.message : err
923 );
924 }
925 }
926}
927
9c5223fClaude928/**
929 * Migration 0088 — trigger repo onboarding on first push to the default branch.
930 * Detects "first push" by checking whether oldSha is all-zeros on a push to
931 * the repo's default branch (or any branch for a repo with no prior history).
932 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
933 * Never throws.
934 */
935async function fireRepoOnboarding(
936 owner: string,
937 repo: string,
938 refs: PushRef[]
939): Promise<void> {
940 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
941 const firstPushRefs = refs.filter(
942 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
943 );
944 if (firstPushRefs.length === 0) return;
945
946 let repositoryId = "";
947 try {
948 const [row] = await db
949 .select({ id: repositories.id })
950 .from(repositories)
951 .innerJoin(users, eq(repositories.ownerId, users.id))
952 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
953 .limit(1);
954 repositoryId = row?.id || "";
955 } catch {
956 return;
957 }
958 if (!repositoryId) return;
959
960 await ensureRepoOnboarding(repositoryId, owner, repo);
961}
962
43cf9b0Claude963/** Test-only access to internal helpers. */
a686079Claude964export const __test = {
9ecf5a4Claude965 triggerVapronDeploy,
a686079Claude966 signBody,
967 buildPayload,
968 RETRY_DELAYS_MS,
969 listChangedPaths,
970 fireSemanticIndex,
4bbacbeClaude971 firePreviewBuilds,
d199847Claude972 fireDocDriftCheck,
783dd46Claude973 fireServerTargetDeploys,
da3fc18Claude974 fireDependencyScan,
9c5223fClaude975 fireRepoOnboarding,
a686079Claude976};