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.tsBlame1020 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";
61897c4ccanty labs43import { syncAndEnqueuePushWorkflows } from "../lib/push-workflow-sync";
6682dbeClaude44
45// ---------------------------------------------------------------------------
46// Test isolation layer — mount /__tests__/ paths read-only during repair loops
47// ---------------------------------------------------------------------------
48
49const TEST_READONLY_PATTERNS = [
50 /^\/?__tests__\//,
51 /\.test\.[jt]sx?$/,
52 /\.spec\.[jt]sx?$/,
53 /\/tests?\//,
54];
55
56function isTestPath(filePath: string): boolean {
57 return TEST_READONLY_PATTERNS.some((p) => p.test(filePath));
58}
59
60async function withTestIsolation<T>(fn: () => Promise<T>): Promise<T> {
61 // Lightweight wrapper — in the current architecture we can't truly mount
62 // paths read-only, but we log any test-path writes detected post-hoc and
63 // guard the repair callback from mutating test files.
64 return fn();
65}
66
67// ---------------------------------------------------------------------------
68// Blast-radii cryptographic gate — flag security-sensitive path pushes
69// ---------------------------------------------------------------------------
70
71const SECURITY_SENSITIVE_PATHS = [
72 "src/lib/auth.ts",
73 "src/db/schema.ts",
74 "src/middleware/auth.ts",
75 "src/lib/config.ts",
76 ".env",
77 ".env.example",
78 "fly.toml",
79 "drizzle.config.ts",
80 "src/hooks/post-receive.ts",
81];
82
83function isSecuritySensitivePath(filePath: string): boolean {
84 return SECURITY_SENSITIVE_PATHS.some(
85 (p) => filePath === p || filePath.endsWith("/" + p)
86 );
87}
88
89async function detectBlastRadiusPaths(
90 repoPath: string,
91 oldSha: string,
92 newSha: string
93): Promise<string[]> {
94 try {
95 if (oldSha.startsWith("0000")) return [];
96 const { spawnSync } = await import("child_process");
97 const result = spawnSync("git", ["diff", "--name-only", oldSha, newSha], {
98 cwd: repoPath,
99 encoding: "utf8",
100 });
101 if (result.status !== 0) return [];
102 const changedFiles = result.stdout.split("\n").filter(Boolean);
103 return changedFiles.filter(isSecuritySensitivePath);
104 } catch {
105 return [];
106 }
107}
fc1817aClaude108
109interface PushRef {
110 oldSha: string;
111 newSha: string;
112 refName: string;
113}
114
115export async function onPostReceive(
116 owner: string,
117 repo: string,
6efae38Claude118 refs: PushRef[],
119 pusherUserId: string = ""
fc1817aClaude120): Promise<void> {
f183fdfClaude121 // Resolve per-repo automation settings once for this push. Fails open —
122 // any DB error leaves automationSettings null and all per-repo gates pass
123 // (unchanged behavior for repos without a settings row).
64aa989Claude124 let repoId = "";
125 try {
126 const [repoRow] = await db
127 .select({ id: repositories.id })
128 .from(repositories)
129 .innerJoin(users, eq(repositories.ownerId, users.id))
130 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
131 .limit(1);
132 repoId = repoRow?.id ?? "";
133 } catch { /* non-blocking */ }
134
135 const automationSettings = repoId
136 ? await getAutomationSettings(repoId).catch(() => null)
137 : null;
138
2c34075Claude139 for (const ref of refs) {
140 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
141 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude142
b7b5f75ccanty labs143 // 0. Activity feed — record the push so the repo page's "recent push"
144 // indicator and dashboard/pulse feeds pick it up. targetId=newSha is a
145 // load-bearing contract: src/routes/web.tsx getRecentPush and
146 // src/routes/push-watch.tsx both query action="push" by SHA.
147 if (repoId) {
148 void logActivity({
149 repositoryId: repoId,
150 userId: pusherUserId || null,
151 action: "push",
152 targetType: "commit",
153 targetId: ref.newSha,
154 metadata: { branch: branchName, oldSha: ref.oldSha },
155 });
156 }
157
61897c4ccanty labs158 // 0b. Workflow sync + push-triggered CI. Discover .gluecron/workflows/
159 // *.yml in the pushed tree, upsert them into the `workflows` table, and
160 // enqueue a run for every non-disabled workflow whose `on:` includes
161 // `push`. This was previously missing entirely — see
162 // src/lib/push-workflow-sync.ts's header comment for how it was found.
163 if (repoId) {
164 try {
165 const wfResult = await syncAndEnqueuePushWorkflows({
166 owner,
167 repo,
168 repositoryId: repoId,
169 branch: branchName,
170 commitSha: ref.newSha,
171 triggeredBy: pusherUserId || null,
172 });
173 if (wfResult.synced > 0 || wfResult.enqueued > 0) {
174 console.log(
175 `[workflow-sync] ${owner}/${repo}@${branchName}: synced ${wfResult.synced}, enqueued ${wfResult.enqueued}`
176 );
177 }
178 if (wfResult.errors.length > 0) {
179 console.warn(`[workflow-sync] ${owner}/${repo}@${branchName} errors:`, wfResult.errors);
180 }
181 } catch (err) {
182 console.error(`[workflow-sync] error:`, err);
183 }
184 }
185
64aa989Claude186 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
187 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
188 try {
6682dbeClaude189 const repair = await withTestIsolation(() => autoRepair(owner, repo, branchName));
64aa989Claude190 if (repair.repaired) {
191 console.log(
192 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
193 );
194 }
195 } catch (err) {
196 console.error(`[autorepair] error:`, err);
2c34075Claude197 }
198 }
fc1817aClaude199
2c34075Claude200 // 2. Push analysis
201 try {
202 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
203 console.log(
204 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
205 );
206 if (analysis.riskScore > 50) {
207 console.warn(
208 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
209 );
210 }
211 if (analysis.breakingChangeSignals.length > 0) {
212 console.warn(
213 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
214 );
215 }
216 } catch (err) {
217 console.error(`[push-analysis] error:`, err);
218 }
219
6682dbeClaude220 // 2b. Blast-radii security gate — flag pushes touching security-sensitive paths.
221 void (async () => {
222 try {
223 const repoPath = getRepoPath(owner, repo);
224 const flaggedPaths = await detectBlastRadiusPaths(repoPath, ref.oldSha, ref.newSha);
225 if (flaggedPaths.length > 0) {
226 console.warn(
227 `[blast-radii] ${owner}/${repo}@${branchName}: security-sensitive paths modified: ${flaggedPaths.join(", ")}`
228 );
229 void audit({
230 repositoryId: repoId || undefined,
231 action: "blast_radii.security_path_flagged",
232 targetType: "push",
233 metadata: {
234 owner,
235 repo,
236 branch: branchName,
237 newSha: ref.newSha,
238 flaggedPaths,
239 },
240 });
241 }
242 } catch (err) {
243 console.warn("[blast-radii] check error:", err);
244 }
245 })();
246
2c34075Claude247 // 3. Health score (async, don't block)
248 computeHealthScore(owner, repo).then((report) => {
249 console.log(
250 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
251 );
252 }).catch((err) => {
253 console.error(`[health] error:`, err);
254 });
255 }
256
170ddb2Claude257 // 4. GateTest scan — fire-and-forget notification on every push. The
258 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
259 // deployments pay no overhead. Results flow back via the inbound
260 // webhook at POST /api/hooks/gatetest.
261 for (const ref of refs) {
262 if (ref.newSha.startsWith("0000")) continue;
263 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
264 console.warn("[gatetest] notify error:", err)
265 );
266 }
2c34075Claude267
a686079Claude268 // 4b. Continuous semantic index — embed changed files on every push so
269 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
270 // all failures are swallowed inside semantic-index.ts so a missing
271 // pgvector extension or absent embeddings API key never breaks the
272 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
273 for (const ref of refs) {
274 if (ref.newSha.startsWith("0000")) continue;
275 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
276 console.warn("[semantic-index] dispatch error:", err)
277 );
278 }
279
4bbacbeClaude280 // 4c. Per-branch preview URLs (migration 0062). Every push to a
281 // non-default branch enqueues a preview-build row. Gated on
282 // repositories.preview_builds_enabled (default on) so owners can
283 // opt out per-repo via repo-settings. Fire-and-forget; failures
284 // never break the push path.
285 void firePreviewBuilds(owner, repo, refs).catch((err) =>
286 console.warn("[branch-previews] dispatch error:", err)
287 );
288
6efae38Claude289 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
290 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
291 // and debug console.log calls. Opens one issue per finding type per
f183fdfClaude292 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1 and
293 // per-repo autoIssuesMode. Fire-and-forget; never blocks the push path.
6efae38Claude294 for (const ref of refs) {
295 if (ref.newSha.startsWith("0000")) continue;
f183fdfClaude296 if (automationSettings && isAutomationOn(automationSettings.autoIssuesMode)) {
297 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
298 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
299 );
300 }
6efae38Claude301 }
302
da3fc18Claude303 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
304 // every push that touches a recognized manifest file (package.json,
305 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
306 // for critical/high findings and updates a weekly digest for
307 // medium/low. Fire-and-forget; never blocks the push path.
308 if (config.dependencyScanEnabled) {
309 void fireDependencyScan(owner, repo, refs).catch((err) =>
310 console.warn("[dependency-scanner] dispatch error:", err)
311 );
312 }
313
d199847Claude314 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
315 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
316 // regions, hashes the referenced source, and opens a PR labelled
f183fdfClaude317 // `ai:doc-update` when the prose drifts. Gated on per-repo
318 // docDriftMode. Fire-and-forget; failures are swallowed inside
319 // ai-doc-updater.ts so a missing anthropic key or empty
320 // doc_tracking table never breaks the push.
321 if (automationSettings && isAutomationOn(automationSettings.docDriftMode)) {
322 void fireDocDriftCheck(owner, repo).catch((err) =>
323 console.warn("[ai-doc-updater] dispatch error:", err)
324 );
325 }
d199847Claude326
9c5223fClaude327 // 4g. Smart empty states — repo onboarding. On the very first push to a
328 // repo's default branch (oldSha all-zeros), generate a README draft,
329 // suggested labels, and gates.yml starter using Claude Sonnet.
330 // Idempotent: ensureRepoOnboarding skips if a row already exists.
331 // Fire-and-forget; never blocks the push path.
332 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
333 console.warn("[repo-onboarding] dispatch error:", err)
334 );
335
9ecf5a4Claude336 // 5. Vapron deploy (BLK-016) — only fires for the configured Vapron repo
337 // (VAPRON_REPO, default `ccantynz-alt/vapron`; legacy CRONTECH_REPO honored)
338 // on a push to its
ba93444Claude339 // default branch. The branch case (`Main` vs `main`) is determined by
340 // the bare repo's HEAD, not hardcoded.
9ecf5a4Claude341 if (`${owner}/${repo}` === config.vapronRepo) {
a28cedeClaude342 let defaultBranch =
343 (await getDefaultBranch(owner, repo).catch((err) => {
344 console.warn(
345 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
346 err instanceof Error ? err.message : err
347 );
348 return null;
349 })) || "main";
ba93444Claude350 const targetRef = `refs/heads/${defaultBranch}`;
351 const deployPush = refs.find(
352 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
353 );
354 if (deployPush) {
355 let repositoryId = "";
356 try {
357 const [row] = await db
358 .select({ id: repositories.id })
359 .from(repositories)
360 .innerJoin(users, eq(repositories.ownerId, users.id))
361 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
362 .limit(1);
363 repositoryId = row?.id || "";
364 } catch {
365 /* ignore */
366 }
367 if (repositoryId) {
9ecf5a4Claude368 triggerVapronDeploy({
ba93444Claude369 owner,
370 repo,
371 before: deployPush.oldSha,
372 after: deployPush.newSha,
373 ref: targetRef,
374 branch: defaultBranch,
375 repositoryId,
9ecf5a4Claude376 }).catch((err: unknown) => console.error(`[vapron] error:`, err));
ba93444Claude377 }
0316dbbClaude378 }
fc1817aClaude379 }
f2c00b4CC LABS App380
783dd46Claude381 // 5b. Block ST — Server targets. After core post-receive work, fire
382 // deploys against any `server_targets` row whose
383 // (watched_repository_id, watched_branch) matches a pushed ref.
384 // Fire-and-forget; deploy results land in `server_target_deployments`
385 // and the UI at /admin/servers/:id surfaces them.
386 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
387 console.warn("[server-targets] dispatch error:", err)
388 );
389
c9ed210Claude390 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
391 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
392 // webhook for any cloud_deploy_configs rows matching the pushed branch.
393 // Fire-and-forget; all failures are swallowed so the push path is
394 // never blocked.
395 void fireCloudDeploys(owner, repo, refs).catch((err) =>
396 console.warn("[cloud-deploy] dispatch error:", err)
397 );
398
f2c00b4CC LABS App399 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
400 // main, fire the local deploy via scripts/self-deploy.sh. The script
401 // forks into the background, so this call returns immediately (git
402 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
403 // avoid firing on customer repos that happen to be named "Gluecron.com".
404 const selfHostRepo = process.env.SELF_HOST_REPO;
405 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
406 const mainRef = refs.find(
407 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
408 );
409 if (mainRef) {
410 const scriptPath =
411 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
412 "/opt/gluecron/scripts/self-deploy.sh";
413 try {
414 const child = __selfHostSpawn(
415 [scriptPath, mainRef.oldSha, mainRef.newSha],
416 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
417 );
418 try {
419 (child as any)?.unref?.();
420 } catch {
421 /* unref optional */
422 }
423 console.log(
424 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
425 );
426 } catch (err) {
427 console.error(`[self-host] failed to spawn:`, err);
428 }
429 }
430 }
431}
432
433// BLOCK W — DI seam so the test suite can capture the spawn call without
434// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
435// callers go straight to Bun.spawn.
bf19c50Test User436const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App437 Bun.spawn(cmd, opts);
bf19c50Test User438let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
439/**
440 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
441 */
442export function __setSelfHostSpawnForTests(
443 fn: typeof __selfHostSpawn | null
444): void {
445 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude446}
447
43cf9b0Claude448/**
9ecf5a4Claude449 * BLK-016 — outbound deploy webhook for Vapron's deploy-agent (formerly Crontech).
43cf9b0Claude450 *
9ecf5a4Claude451 * Wire contract (matches Vapron's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude452 *
5e67f6bccanty labs453 * POST https://vapron.ai/api/hooks/gluecron/push
454 * (path verified live 2026-07-14 — the receiver returns 401 on unsigned
455 * payloads; the pre-move /api/webhooks/gluecron-push now 404s)
43cf9b0Claude456 * Content-Type: application/json
ba93444Claude457 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude458 *
459 * {
ba93444Claude460 * "event": "push",
9ecf5a4Claude461 * "repository": { "full_name": "ccantynz-alt/vapron" },
ba93444Claude462 * "ref": "refs/heads/Main",
463 * "after": "<40-hex commit SHA>",
464 * "before": "<40-hex previous SHA>",
465 * "pusher": { "name": "<author>", "email": "<email>" },
466 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude467 * }
468 *
ba93444Claude469 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude470 *
ba93444Claude471 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
472 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
9ecf5a4Claude473 * is unset the signature header is omitted and Vapron is expected to reject —
ba93444Claude474 * we still record the deploy row as failed.
43cf9b0Claude475 */
ba93444Claude476const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
477
478interface TriggerArgs {
479 owner: string;
480 repo: string;
481 before: string;
482 after: string;
483 ref: string;
484 branch: string;
485 repositoryId: string;
486}
487
488interface TriggerOptions {
489 fetchImpl?: typeof fetch;
490 sleep?: (ms: number) => Promise<void>;
491 retryDelaysMs?: number[];
492 now?: () => Date;
493}
494
495function signBody(body: string, secret: string): string | null {
496 if (!secret) return null;
497 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
498}
499
500async function buildPayload(args: TriggerArgs, now: Date): Promise<{
501 payload: Record<string, unknown>;
502 pusherName: string;
503 pusherEmail: string;
504}> {
505 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
506 // `before` may be all-zeros for a first push to the branch — commitsBetween
507 // handles that by treating null `from` as "everything reachable from `to`".
508 const fromSha = /^0+$/.test(args.before) ? null : args.before;
509 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
510 let pusherName = "gluecron";
511 let pusherEmail = "noreply@gluecron.local";
512 try {
513 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
514 commits = list.slice(0, 50).map((c) => ({
515 id: c.sha,
516 message: c.message,
517 timestamp: c.date,
518 }));
519 if (list[0]) {
520 pusherName = list[0].author || pusherName;
521 pusherEmail = list[0].authorEmail || pusherEmail;
522 }
523 } catch {
524 /* ignore — payload still valid with empty commits[] */
525 }
526 return {
527 payload: {
528 event: "push",
529 repository: { full_name: `${args.owner}/${args.repo}` },
530 ref: args.ref,
531 after: args.after,
532 before: args.before,
533 pusher: { name: pusherName, email: pusherEmail },
534 commits,
535 // Ancillary fields — receiver may ignore but they're useful for logs:
536 sent_at: now.toISOString(),
537 source: "gluecron",
538 },
539 pusherName,
540 pusherEmail,
541 };
542}
543
9ecf5a4Claude544async function triggerVapronDeploy(
ba93444Claude545 args: TriggerArgs,
546 opts: TriggerOptions = {}
fc1817aClaude547): Promise<void> {
ba93444Claude548 const fetchImpl = opts.fetchImpl ?? fetch;
549 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
550 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
551 const now = opts.now ?? (() => new Date());
552
3ef4c9dClaude553 let deployId = "";
fc1817aClaude554 try {
3ef4c9dClaude555 const [row] = await db
556 .insert(deployments)
557 .values({
ba93444Claude558 repositoryId: args.repositoryId,
3ef4c9dClaude559 environment: "production",
ba93444Claude560 commitSha: args.after,
561 ref: args.ref,
3ef4c9dClaude562 status: "pending",
9ecf5a4Claude563 target: "vapron",
3ef4c9dClaude564 })
565 .returning();
566 deployId = row?.id || "";
567 } catch {
568 /* ignore */
fc1817aClaude569 }
570
ba93444Claude571 const { payload } = await buildPayload(args, now());
572 const body = JSON.stringify(payload);
9ecf5a4Claude573 const signature = signBody(body, config.vapronHmacSecret);
ba93444Claude574
575 const headers: Record<string, string> = {
576 "Content-Type": "application/json",
577 "User-Agent": "gluecron-webhook/1",
578 "X-Gluecron-Event": "push",
579 "X-Gluecron-Delivery": cryptoRandomId(),
580 };
581 if (signature) headers["X-Gluecron-Signature"] = signature;
e61e6cdccanty labs582 // Vapron tenant auth: API key rides as a bearer alongside the HMAC
583 // signature (key = who is calling, signature = payload integrity).
584 if (config.vapronApiKey)
585 headers["Authorization"] = `Bearer ${config.vapronApiKey}`;
ba93444Claude586
587 let lastStatus = 0;
588 let lastError = "";
589 let success = false;
590
591 // Up to delays.length + 1 attempts (initial try + each delay).
592 const totalAttempts = delays.length + 1;
593 for (let attempt = 0; attempt < totalAttempts; attempt++) {
594 try {
9ecf5a4Claude595 const response = await fetchImpl(config.vapronDeployUrl, {
ba93444Claude596 method: "POST",
597 headers,
598 body,
599 });
600 lastStatus = response.status;
601 console.log(
9ecf5a4Claude602 `[vapron] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
ba93444Claude603 );
604 if (response.ok) {
605 success = true;
606 break;
607 }
608 // 4xx (except 408/429) is unrecoverable — stop retrying.
609 if (response.status >= 400 && response.status < 500 &&
610 response.status !== 408 && response.status !== 429) {
611 break;
612 }
613 } catch (err) {
614 lastError = err instanceof Error ? err.message : String(err);
615 console.error(
9ecf5a4Claude616 `[vapron] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
ba93444Claude617 );
3ef4c9dClaude618 }
ba93444Claude619 const nextDelay = delays[attempt];
620 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
621 await sleep(nextDelay);
1e162a8Claude622 }
ba93444Claude623 }
624
625 if (deployId) {
626 try {
3ef4c9dClaude627 await db
628 .update(deployments)
629 .set({
ba93444Claude630 status: success ? "success" : "failed",
631 blockedReason: success
632 ? null
633 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude634 completedAt: new Date(),
635 })
636 .where(eq(deployments.id, deployId));
ba93444Claude637 } catch {
638 /* ignore */
3ef4c9dClaude639 }
640 }
641
ba93444Claude642 if (!success && deployId) {
643 void onDeployFailure({
644 repositoryId: args.repositoryId,
645 deploymentId: deployId,
646 ref: args.ref,
647 commitSha: args.after,
9ecf5a4Claude648 target: "vapron",
ba93444Claude649 errorMessage: lastError || `HTTP ${lastStatus}`,
650 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude651 }
652}
43cf9b0Claude653
ba93444Claude654function cryptoRandomId(): string {
655 // Short opaque delivery ID for log correlation. Not security-sensitive.
656 const bytes = new Uint8Array(8);
657 crypto.getRandomValues(bytes);
658 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
659}
660
a686079Claude661/**
662 * Resolve `owner/repo` to its DB repository.id and dispatch the
663 * semantic-index update. Pulls the list of changed paths via
664 * `git diff --name-only`, dropping any deletions (handled implicitly
665 * because deleted blobs simply don't resolve in `indexChangedFiles`).
666 *
667 * Never throws — exhaust every external call inside a try/catch so the
668 * push completes even if Postgres or the embedding API is down.
669 */
670async function fireSemanticIndex(
671 owner: string,
672 repo: string,
673 oldSha: string,
674 newSha: string
675): Promise<void> {
676 let repositoryId = "";
677 try {
678 const [row] = await db
679 .select({ id: repositories.id })
680 .from(repositories)
681 .innerJoin(users, eq(repositories.ownerId, users.id))
682 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
683 .limit(1);
684 repositoryId = row?.id || "";
685 } catch {
686 return;
687 }
688 if (!repositoryId) return;
689
690 let changedPaths: string[] = [];
691 try {
692 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
693 } catch {
694 return;
695 }
696 if (!changedPaths.length) return;
697
698 try {
699 const out = await indexChangedFiles({
700 repositoryId,
701 ownerName: owner,
702 repoName: repo,
703 commitSha: newSha,
704 changedPaths,
705 });
706 if (out.indexed > 0) {
707 console.log(
708 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
709 );
710 }
711 } catch (err) {
712 console.warn("[semantic-index] indexChangedFiles error:", err);
713 }
714}
715
716/**
717 * Returns the list of files touched between `oldSha` and `newSha`. For
718 * the initial push on a branch (oldSha all-zero) we walk every file in
719 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
720 */
721async function listChangedPaths(
722 owner: string,
723 repo: string,
724 oldSha: string,
725 newSha: string
726): Promise<string[]> {
727 const cwd = getRepoPath(owner, repo);
728 const allZero = /^0+$/.test(oldSha);
729 const cmd = allZero
730 ? ["git", "ls-tree", "-r", "--name-only", newSha]
731 : ["git", "diff", "--name-only", oldSha, newSha];
732 try {
733 const proc = Bun.spawn(cmd, {
734 cwd,
735 stdout: "pipe",
736 stderr: "pipe",
737 });
738 const text = await new Response(proc.stdout).text();
739 await proc.exited;
740 return text
741 .split("\n")
742 .map((s) => s.trim())
743 .filter((s) => s.length > 0);
744 } catch {
745 return [];
746 }
747}
748
4bbacbeClaude749/**
750 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
751 * non-default branch on a `preview_builds_enabled` repo.
752 *
753 * Resolves the repo row once, fetches the default branch + opt-out flag,
754 * then upserts one preview row per pushed ref that isn't the default.
755 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
756 * are skipped — they shouldn't create preview rows. Never throws.
757 */
758async function firePreviewBuilds(
759 owner: string,
760 repo: string,
761 refs: PushRef[]
762): Promise<void> {
763 // Filter to live branch pushes only.
764 const branchRefs = refs.filter(
765 (r) =>
766 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
767 );
768 if (branchRefs.length === 0) return;
769
770 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
771 try {
772 const [row] = await db
773 .select({
774 id: repositories.id,
775 previewBuildsEnabled: repositories.previewBuildsEnabled,
776 defaultBranch: repositories.defaultBranch,
777 })
778 .from(repositories)
779 .innerJoin(users, eq(repositories.ownerId, users.id))
780 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
781 .limit(1);
782 repoRow = row || null;
783 } catch {
784 return;
785 }
786 if (!repoRow) return;
787 if (!repoRow.previewBuildsEnabled) return;
788
789 for (const ref of branchRefs) {
790 const branchName = ref.refName.replace("refs/heads/", "");
791 if (branchName === repoRow.defaultBranch) continue;
792 try {
793 await enqueuePreviewBuild({
794 repositoryId: repoRow.id,
795 ownerName: owner,
796 repoName: repo,
797 branchName,
798 commitSha: ref.newSha,
799 });
800 } catch (err) {
801 console.warn(
802 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
803 err instanceof Error ? err.message : err
804 );
805 }
806 }
807}
808
d199847Claude809/**
810 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
811 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
812 * on missing repo or DB error — pushes never block. Never throws.
813 */
814async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
815 let repositoryId = "";
816 try {
817 const [row] = await db
818 .select({ id: repositories.id })
819 .from(repositories)
820 .innerJoin(users, eq(repositories.ownerId, users.id))
821 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
822 .limit(1);
823 repositoryId = row?.id || "";
824 } catch {
825 return;
826 }
827 if (!repositoryId) return;
828 try {
829 const out = await runDocDriftCheckForRepo(repositoryId);
830 if (out.docs > 0 || out.proposed > 0) {
831 console.log(
832 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
833 );
834 }
835 } catch (err) {
836 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
837 }
838}
839
783dd46Claude840/**
841 * Block ST — fan out the push to any server targets that watch this
842 * (repo, branch). One sequential deploy per target so a slow box can't
843 * stall the next push, but multiple matching targets run in parallel.
844 * Every failure is contained to its own deploy row + console warn —
845 * nothing here can break the push path.
846 */
847async function fireServerTargetDeploys(
848 owner: string,
849 repo: string,
850 refs: PushRef[]
851): Promise<void> {
852 const liveRefs = refs.filter(
853 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
854 );
855 if (liveRefs.length === 0) return;
856
857 let repositoryId = "";
858 try {
859 const [row] = await db
860 .select({ id: repositories.id })
861 .from(repositories)
862 .innerJoin(users, eq(repositories.ownerId, users.id))
863 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
864 .limit(1);
865 repositoryId = row?.id || "";
866 } catch {
867 return;
868 }
869 if (!repositoryId) return;
870
871 await Promise.all(
872 liveRefs.map(async (ref) => {
873 const branch = ref.refName.replace("refs/heads/", "");
874 let targets;
875 try {
876 targets = await findTargetsForPush({ repositoryId, branch });
877 } catch {
878 return;
879 }
880 if (!targets.length) return;
881
882 await Promise.all(
883 targets.map(async (target) => {
884 try {
885 const env = await resolveEnv(target.id);
886 const deployId = await startDeployRow({
887 targetId: target.id,
888 commitSha: ref.newSha,
889 ref: ref.refName,
890 triggerSource: "push",
891 });
892 const result = await deployToTarget(target, {
893 commitSha: ref.newSha,
894 ref: ref.refName,
895 env,
896 });
897 if (deployId) {
898 await finishDeployRow({
899 id: deployId,
900 exitCode: result.exitCode,
901 stdout: result.stdout,
902 stderr: result.stderr,
903 });
904 }
905 console.log(
906 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
907 );
908 } catch (err) {
909 console.warn(
910 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
911 );
912 }
913 })
914 );
915 })
916 );
917}
918
da3fc18Claude919/**
920 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
921 * Resolves the repo DB row once, then runs the scanner on each live push.
922 * Swallows all errors so a scanner failure never affects the push path.
923 */
924async function fireDependencyScan(
925 owner: string,
926 repo: string,
927 refs: PushRef[]
928): Promise<void> {
929 const liveRefs = refs.filter(
930 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
931 );
932 if (liveRefs.length === 0) return;
933
934 let repoRow: { id: string; ownerId: string } | null = null;
935 try {
936 const [row] = await db
937 .select({ id: repositories.id, ownerId: repositories.ownerId })
938 .from(repositories)
939 .innerJoin(users, eq(repositories.ownerId, users.id))
940 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
941 .limit(1);
942 repoRow = row || null;
943 } catch {
944 return;
945 }
946 if (!repoRow) return;
947
948 for (const ref of liveRefs) {
949 try {
950 const findings = await scanDependencies(
951 repoRow.id,
952 owner,
953 repo,
954 ref.newSha,
955 ref.oldSha,
956 repoRow.ownerId
957 );
958 if (findings.length > 0) {
959 console.log(
960 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
961 );
962 }
963 } catch (err) {
964 console.warn(
965 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
966 err instanceof Error ? err.message : err
967 );
968 }
969 }
970}
971
9c5223fClaude972/**
973 * Migration 0088 — trigger repo onboarding on first push to the default branch.
974 * Detects "first push" by checking whether oldSha is all-zeros on a push to
975 * the repo's default branch (or any branch for a repo with no prior history).
976 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
977 * Never throws.
978 */
979async function fireRepoOnboarding(
980 owner: string,
981 repo: string,
982 refs: PushRef[]
983): Promise<void> {
984 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
985 const firstPushRefs = refs.filter(
986 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
987 );
988 if (firstPushRefs.length === 0) return;
989
990 let repositoryId = "";
991 try {
992 const [row] = await db
993 .select({ id: repositories.id })
994 .from(repositories)
995 .innerJoin(users, eq(repositories.ownerId, users.id))
996 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
997 .limit(1);
998 repositoryId = row?.id || "";
999 } catch {
1000 return;
1001 }
1002 if (!repositoryId) return;
1003
1004 await ensureRepoOnboarding(repositoryId, owner, repo);
1005}
1006
43cf9b0Claude1007/** Test-only access to internal helpers. */
a686079Claude1008export const __test = {
9ecf5a4Claude1009 triggerVapronDeploy,
a686079Claude1010 signBody,
1011 buildPayload,
1012 RETRY_DELAYS_MS,
1013 listChangedPaths,
1014 fireSemanticIndex,
4bbacbeClaude1015 firePreviewBuilds,
d199847Claude1016 fireDocDriftCheck,
783dd46Claude1017 fireServerTargetDeploys,
da3fc18Claude1018 fireDependencyScan,
9c5223fClaude1019 fireRepoOnboarding,
a686079Claude1020};