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.tsBlame1036 lines · 5 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";
a74f4edccanty labs43import { fireWebhooks } from "../routes/webhooks";
61897c4ccanty labs44import { syncAndEnqueuePushWorkflows } from "../lib/push-workflow-sync";
6682dbeClaude45
46// ---------------------------------------------------------------------------
47// Test isolation layer — mount /__tests__/ paths read-only during repair loops
48// ---------------------------------------------------------------------------
49
50const TEST_READONLY_PATTERNS = [
51 /^\/?__tests__\//,
52 /\.test\.[jt]sx?$/,
53 /\.spec\.[jt]sx?$/,
54 /\/tests?\//,
55];
56
57function isTestPath(filePath: string): boolean {
58 return TEST_READONLY_PATTERNS.some((p) => p.test(filePath));
59}
60
61async function withTestIsolation<T>(fn: () => Promise<T>): Promise<T> {
62 // Lightweight wrapper — in the current architecture we can't truly mount
63 // paths read-only, but we log any test-path writes detected post-hoc and
64 // guard the repair callback from mutating test files.
65 return fn();
66}
67
68// ---------------------------------------------------------------------------
69// Blast-radii cryptographic gate — flag security-sensitive path pushes
70// ---------------------------------------------------------------------------
71
72const SECURITY_SENSITIVE_PATHS = [
73 "src/lib/auth.ts",
74 "src/db/schema.ts",
75 "src/middleware/auth.ts",
76 "src/lib/config.ts",
77 ".env",
78 ".env.example",
79 "fly.toml",
80 "drizzle.config.ts",
81 "src/hooks/post-receive.ts",
82];
83
84function isSecuritySensitivePath(filePath: string): boolean {
85 return SECURITY_SENSITIVE_PATHS.some(
86 (p) => filePath === p || filePath.endsWith("/" + p)
87 );
88}
89
90async function detectBlastRadiusPaths(
91 repoPath: string,
92 oldSha: string,
93 newSha: string
94): Promise<string[]> {
95 try {
96 if (oldSha.startsWith("0000")) return [];
97 const { spawnSync } = await import("child_process");
98 const result = spawnSync("git", ["diff", "--name-only", oldSha, newSha], {
99 cwd: repoPath,
100 encoding: "utf8",
101 });
102 if (result.status !== 0) return [];
103 const changedFiles = result.stdout.split("\n").filter(Boolean);
104 return changedFiles.filter(isSecuritySensitivePath);
105 } catch {
106 return [];
107 }
108}
fc1817aClaude109
110interface PushRef {
111 oldSha: string;
112 newSha: string;
113 refName: string;
114}
115
116export async function onPostReceive(
117 owner: string,
118 repo: string,
6efae38Claude119 refs: PushRef[],
120 pusherUserId: string = ""
fc1817aClaude121): Promise<void> {
f183fdfClaude122 // Resolve per-repo automation settings once for this push. Fails open —
123 // any DB error leaves automationSettings null and all per-repo gates pass
124 // (unchanged behavior for repos without a settings row).
64aa989Claude125 let repoId = "";
126 try {
127 const [repoRow] = await db
128 .select({ id: repositories.id })
129 .from(repositories)
130 .innerJoin(users, eq(repositories.ownerId, users.id))
131 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
132 .limit(1);
133 repoId = repoRow?.id ?? "";
134 } catch { /* non-blocking */ }
135
136 const automationSettings = repoId
137 ? await getAutomationSettings(repoId).catch(() => null)
138 : null;
139
2c34075Claude140 for (const ref of refs) {
141 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
142 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude143
b7b5f75ccanty labs144 // 0. Activity feed — record the push so the repo page's "recent push"
145 // indicator and dashboard/pulse feeds pick it up. targetId=newSha is a
146 // load-bearing contract: src/routes/web.tsx getRecentPush and
147 // src/routes/push-watch.tsx both query action="push" by SHA.
148 if (repoId) {
149 void logActivity({
150 repositoryId: repoId,
151 userId: pusherUserId || null,
152 action: "push",
153 targetType: "commit",
154 targetId: ref.newSha,
155 metadata: { branch: branchName, oldSha: ref.oldSha },
156 });
a74f4edccanty labs157 void fireWebhooks(repoId, "push", {
158 branch: branchName,
159 oldSha: ref.oldSha,
160 newSha: ref.newSha,
161 });
b7b5f75ccanty labs162 }
163
61897c4ccanty labs164 // 0b. Workflow sync + push-triggered CI. Discover .gluecron/workflows/
165 // *.yml in the pushed tree, upsert them into the `workflows` table, and
166 // enqueue a run for every non-disabled workflow whose `on:` includes
167 // `push`. This was previously missing entirely — see
168 // src/lib/push-workflow-sync.ts's header comment for how it was found.
169 if (repoId) {
170 try {
171 const wfResult = await syncAndEnqueuePushWorkflows({
172 owner,
173 repo,
174 repositoryId: repoId,
175 branch: branchName,
176 commitSha: ref.newSha,
177 triggeredBy: pusherUserId || null,
178 });
179 if (wfResult.synced > 0 || wfResult.enqueued > 0) {
180 console.log(
181 `[workflow-sync] ${owner}/${repo}@${branchName}: synced ${wfResult.synced}, enqueued ${wfResult.enqueued}`
182 );
183 }
184 if (wfResult.errors.length > 0) {
185 console.warn(`[workflow-sync] ${owner}/${repo}@${branchName} errors:`, wfResult.errors);
186 }
187 } catch (err) {
188 console.error(`[workflow-sync] error:`, err);
189 }
190 }
191
64aa989Claude192 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
193 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
194 try {
6682dbeClaude195 const repair = await withTestIsolation(() => autoRepair(owner, repo, branchName));
64aa989Claude196 if (repair.repaired) {
197 console.log(
198 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
199 );
200 }
201 } catch (err) {
202 console.error(`[autorepair] error:`, err);
2c34075Claude203 }
204 }
fc1817aClaude205
2c34075Claude206 // 2. Push analysis
207 try {
208 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
209 console.log(
210 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
211 );
212 if (analysis.riskScore > 50) {
213 console.warn(
214 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
215 );
216 }
217 if (analysis.breakingChangeSignals.length > 0) {
218 console.warn(
219 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
220 );
221 }
222 } catch (err) {
223 console.error(`[push-analysis] error:`, err);
224 }
225
6682dbeClaude226 // 2b. Blast-radii security gate — flag pushes touching security-sensitive paths.
227 void (async () => {
228 try {
229 const repoPath = getRepoPath(owner, repo);
230 const flaggedPaths = await detectBlastRadiusPaths(repoPath, ref.oldSha, ref.newSha);
231 if (flaggedPaths.length > 0) {
232 console.warn(
233 `[blast-radii] ${owner}/${repo}@${branchName}: security-sensitive paths modified: ${flaggedPaths.join(", ")}`
234 );
235 void audit({
236 repositoryId: repoId || undefined,
237 action: "blast_radii.security_path_flagged",
238 targetType: "push",
239 metadata: {
240 owner,
241 repo,
242 branch: branchName,
243 newSha: ref.newSha,
244 flaggedPaths,
245 },
246 });
247 }
248 } catch (err) {
249 console.warn("[blast-radii] check error:", err);
250 }
251 })();
252
2c34075Claude253 // 3. Health score (async, don't block)
254 computeHealthScore(owner, repo).then((report) => {
255 console.log(
256 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
257 );
258 }).catch((err) => {
259 console.error(`[health] error:`, err);
260 });
261 }
262
170ddb2Claude263 // 4. GateTest scan — fire-and-forget notification on every push. The
264 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
265 // deployments pay no overhead. Results flow back via the inbound
266 // webhook at POST /api/hooks/gatetest.
267 for (const ref of refs) {
268 if (ref.newSha.startsWith("0000")) continue;
269 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
270 console.warn("[gatetest] notify error:", err)
271 );
272 }
2c34075Claude273
a686079Claude274 // 4b. Continuous semantic index — embed changed files on every push so
275 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
276 // all failures are swallowed inside semantic-index.ts so a missing
277 // pgvector extension or absent embeddings API key never breaks the
278 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
279 for (const ref of refs) {
280 if (ref.newSha.startsWith("0000")) continue;
281 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
282 console.warn("[semantic-index] dispatch error:", err)
283 );
284 }
285
4bbacbeClaude286 // 4c. Per-branch preview URLs (migration 0062). Every push to a
287 // non-default branch enqueues a preview-build row. Gated on
288 // repositories.preview_builds_enabled (default on) so owners can
289 // opt out per-repo via repo-settings. Fire-and-forget; failures
290 // never break the push path.
291 void firePreviewBuilds(owner, repo, refs).catch((err) =>
292 console.warn("[branch-previews] dispatch error:", err)
293 );
294
6efae38Claude295 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
296 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
297 // and debug console.log calls. Opens one issue per finding type per
f183fdfClaude298 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1 and
299 // per-repo autoIssuesMode. Fire-and-forget; never blocks the push path.
6efae38Claude300 for (const ref of refs) {
301 if (ref.newSha.startsWith("0000")) continue;
f183fdfClaude302 if (automationSettings && isAutomationOn(automationSettings.autoIssuesMode)) {
303 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
304 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
305 );
306 }
6efae38Claude307 }
308
da3fc18Claude309 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
310 // every push that touches a recognized manifest file (package.json,
311 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
312 // for critical/high findings and updates a weekly digest for
313 // medium/low. Fire-and-forget; never blocks the push path.
314 if (config.dependencyScanEnabled) {
315 void fireDependencyScan(owner, repo, refs).catch((err) =>
316 console.warn("[dependency-scanner] dispatch error:", err)
317 );
318 }
319
d199847Claude320 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
321 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
322 // regions, hashes the referenced source, and opens a PR labelled
f183fdfClaude323 // `ai:doc-update` when the prose drifts. Gated on per-repo
324 // docDriftMode. Fire-and-forget; failures are swallowed inside
325 // ai-doc-updater.ts so a missing anthropic key or empty
326 // doc_tracking table never breaks the push.
327 if (automationSettings && isAutomationOn(automationSettings.docDriftMode)) {
328 void fireDocDriftCheck(owner, repo).catch((err) =>
329 console.warn("[ai-doc-updater] dispatch error:", err)
330 );
331 }
d199847Claude332
9c5223fClaude333 // 4g. Smart empty states — repo onboarding. On the very first push to a
334 // repo's default branch (oldSha all-zeros), generate a README draft,
335 // suggested labels, and gates.yml starter using Claude Sonnet.
336 // Idempotent: ensureRepoOnboarding skips if a row already exists.
337 // Fire-and-forget; never blocks the push path.
338 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
339 console.warn("[repo-onboarding] dispatch error:", err)
340 );
341
9ecf5a4Claude342 // 5. Vapron deploy (BLK-016) — only fires for the configured Vapron repo
343 // (VAPRON_REPO, default `ccantynz-alt/vapron`; legacy CRONTECH_REPO honored)
344 // on a push to its
ba93444Claude345 // default branch. The branch case (`Main` vs `main`) is determined by
346 // the bare repo's HEAD, not hardcoded.
9ecf5a4Claude347 if (`${owner}/${repo}` === config.vapronRepo) {
a28cedeClaude348 let defaultBranch =
349 (await getDefaultBranch(owner, repo).catch((err) => {
350 console.warn(
351 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
352 err instanceof Error ? err.message : err
353 );
354 return null;
355 })) || "main";
ba93444Claude356 const targetRef = `refs/heads/${defaultBranch}`;
357 const deployPush = refs.find(
358 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
359 );
360 if (deployPush) {
361 let repositoryId = "";
362 try {
363 const [row] = await db
364 .select({ id: repositories.id })
365 .from(repositories)
366 .innerJoin(users, eq(repositories.ownerId, users.id))
367 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
368 .limit(1);
369 repositoryId = row?.id || "";
370 } catch {
371 /* ignore */
372 }
373 if (repositoryId) {
9ecf5a4Claude374 triggerVapronDeploy({
ba93444Claude375 owner,
376 repo,
377 before: deployPush.oldSha,
378 after: deployPush.newSha,
379 ref: targetRef,
380 branch: defaultBranch,
381 repositoryId,
9ecf5a4Claude382 }).catch((err: unknown) => console.error(`[vapron] error:`, err));
ba93444Claude383 }
0316dbbClaude384 }
fc1817aClaude385 }
f2c00b4CC LABS App386
783dd46Claude387 // 5b. Block ST — Server targets. After core post-receive work, fire
388 // deploys against any `server_targets` row whose
389 // (watched_repository_id, watched_branch) matches a pushed ref.
390 // Fire-and-forget; deploy results land in `server_target_deployments`
391 // and the UI at /admin/servers/:id surfaces them.
392 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
393 console.warn("[server-targets] dispatch error:", err)
394 );
395
c9ed210Claude396 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
397 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
398 // webhook for any cloud_deploy_configs rows matching the pushed branch.
399 // Fire-and-forget; all failures are swallowed so the push path is
400 // never blocked.
401 void fireCloudDeploys(owner, repo, refs).catch((err) =>
402 console.warn("[cloud-deploy] dispatch error:", err)
403 );
404
f2c00b4CC LABS App405 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
406 // main, fire the local deploy via scripts/self-deploy.sh. The script
407 // forks into the background, so this call returns immediately (git
408 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
409 // avoid firing on customer repos that happen to be named "Gluecron.com".
410 const selfHostRepo = process.env.SELF_HOST_REPO;
411 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
412 const mainRef = refs.find(
413 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
414 );
415 if (mainRef) {
416 const scriptPath =
417 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
418 "/opt/gluecron/scripts/self-deploy.sh";
419 try {
420 const child = __selfHostSpawn(
421 [scriptPath, mainRef.oldSha, mainRef.newSha],
422 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
423 );
424 try {
425 (child as any)?.unref?.();
426 } catch {
427 /* unref optional */
428 }
429 console.log(
430 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
431 );
432 } catch (err) {
433 console.error(`[self-host] failed to spawn:`, err);
434 }
435 }
436 }
437}
438
439// BLOCK W — DI seam so the test suite can capture the spawn call without
440// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
441// callers go straight to Bun.spawn.
bf19c50Test User442const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App443 Bun.spawn(cmd, opts);
bf19c50Test User444let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
445/**
446 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
447 */
448export function __setSelfHostSpawnForTests(
449 fn: typeof __selfHostSpawn | null
450): void {
451 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude452}
453
43cf9b0Claude454/**
9ecf5a4Claude455 * BLK-016 — outbound deploy webhook for Vapron's deploy-agent (formerly Crontech).
43cf9b0Claude456 *
9ecf5a4Claude457 * Wire contract (matches Vapron's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude458 *
5e67f6bccanty labs459 * POST https://vapron.ai/api/hooks/gluecron/push
460 * (path verified live 2026-07-14 — the receiver returns 401 on unsigned
461 * payloads; the pre-move /api/webhooks/gluecron-push now 404s)
43cf9b0Claude462 * Content-Type: application/json
ba93444Claude463 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude464 *
465 * {
ba93444Claude466 * "event": "push",
9ecf5a4Claude467 * "repository": { "full_name": "ccantynz-alt/vapron" },
ba93444Claude468 * "ref": "refs/heads/Main",
469 * "after": "<40-hex commit SHA>",
470 * "before": "<40-hex previous SHA>",
471 * "pusher": { "name": "<author>", "email": "<email>" },
472 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude473 * }
474 *
ba93444Claude475 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude476 *
ba93444Claude477 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
478 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
9ecf5a4Claude479 * is unset the signature header is omitted and Vapron is expected to reject —
ba93444Claude480 * we still record the deploy row as failed.
43cf9b0Claude481 */
ba93444Claude482const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
483
484interface TriggerArgs {
485 owner: string;
486 repo: string;
487 before: string;
488 after: string;
489 ref: string;
490 branch: string;
491 repositoryId: string;
492}
493
494interface TriggerOptions {
495 fetchImpl?: typeof fetch;
496 sleep?: (ms: number) => Promise<void>;
497 retryDelaysMs?: number[];
498 now?: () => Date;
499}
500
501function signBody(body: string, secret: string): string | null {
502 if (!secret) return null;
503 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
504}
505
506async function buildPayload(args: TriggerArgs, now: Date): Promise<{
507 payload: Record<string, unknown>;
508 pusherName: string;
509 pusherEmail: string;
510}> {
511 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
512 // `before` may be all-zeros for a first push to the branch — commitsBetween
513 // handles that by treating null `from` as "everything reachable from `to`".
514 const fromSha = /^0+$/.test(args.before) ? null : args.before;
515 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
516 let pusherName = "gluecron";
517 let pusherEmail = "noreply@gluecron.local";
518 try {
519 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
520 commits = list.slice(0, 50).map((c) => ({
521 id: c.sha,
522 message: c.message,
523 timestamp: c.date,
524 }));
525 if (list[0]) {
526 pusherName = list[0].author || pusherName;
527 pusherEmail = list[0].authorEmail || pusherEmail;
528 }
529 } catch {
530 /* ignore — payload still valid with empty commits[] */
531 }
532 return {
533 payload: {
534 event: "push",
535 repository: { full_name: `${args.owner}/${args.repo}` },
536 ref: args.ref,
537 after: args.after,
538 before: args.before,
539 pusher: { name: pusherName, email: pusherEmail },
540 commits,
541 // Ancillary fields — receiver may ignore but they're useful for logs:
542 sent_at: now.toISOString(),
543 source: "gluecron",
544 },
545 pusherName,
546 pusherEmail,
547 };
548}
549
9ecf5a4Claude550async function triggerVapronDeploy(
ba93444Claude551 args: TriggerArgs,
552 opts: TriggerOptions = {}
fc1817aClaude553): Promise<void> {
ba93444Claude554 const fetchImpl = opts.fetchImpl ?? fetch;
555 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
556 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
557 const now = opts.now ?? (() => new Date());
558
3ef4c9dClaude559 let deployId = "";
fc1817aClaude560 try {
3ef4c9dClaude561 const [row] = await db
562 .insert(deployments)
563 .values({
ba93444Claude564 repositoryId: args.repositoryId,
3ef4c9dClaude565 environment: "production",
ba93444Claude566 commitSha: args.after,
567 ref: args.ref,
3ef4c9dClaude568 status: "pending",
9ecf5a4Claude569 target: "vapron",
3ef4c9dClaude570 })
571 .returning();
572 deployId = row?.id || "";
573 } catch {
574 /* ignore */
fc1817aClaude575 }
576
ba93444Claude577 const { payload } = await buildPayload(args, now());
578 const body = JSON.stringify(payload);
9ecf5a4Claude579 const signature = signBody(body, config.vapronHmacSecret);
ba93444Claude580
581 const headers: Record<string, string> = {
582 "Content-Type": "application/json",
583 "User-Agent": "gluecron-webhook/1",
584 "X-Gluecron-Event": "push",
585 "X-Gluecron-Delivery": cryptoRandomId(),
586 };
36ec667ccantynz-alt587 if (signature) {
588 // Send BOTH signature header names so one sender works for both Vapron
589 // receivers: the tenant/customer webhook (/api/hooks/gluecron/push) reads
590 // `X-Gluecron-Signature`, while the platform self-deploy agent
591 // (/__deploy_webhook, services/deploy-agent) reads
592 // `X-Gluecron-Signature-256`. Same `sha256=<hex>` value; the deploy-agent
593 // is intentionally GitHub-webhook-compatible (see its webhook.ts), so no
594 // per-endpoint shim is needed beyond matching the header name.
595 headers["X-Gluecron-Signature"] = signature;
596 headers["X-Gluecron-Signature-256"] = signature;
597 }
e61e6cdccanty labs598 // Vapron tenant auth: API key rides as a bearer alongside the HMAC
599 // signature (key = who is calling, signature = payload integrity).
600 if (config.vapronApiKey)
601 headers["Authorization"] = `Bearer ${config.vapronApiKey}`;
ba93444Claude602
603 let lastStatus = 0;
604 let lastError = "";
605 let success = false;
606
607 // Up to delays.length + 1 attempts (initial try + each delay).
608 const totalAttempts = delays.length + 1;
609 for (let attempt = 0; attempt < totalAttempts; attempt++) {
610 try {
9ecf5a4Claude611 const response = await fetchImpl(config.vapronDeployUrl, {
ba93444Claude612 method: "POST",
613 headers,
614 body,
615 });
616 lastStatus = response.status;
617 console.log(
9ecf5a4Claude618 `[vapron] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
ba93444Claude619 );
620 if (response.ok) {
621 success = true;
622 break;
623 }
624 // 4xx (except 408/429) is unrecoverable — stop retrying.
625 if (response.status >= 400 && response.status < 500 &&
626 response.status !== 408 && response.status !== 429) {
627 break;
628 }
629 } catch (err) {
630 lastError = err instanceof Error ? err.message : String(err);
631 console.error(
9ecf5a4Claude632 `[vapron] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
ba93444Claude633 );
3ef4c9dClaude634 }
ba93444Claude635 const nextDelay = delays[attempt];
636 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
637 await sleep(nextDelay);
1e162a8Claude638 }
ba93444Claude639 }
640
641 if (deployId) {
642 try {
3ef4c9dClaude643 await db
644 .update(deployments)
645 .set({
ba93444Claude646 status: success ? "success" : "failed",
647 blockedReason: success
648 ? null
649 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude650 completedAt: new Date(),
651 })
652 .where(eq(deployments.id, deployId));
ba93444Claude653 } catch {
654 /* ignore */
3ef4c9dClaude655 }
656 }
657
ba93444Claude658 if (!success && deployId) {
659 void onDeployFailure({
660 repositoryId: args.repositoryId,
661 deploymentId: deployId,
662 ref: args.ref,
663 commitSha: args.after,
9ecf5a4Claude664 target: "vapron",
ba93444Claude665 errorMessage: lastError || `HTTP ${lastStatus}`,
666 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude667 }
668}
43cf9b0Claude669
ba93444Claude670function cryptoRandomId(): string {
671 // Short opaque delivery ID for log correlation. Not security-sensitive.
672 const bytes = new Uint8Array(8);
673 crypto.getRandomValues(bytes);
674 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
675}
676
a686079Claude677/**
678 * Resolve `owner/repo` to its DB repository.id and dispatch the
679 * semantic-index update. Pulls the list of changed paths via
680 * `git diff --name-only`, dropping any deletions (handled implicitly
681 * because deleted blobs simply don't resolve in `indexChangedFiles`).
682 *
683 * Never throws — exhaust every external call inside a try/catch so the
684 * push completes even if Postgres or the embedding API is down.
685 */
686async function fireSemanticIndex(
687 owner: string,
688 repo: string,
689 oldSha: string,
690 newSha: string
691): Promise<void> {
692 let repositoryId = "";
693 try {
694 const [row] = await db
695 .select({ id: repositories.id })
696 .from(repositories)
697 .innerJoin(users, eq(repositories.ownerId, users.id))
698 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
699 .limit(1);
700 repositoryId = row?.id || "";
701 } catch {
702 return;
703 }
704 if (!repositoryId) return;
705
706 let changedPaths: string[] = [];
707 try {
708 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
709 } catch {
710 return;
711 }
712 if (!changedPaths.length) return;
713
714 try {
715 const out = await indexChangedFiles({
716 repositoryId,
717 ownerName: owner,
718 repoName: repo,
719 commitSha: newSha,
720 changedPaths,
721 });
722 if (out.indexed > 0) {
723 console.log(
724 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
725 );
726 }
727 } catch (err) {
728 console.warn("[semantic-index] indexChangedFiles error:", err);
729 }
730}
731
732/**
733 * Returns the list of files touched between `oldSha` and `newSha`. For
734 * the initial push on a branch (oldSha all-zero) we walk every file in
735 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
736 */
737async function listChangedPaths(
738 owner: string,
739 repo: string,
740 oldSha: string,
741 newSha: string
742): Promise<string[]> {
743 const cwd = getRepoPath(owner, repo);
744 const allZero = /^0+$/.test(oldSha);
745 const cmd = allZero
746 ? ["git", "ls-tree", "-r", "--name-only", newSha]
747 : ["git", "diff", "--name-only", oldSha, newSha];
748 try {
749 const proc = Bun.spawn(cmd, {
750 cwd,
751 stdout: "pipe",
752 stderr: "pipe",
753 });
754 const text = await new Response(proc.stdout).text();
755 await proc.exited;
756 return text
757 .split("\n")
758 .map((s) => s.trim())
759 .filter((s) => s.length > 0);
760 } catch {
761 return [];
762 }
763}
764
4bbacbeClaude765/**
766 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
767 * non-default branch on a `preview_builds_enabled` repo.
768 *
769 * Resolves the repo row once, fetches the default branch + opt-out flag,
770 * then upserts one preview row per pushed ref that isn't the default.
771 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
772 * are skipped — they shouldn't create preview rows. Never throws.
773 */
774async function firePreviewBuilds(
775 owner: string,
776 repo: string,
777 refs: PushRef[]
778): Promise<void> {
779 // Filter to live branch pushes only.
780 const branchRefs = refs.filter(
781 (r) =>
782 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
783 );
784 if (branchRefs.length === 0) return;
785
786 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
787 try {
788 const [row] = await db
789 .select({
790 id: repositories.id,
791 previewBuildsEnabled: repositories.previewBuildsEnabled,
792 defaultBranch: repositories.defaultBranch,
793 })
794 .from(repositories)
795 .innerJoin(users, eq(repositories.ownerId, users.id))
796 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
797 .limit(1);
798 repoRow = row || null;
799 } catch {
800 return;
801 }
802 if (!repoRow) return;
803 if (!repoRow.previewBuildsEnabled) return;
804
805 for (const ref of branchRefs) {
806 const branchName = ref.refName.replace("refs/heads/", "");
807 if (branchName === repoRow.defaultBranch) continue;
808 try {
809 await enqueuePreviewBuild({
810 repositoryId: repoRow.id,
811 ownerName: owner,
812 repoName: repo,
813 branchName,
814 commitSha: ref.newSha,
815 });
816 } catch (err) {
817 console.warn(
818 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
819 err instanceof Error ? err.message : err
820 );
821 }
822 }
823}
824
d199847Claude825/**
826 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
827 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
828 * on missing repo or DB error — pushes never block. Never throws.
829 */
830async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
831 let repositoryId = "";
832 try {
833 const [row] = await db
834 .select({ id: repositories.id })
835 .from(repositories)
836 .innerJoin(users, eq(repositories.ownerId, users.id))
837 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
838 .limit(1);
839 repositoryId = row?.id || "";
840 } catch {
841 return;
842 }
843 if (!repositoryId) return;
844 try {
845 const out = await runDocDriftCheckForRepo(repositoryId);
846 if (out.docs > 0 || out.proposed > 0) {
847 console.log(
848 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
849 );
850 }
851 } catch (err) {
852 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
853 }
854}
855
783dd46Claude856/**
857 * Block ST — fan out the push to any server targets that watch this
858 * (repo, branch). One sequential deploy per target so a slow box can't
859 * stall the next push, but multiple matching targets run in parallel.
860 * Every failure is contained to its own deploy row + console warn —
861 * nothing here can break the push path.
862 */
863async function fireServerTargetDeploys(
864 owner: string,
865 repo: string,
866 refs: PushRef[]
867): Promise<void> {
868 const liveRefs = refs.filter(
869 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
870 );
871 if (liveRefs.length === 0) return;
872
873 let repositoryId = "";
874 try {
875 const [row] = await db
876 .select({ id: repositories.id })
877 .from(repositories)
878 .innerJoin(users, eq(repositories.ownerId, users.id))
879 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
880 .limit(1);
881 repositoryId = row?.id || "";
882 } catch {
883 return;
884 }
885 if (!repositoryId) return;
886
887 await Promise.all(
888 liveRefs.map(async (ref) => {
889 const branch = ref.refName.replace("refs/heads/", "");
890 let targets;
891 try {
892 targets = await findTargetsForPush({ repositoryId, branch });
893 } catch {
894 return;
895 }
896 if (!targets.length) return;
897
898 await Promise.all(
899 targets.map(async (target) => {
900 try {
901 const env = await resolveEnv(target.id);
902 const deployId = await startDeployRow({
903 targetId: target.id,
904 commitSha: ref.newSha,
905 ref: ref.refName,
906 triggerSource: "push",
907 });
908 const result = await deployToTarget(target, {
909 commitSha: ref.newSha,
910 ref: ref.refName,
911 env,
912 });
913 if (deployId) {
914 await finishDeployRow({
915 id: deployId,
916 exitCode: result.exitCode,
917 stdout: result.stdout,
918 stderr: result.stderr,
919 });
920 }
921 console.log(
922 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
923 );
924 } catch (err) {
925 console.warn(
926 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
927 );
928 }
929 })
930 );
931 })
932 );
933}
934
da3fc18Claude935/**
936 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
937 * Resolves the repo DB row once, then runs the scanner on each live push.
938 * Swallows all errors so a scanner failure never affects the push path.
939 */
940async function fireDependencyScan(
941 owner: string,
942 repo: string,
943 refs: PushRef[]
944): Promise<void> {
945 const liveRefs = refs.filter(
946 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
947 );
948 if (liveRefs.length === 0) return;
949
950 let repoRow: { id: string; ownerId: string } | null = null;
951 try {
952 const [row] = await db
953 .select({ id: repositories.id, ownerId: repositories.ownerId })
954 .from(repositories)
955 .innerJoin(users, eq(repositories.ownerId, users.id))
956 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
957 .limit(1);
958 repoRow = row || null;
959 } catch {
960 return;
961 }
962 if (!repoRow) return;
963
964 for (const ref of liveRefs) {
965 try {
966 const findings = await scanDependencies(
967 repoRow.id,
968 owner,
969 repo,
970 ref.newSha,
971 ref.oldSha,
972 repoRow.ownerId
973 );
974 if (findings.length > 0) {
975 console.log(
976 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
977 );
978 }
979 } catch (err) {
980 console.warn(
981 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
982 err instanceof Error ? err.message : err
983 );
984 }
985 }
986}
987
9c5223fClaude988/**
989 * Migration 0088 — trigger repo onboarding on first push to the default branch.
990 * Detects "first push" by checking whether oldSha is all-zeros on a push to
991 * the repo's default branch (or any branch for a repo with no prior history).
992 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
993 * Never throws.
994 */
995async function fireRepoOnboarding(
996 owner: string,
997 repo: string,
998 refs: PushRef[]
999): Promise<void> {
1000 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
1001 const firstPushRefs = refs.filter(
1002 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
1003 );
1004 if (firstPushRefs.length === 0) return;
1005
1006 let repositoryId = "";
1007 try {
1008 const [row] = await db
1009 .select({ id: repositories.id })
1010 .from(repositories)
1011 .innerJoin(users, eq(repositories.ownerId, users.id))
1012 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
1013 .limit(1);
1014 repositoryId = row?.id || "";
1015 } catch {
1016 return;
1017 }
1018 if (!repositoryId) return;
1019
1020 await ensureRepoOnboarding(repositoryId, owner, repo);
1021}
1022
43cf9b0Claude1023/** Test-only access to internal helpers. */
a686079Claude1024export const __test = {
9ecf5a4Claude1025 triggerVapronDeploy,
a686079Claude1026 signBody,
1027 buildPayload,
1028 RETRY_DELAYS_MS,
1029 listChangedPaths,
1030 fireSemanticIndex,
4bbacbeClaude1031 firePreviewBuilds,
d199847Claude1032 fireDocDriftCheck,
783dd46Claude1033 fireServerTargetDeploys,
da3fc18Claude1034 fireDependencyScan,
9c5223fClaude1035 fireRepoOnboarding,
a686079Claude1036};