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.tsBlame1055 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
8102dd4ccantynz-alt136 // Stamp `pushed_at` (and bump `updated_at`) so every "recently active"
137 // sort, dashboard ordering and repo-list surface reflects this push.
138 // Nothing else in the codebase ever wrote this column, so it read null
139 // forever and those surfaces silently ranked every repo as dormant.
140 // Fire-and-forget: a DB hiccup must never break the push path.
141 if (repoId && refs.some((r) => !r.newSha.startsWith("0000"))) {
142 const pushedAt = new Date();
143 void db
144 .update(repositories)
145 .set({ pushedAt, updatedAt: pushedAt })
146 .where(eq(repositories.id, repoId))
147 .catch((err) => {
148 console.warn(
149 "[post-receive] pushedAt update failed:",
150 err instanceof Error ? err.message : err
151 );
152 });
153 }
154
64aa989Claude155 const automationSettings = repoId
156 ? await getAutomationSettings(repoId).catch(() => null)
157 : null;
158
2c34075Claude159 for (const ref of refs) {
160 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
161 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude162
b7b5f75ccanty labs163 // 0. Activity feed — record the push so the repo page's "recent push"
164 // indicator and dashboard/pulse feeds pick it up. targetId=newSha is a
165 // load-bearing contract: src/routes/web.tsx getRecentPush and
166 // src/routes/push-watch.tsx both query action="push" by SHA.
167 if (repoId) {
168 void logActivity({
169 repositoryId: repoId,
170 userId: pusherUserId || null,
171 action: "push",
172 targetType: "commit",
173 targetId: ref.newSha,
174 metadata: { branch: branchName, oldSha: ref.oldSha },
175 });
a74f4edccanty labs176 void fireWebhooks(repoId, "push", {
177 branch: branchName,
178 oldSha: ref.oldSha,
179 newSha: ref.newSha,
180 });
b7b5f75ccanty labs181 }
182
61897c4ccanty labs183 // 0b. Workflow sync + push-triggered CI. Discover .gluecron/workflows/
184 // *.yml in the pushed tree, upsert them into the `workflows` table, and
185 // enqueue a run for every non-disabled workflow whose `on:` includes
186 // `push`. This was previously missing entirely — see
187 // src/lib/push-workflow-sync.ts's header comment for how it was found.
188 if (repoId) {
189 try {
190 const wfResult = await syncAndEnqueuePushWorkflows({
191 owner,
192 repo,
193 repositoryId: repoId,
194 branch: branchName,
195 commitSha: ref.newSha,
196 triggeredBy: pusherUserId || null,
197 });
198 if (wfResult.synced > 0 || wfResult.enqueued > 0) {
199 console.log(
200 `[workflow-sync] ${owner}/${repo}@${branchName}: synced ${wfResult.synced}, enqueued ${wfResult.enqueued}`
201 );
202 }
203 if (wfResult.errors.length > 0) {
204 console.warn(`[workflow-sync] ${owner}/${repo}@${branchName} errors:`, wfResult.errors);
205 }
206 } catch (err) {
207 console.error(`[workflow-sync] error:`, err);
208 }
209 }
210
64aa989Claude211 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
212 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
213 try {
6682dbeClaude214 const repair = await withTestIsolation(() => autoRepair(owner, repo, branchName));
64aa989Claude215 if (repair.repaired) {
216 console.log(
217 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
218 );
219 }
220 } catch (err) {
221 console.error(`[autorepair] error:`, err);
2c34075Claude222 }
223 }
fc1817aClaude224
2c34075Claude225 // 2. Push analysis
226 try {
227 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
228 console.log(
229 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
230 );
231 if (analysis.riskScore > 50) {
232 console.warn(
233 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
234 );
235 }
236 if (analysis.breakingChangeSignals.length > 0) {
237 console.warn(
238 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
239 );
240 }
241 } catch (err) {
242 console.error(`[push-analysis] error:`, err);
243 }
244
6682dbeClaude245 // 2b. Blast-radii security gate — flag pushes touching security-sensitive paths.
246 void (async () => {
247 try {
248 const repoPath = getRepoPath(owner, repo);
249 const flaggedPaths = await detectBlastRadiusPaths(repoPath, ref.oldSha, ref.newSha);
250 if (flaggedPaths.length > 0) {
251 console.warn(
252 `[blast-radii] ${owner}/${repo}@${branchName}: security-sensitive paths modified: ${flaggedPaths.join(", ")}`
253 );
254 void audit({
255 repositoryId: repoId || undefined,
256 action: "blast_radii.security_path_flagged",
257 targetType: "push",
258 metadata: {
259 owner,
260 repo,
261 branch: branchName,
262 newSha: ref.newSha,
263 flaggedPaths,
264 },
265 });
266 }
267 } catch (err) {
268 console.warn("[blast-radii] check error:", err);
269 }
270 })();
271
2c34075Claude272 // 3. Health score (async, don't block)
273 computeHealthScore(owner, repo).then((report) => {
274 console.log(
275 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
276 );
277 }).catch((err) => {
278 console.error(`[health] error:`, err);
279 });
280 }
281
170ddb2Claude282 // 4. GateTest scan — fire-and-forget notification on every push. The
283 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
284 // deployments pay no overhead. Results flow back via the inbound
285 // webhook at POST /api/hooks/gatetest.
286 for (const ref of refs) {
287 if (ref.newSha.startsWith("0000")) continue;
288 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
289 console.warn("[gatetest] notify error:", err)
290 );
291 }
2c34075Claude292
a686079Claude293 // 4b. Continuous semantic index — embed changed files on every push so
294 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
295 // all failures are swallowed inside semantic-index.ts so a missing
296 // pgvector extension or absent embeddings API key never breaks the
297 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
298 for (const ref of refs) {
299 if (ref.newSha.startsWith("0000")) continue;
300 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
301 console.warn("[semantic-index] dispatch error:", err)
302 );
303 }
304
4bbacbeClaude305 // 4c. Per-branch preview URLs (migration 0062). Every push to a
306 // non-default branch enqueues a preview-build row. Gated on
307 // repositories.preview_builds_enabled (default on) so owners can
308 // opt out per-repo via repo-settings. Fire-and-forget; failures
309 // never break the push path.
310 void firePreviewBuilds(owner, repo, refs).catch((err) =>
311 console.warn("[branch-previews] dispatch error:", err)
312 );
313
6efae38Claude314 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
315 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
316 // and debug console.log calls. Opens one issue per finding type per
f183fdfClaude317 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1 and
318 // per-repo autoIssuesMode. Fire-and-forget; never blocks the push path.
6efae38Claude319 for (const ref of refs) {
320 if (ref.newSha.startsWith("0000")) continue;
f183fdfClaude321 if (automationSettings && isAutomationOn(automationSettings.autoIssuesMode)) {
322 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
323 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
324 );
325 }
6efae38Claude326 }
327
da3fc18Claude328 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
329 // every push that touches a recognized manifest file (package.json,
330 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
331 // for critical/high findings and updates a weekly digest for
332 // medium/low. Fire-and-forget; never blocks the push path.
333 if (config.dependencyScanEnabled) {
334 void fireDependencyScan(owner, repo, refs).catch((err) =>
335 console.warn("[dependency-scanner] dispatch error:", err)
336 );
337 }
338
d199847Claude339 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
340 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
341 // regions, hashes the referenced source, and opens a PR labelled
f183fdfClaude342 // `ai:doc-update` when the prose drifts. Gated on per-repo
343 // docDriftMode. Fire-and-forget; failures are swallowed inside
344 // ai-doc-updater.ts so a missing anthropic key or empty
345 // doc_tracking table never breaks the push.
346 if (automationSettings && isAutomationOn(automationSettings.docDriftMode)) {
347 void fireDocDriftCheck(owner, repo).catch((err) =>
348 console.warn("[ai-doc-updater] dispatch error:", err)
349 );
350 }
d199847Claude351
9c5223fClaude352 // 4g. Smart empty states — repo onboarding. On the very first push to a
353 // repo's default branch (oldSha all-zeros), generate a README draft,
354 // suggested labels, and gates.yml starter using Claude Sonnet.
355 // Idempotent: ensureRepoOnboarding skips if a row already exists.
356 // Fire-and-forget; never blocks the push path.
357 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
358 console.warn("[repo-onboarding] dispatch error:", err)
359 );
360
9ecf5a4Claude361 // 5. Vapron deploy (BLK-016) — only fires for the configured Vapron repo
362 // (VAPRON_REPO, default `ccantynz-alt/vapron`; legacy CRONTECH_REPO honored)
363 // on a push to its
ba93444Claude364 // default branch. The branch case (`Main` vs `main`) is determined by
365 // the bare repo's HEAD, not hardcoded.
9ecf5a4Claude366 if (`${owner}/${repo}` === config.vapronRepo) {
a28cedeClaude367 let defaultBranch =
368 (await getDefaultBranch(owner, repo).catch((err) => {
369 console.warn(
370 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
371 err instanceof Error ? err.message : err
372 );
373 return null;
374 })) || "main";
ba93444Claude375 const targetRef = `refs/heads/${defaultBranch}`;
376 const deployPush = refs.find(
377 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
378 );
379 if (deployPush) {
380 let repositoryId = "";
381 try {
382 const [row] = await db
383 .select({ id: repositories.id })
384 .from(repositories)
385 .innerJoin(users, eq(repositories.ownerId, users.id))
386 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
387 .limit(1);
388 repositoryId = row?.id || "";
389 } catch {
390 /* ignore */
391 }
392 if (repositoryId) {
9ecf5a4Claude393 triggerVapronDeploy({
ba93444Claude394 owner,
395 repo,
396 before: deployPush.oldSha,
397 after: deployPush.newSha,
398 ref: targetRef,
399 branch: defaultBranch,
400 repositoryId,
9ecf5a4Claude401 }).catch((err: unknown) => console.error(`[vapron] error:`, err));
ba93444Claude402 }
0316dbbClaude403 }
fc1817aClaude404 }
f2c00b4CC LABS App405
783dd46Claude406 // 5b. Block ST — Server targets. After core post-receive work, fire
407 // deploys against any `server_targets` row whose
408 // (watched_repository_id, watched_branch) matches a pushed ref.
409 // Fire-and-forget; deploy results land in `server_target_deployments`
410 // and the UI at /admin/servers/:id surfaces them.
411 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
412 console.warn("[server-targets] dispatch error:", err)
413 );
414
c9ed210Claude415 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
416 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
417 // webhook for any cloud_deploy_configs rows matching the pushed branch.
418 // Fire-and-forget; all failures are swallowed so the push path is
419 // never blocked.
420 void fireCloudDeploys(owner, repo, refs).catch((err) =>
421 console.warn("[cloud-deploy] dispatch error:", err)
422 );
423
f2c00b4CC LABS App424 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
425 // main, fire the local deploy via scripts/self-deploy.sh. The script
426 // forks into the background, so this call returns immediately (git
427 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
428 // avoid firing on customer repos that happen to be named "Gluecron.com".
429 const selfHostRepo = process.env.SELF_HOST_REPO;
430 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
431 const mainRef = refs.find(
432 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
433 );
434 if (mainRef) {
435 const scriptPath =
436 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
437 "/opt/gluecron/scripts/self-deploy.sh";
438 try {
439 const child = __selfHostSpawn(
440 [scriptPath, mainRef.oldSha, mainRef.newSha],
441 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
442 );
443 try {
444 (child as any)?.unref?.();
445 } catch {
446 /* unref optional */
447 }
448 console.log(
449 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
450 );
451 } catch (err) {
452 console.error(`[self-host] failed to spawn:`, err);
453 }
454 }
455 }
456}
457
458// BLOCK W — DI seam so the test suite can capture the spawn call without
459// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
460// callers go straight to Bun.spawn.
bf19c50Test User461const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App462 Bun.spawn(cmd, opts);
bf19c50Test User463let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
464/**
465 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
466 */
467export function __setSelfHostSpawnForTests(
468 fn: typeof __selfHostSpawn | null
469): void {
470 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude471}
472
43cf9b0Claude473/**
9ecf5a4Claude474 * BLK-016 — outbound deploy webhook for Vapron's deploy-agent (formerly Crontech).
43cf9b0Claude475 *
9ecf5a4Claude476 * Wire contract (matches Vapron's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude477 *
5e67f6bccanty labs478 * POST https://vapron.ai/api/hooks/gluecron/push
479 * (path verified live 2026-07-14 — the receiver returns 401 on unsigned
480 * payloads; the pre-move /api/webhooks/gluecron-push now 404s)
43cf9b0Claude481 * Content-Type: application/json
ba93444Claude482 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude483 *
484 * {
ba93444Claude485 * "event": "push",
9ecf5a4Claude486 * "repository": { "full_name": "ccantynz-alt/vapron" },
ba93444Claude487 * "ref": "refs/heads/Main",
488 * "after": "<40-hex commit SHA>",
489 * "before": "<40-hex previous SHA>",
490 * "pusher": { "name": "<author>", "email": "<email>" },
491 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude492 * }
493 *
ba93444Claude494 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude495 *
ba93444Claude496 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
497 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
9ecf5a4Claude498 * is unset the signature header is omitted and Vapron is expected to reject —
ba93444Claude499 * we still record the deploy row as failed.
43cf9b0Claude500 */
ba93444Claude501const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
502
503interface TriggerArgs {
504 owner: string;
505 repo: string;
506 before: string;
507 after: string;
508 ref: string;
509 branch: string;
510 repositoryId: string;
511}
512
513interface TriggerOptions {
514 fetchImpl?: typeof fetch;
515 sleep?: (ms: number) => Promise<void>;
516 retryDelaysMs?: number[];
517 now?: () => Date;
518}
519
520function signBody(body: string, secret: string): string | null {
521 if (!secret) return null;
522 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
523}
524
525async function buildPayload(args: TriggerArgs, now: Date): Promise<{
526 payload: Record<string, unknown>;
527 pusherName: string;
528 pusherEmail: string;
529}> {
530 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
531 // `before` may be all-zeros for a first push to the branch — commitsBetween
532 // handles that by treating null `from` as "everything reachable from `to`".
533 const fromSha = /^0+$/.test(args.before) ? null : args.before;
534 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
535 let pusherName = "gluecron";
536 let pusherEmail = "noreply@gluecron.local";
537 try {
538 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
539 commits = list.slice(0, 50).map((c) => ({
540 id: c.sha,
541 message: c.message,
542 timestamp: c.date,
543 }));
544 if (list[0]) {
545 pusherName = list[0].author || pusherName;
546 pusherEmail = list[0].authorEmail || pusherEmail;
547 }
548 } catch {
549 /* ignore — payload still valid with empty commits[] */
550 }
551 return {
552 payload: {
553 event: "push",
554 repository: { full_name: `${args.owner}/${args.repo}` },
555 ref: args.ref,
556 after: args.after,
557 before: args.before,
558 pusher: { name: pusherName, email: pusherEmail },
559 commits,
560 // Ancillary fields — receiver may ignore but they're useful for logs:
561 sent_at: now.toISOString(),
562 source: "gluecron",
563 },
564 pusherName,
565 pusherEmail,
566 };
567}
568
9ecf5a4Claude569async function triggerVapronDeploy(
ba93444Claude570 args: TriggerArgs,
571 opts: TriggerOptions = {}
fc1817aClaude572): Promise<void> {
ba93444Claude573 const fetchImpl = opts.fetchImpl ?? fetch;
574 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
575 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
576 const now = opts.now ?? (() => new Date());
577
3ef4c9dClaude578 let deployId = "";
fc1817aClaude579 try {
3ef4c9dClaude580 const [row] = await db
581 .insert(deployments)
582 .values({
ba93444Claude583 repositoryId: args.repositoryId,
3ef4c9dClaude584 environment: "production",
ba93444Claude585 commitSha: args.after,
586 ref: args.ref,
3ef4c9dClaude587 status: "pending",
9ecf5a4Claude588 target: "vapron",
3ef4c9dClaude589 })
590 .returning();
591 deployId = row?.id || "";
592 } catch {
593 /* ignore */
fc1817aClaude594 }
595
ba93444Claude596 const { payload } = await buildPayload(args, now());
597 const body = JSON.stringify(payload);
9ecf5a4Claude598 const signature = signBody(body, config.vapronHmacSecret);
ba93444Claude599
600 const headers: Record<string, string> = {
601 "Content-Type": "application/json",
602 "User-Agent": "gluecron-webhook/1",
603 "X-Gluecron-Event": "push",
604 "X-Gluecron-Delivery": cryptoRandomId(),
605 };
36ec667ccantynz-alt606 if (signature) {
607 // Send BOTH signature header names so one sender works for both Vapron
608 // receivers: the tenant/customer webhook (/api/hooks/gluecron/push) reads
609 // `X-Gluecron-Signature`, while the platform self-deploy agent
610 // (/__deploy_webhook, services/deploy-agent) reads
611 // `X-Gluecron-Signature-256`. Same `sha256=<hex>` value; the deploy-agent
612 // is intentionally GitHub-webhook-compatible (see its webhook.ts), so no
613 // per-endpoint shim is needed beyond matching the header name.
614 headers["X-Gluecron-Signature"] = signature;
615 headers["X-Gluecron-Signature-256"] = signature;
616 }
e61e6cdccanty labs617 // Vapron tenant auth: API key rides as a bearer alongside the HMAC
618 // signature (key = who is calling, signature = payload integrity).
619 if (config.vapronApiKey)
620 headers["Authorization"] = `Bearer ${config.vapronApiKey}`;
ba93444Claude621
622 let lastStatus = 0;
623 let lastError = "";
624 let success = false;
625
626 // Up to delays.length + 1 attempts (initial try + each delay).
627 const totalAttempts = delays.length + 1;
628 for (let attempt = 0; attempt < totalAttempts; attempt++) {
629 try {
9ecf5a4Claude630 const response = await fetchImpl(config.vapronDeployUrl, {
ba93444Claude631 method: "POST",
632 headers,
633 body,
634 });
635 lastStatus = response.status;
636 console.log(
9ecf5a4Claude637 `[vapron] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
ba93444Claude638 );
639 if (response.ok) {
640 success = true;
641 break;
642 }
643 // 4xx (except 408/429) is unrecoverable — stop retrying.
644 if (response.status >= 400 && response.status < 500 &&
645 response.status !== 408 && response.status !== 429) {
646 break;
647 }
648 } catch (err) {
649 lastError = err instanceof Error ? err.message : String(err);
650 console.error(
9ecf5a4Claude651 `[vapron] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
ba93444Claude652 );
3ef4c9dClaude653 }
ba93444Claude654 const nextDelay = delays[attempt];
655 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
656 await sleep(nextDelay);
1e162a8Claude657 }
ba93444Claude658 }
659
660 if (deployId) {
661 try {
3ef4c9dClaude662 await db
663 .update(deployments)
664 .set({
ba93444Claude665 status: success ? "success" : "failed",
666 blockedReason: success
667 ? null
668 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude669 completedAt: new Date(),
670 })
671 .where(eq(deployments.id, deployId));
ba93444Claude672 } catch {
673 /* ignore */
3ef4c9dClaude674 }
675 }
676
ba93444Claude677 if (!success && deployId) {
678 void onDeployFailure({
679 repositoryId: args.repositoryId,
680 deploymentId: deployId,
681 ref: args.ref,
682 commitSha: args.after,
9ecf5a4Claude683 target: "vapron",
ba93444Claude684 errorMessage: lastError || `HTTP ${lastStatus}`,
685 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude686 }
687}
43cf9b0Claude688
ba93444Claude689function cryptoRandomId(): string {
690 // Short opaque delivery ID for log correlation. Not security-sensitive.
691 const bytes = new Uint8Array(8);
692 crypto.getRandomValues(bytes);
693 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
694}
695
a686079Claude696/**
697 * Resolve `owner/repo` to its DB repository.id and dispatch the
698 * semantic-index update. Pulls the list of changed paths via
699 * `git diff --name-only`, dropping any deletions (handled implicitly
700 * because deleted blobs simply don't resolve in `indexChangedFiles`).
701 *
702 * Never throws — exhaust every external call inside a try/catch so the
703 * push completes even if Postgres or the embedding API is down.
704 */
705async function fireSemanticIndex(
706 owner: string,
707 repo: string,
708 oldSha: string,
709 newSha: string
710): Promise<void> {
711 let repositoryId = "";
712 try {
713 const [row] = await db
714 .select({ id: repositories.id })
715 .from(repositories)
716 .innerJoin(users, eq(repositories.ownerId, users.id))
717 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
718 .limit(1);
719 repositoryId = row?.id || "";
720 } catch {
721 return;
722 }
723 if (!repositoryId) return;
724
725 let changedPaths: string[] = [];
726 try {
727 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
728 } catch {
729 return;
730 }
731 if (!changedPaths.length) return;
732
733 try {
734 const out = await indexChangedFiles({
735 repositoryId,
736 ownerName: owner,
737 repoName: repo,
738 commitSha: newSha,
739 changedPaths,
740 });
741 if (out.indexed > 0) {
742 console.log(
743 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
744 );
745 }
746 } catch (err) {
747 console.warn("[semantic-index] indexChangedFiles error:", err);
748 }
749}
750
751/**
752 * Returns the list of files touched between `oldSha` and `newSha`. For
753 * the initial push on a branch (oldSha all-zero) we walk every file in
754 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
755 */
756async function listChangedPaths(
757 owner: string,
758 repo: string,
759 oldSha: string,
760 newSha: string
761): Promise<string[]> {
762 const cwd = getRepoPath(owner, repo);
763 const allZero = /^0+$/.test(oldSha);
764 const cmd = allZero
765 ? ["git", "ls-tree", "-r", "--name-only", newSha]
766 : ["git", "diff", "--name-only", oldSha, newSha];
767 try {
768 const proc = Bun.spawn(cmd, {
769 cwd,
770 stdout: "pipe",
771 stderr: "pipe",
772 });
773 const text = await new Response(proc.stdout).text();
774 await proc.exited;
775 return text
776 .split("\n")
777 .map((s) => s.trim())
778 .filter((s) => s.length > 0);
779 } catch {
780 return [];
781 }
782}
783
4bbacbeClaude784/**
785 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
786 * non-default branch on a `preview_builds_enabled` repo.
787 *
788 * Resolves the repo row once, fetches the default branch + opt-out flag,
789 * then upserts one preview row per pushed ref that isn't the default.
790 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
791 * are skipped — they shouldn't create preview rows. Never throws.
792 */
793async function firePreviewBuilds(
794 owner: string,
795 repo: string,
796 refs: PushRef[]
797): Promise<void> {
798 // Filter to live branch pushes only.
799 const branchRefs = refs.filter(
800 (r) =>
801 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
802 );
803 if (branchRefs.length === 0) return;
804
805 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
806 try {
807 const [row] = await db
808 .select({
809 id: repositories.id,
810 previewBuildsEnabled: repositories.previewBuildsEnabled,
811 defaultBranch: repositories.defaultBranch,
812 })
813 .from(repositories)
814 .innerJoin(users, eq(repositories.ownerId, users.id))
815 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
816 .limit(1);
817 repoRow = row || null;
818 } catch {
819 return;
820 }
821 if (!repoRow) return;
822 if (!repoRow.previewBuildsEnabled) return;
823
824 for (const ref of branchRefs) {
825 const branchName = ref.refName.replace("refs/heads/", "");
826 if (branchName === repoRow.defaultBranch) continue;
827 try {
828 await enqueuePreviewBuild({
829 repositoryId: repoRow.id,
830 ownerName: owner,
831 repoName: repo,
832 branchName,
833 commitSha: ref.newSha,
834 });
835 } catch (err) {
836 console.warn(
837 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
838 err instanceof Error ? err.message : err
839 );
840 }
841 }
842}
843
d199847Claude844/**
845 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
846 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
847 * on missing repo or DB error — pushes never block. Never throws.
848 */
849async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
850 let repositoryId = "";
851 try {
852 const [row] = await db
853 .select({ id: repositories.id })
854 .from(repositories)
855 .innerJoin(users, eq(repositories.ownerId, users.id))
856 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
857 .limit(1);
858 repositoryId = row?.id || "";
859 } catch {
860 return;
861 }
862 if (!repositoryId) return;
863 try {
864 const out = await runDocDriftCheckForRepo(repositoryId);
865 if (out.docs > 0 || out.proposed > 0) {
866 console.log(
867 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
868 );
869 }
870 } catch (err) {
871 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
872 }
873}
874
783dd46Claude875/**
876 * Block ST — fan out the push to any server targets that watch this
877 * (repo, branch). One sequential deploy per target so a slow box can't
878 * stall the next push, but multiple matching targets run in parallel.
879 * Every failure is contained to its own deploy row + console warn —
880 * nothing here can break the push path.
881 */
882async function fireServerTargetDeploys(
883 owner: string,
884 repo: string,
885 refs: PushRef[]
886): Promise<void> {
887 const liveRefs = refs.filter(
888 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
889 );
890 if (liveRefs.length === 0) return;
891
892 let repositoryId = "";
893 try {
894 const [row] = await db
895 .select({ id: repositories.id })
896 .from(repositories)
897 .innerJoin(users, eq(repositories.ownerId, users.id))
898 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
899 .limit(1);
900 repositoryId = row?.id || "";
901 } catch {
902 return;
903 }
904 if (!repositoryId) return;
905
906 await Promise.all(
907 liveRefs.map(async (ref) => {
908 const branch = ref.refName.replace("refs/heads/", "");
909 let targets;
910 try {
911 targets = await findTargetsForPush({ repositoryId, branch });
912 } catch {
913 return;
914 }
915 if (!targets.length) return;
916
917 await Promise.all(
918 targets.map(async (target) => {
919 try {
920 const env = await resolveEnv(target.id);
921 const deployId = await startDeployRow({
922 targetId: target.id,
923 commitSha: ref.newSha,
924 ref: ref.refName,
925 triggerSource: "push",
926 });
927 const result = await deployToTarget(target, {
928 commitSha: ref.newSha,
929 ref: ref.refName,
930 env,
931 });
932 if (deployId) {
933 await finishDeployRow({
934 id: deployId,
935 exitCode: result.exitCode,
936 stdout: result.stdout,
937 stderr: result.stderr,
938 });
939 }
940 console.log(
941 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
942 );
943 } catch (err) {
944 console.warn(
945 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
946 );
947 }
948 })
949 );
950 })
951 );
952}
953
da3fc18Claude954/**
955 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
956 * Resolves the repo DB row once, then runs the scanner on each live push.
957 * Swallows all errors so a scanner failure never affects the push path.
958 */
959async function fireDependencyScan(
960 owner: string,
961 repo: string,
962 refs: PushRef[]
963): Promise<void> {
964 const liveRefs = refs.filter(
965 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
966 );
967 if (liveRefs.length === 0) return;
968
969 let repoRow: { id: string; ownerId: string } | null = null;
970 try {
971 const [row] = await db
972 .select({ id: repositories.id, ownerId: repositories.ownerId })
973 .from(repositories)
974 .innerJoin(users, eq(repositories.ownerId, users.id))
975 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
976 .limit(1);
977 repoRow = row || null;
978 } catch {
979 return;
980 }
981 if (!repoRow) return;
982
983 for (const ref of liveRefs) {
984 try {
985 const findings = await scanDependencies(
986 repoRow.id,
987 owner,
988 repo,
989 ref.newSha,
990 ref.oldSha,
991 repoRow.ownerId
992 );
993 if (findings.length > 0) {
994 console.log(
995 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
996 );
997 }
998 } catch (err) {
999 console.warn(
1000 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
1001 err instanceof Error ? err.message : err
1002 );
1003 }
1004 }
1005}
1006
9c5223fClaude1007/**
1008 * Migration 0088 — trigger repo onboarding on first push to the default branch.
1009 * Detects "first push" by checking whether oldSha is all-zeros on a push to
1010 * the repo's default branch (or any branch for a repo with no prior history).
1011 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
1012 * Never throws.
1013 */
1014async function fireRepoOnboarding(
1015 owner: string,
1016 repo: string,
1017 refs: PushRef[]
1018): Promise<void> {
1019 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
1020 const firstPushRefs = refs.filter(
1021 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
1022 );
1023 if (firstPushRefs.length === 0) return;
1024
1025 let repositoryId = "";
1026 try {
1027 const [row] = await db
1028 .select({ id: repositories.id })
1029 .from(repositories)
1030 .innerJoin(users, eq(repositories.ownerId, users.id))
1031 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
1032 .limit(1);
1033 repositoryId = row?.id || "";
1034 } catch {
1035 return;
1036 }
1037 if (!repositoryId) return;
1038
1039 await ensureRepoOnboarding(repositoryId, owner, repo);
1040}
1041
43cf9b0Claude1042/** Test-only access to internal helpers. */
a686079Claude1043export const __test = {
9ecf5a4Claude1044 triggerVapronDeploy,
a686079Claude1045 signBody,
1046 buildPayload,
1047 RETRY_DELAYS_MS,
1048 listChangedPaths,
1049 fireSemanticIndex,
4bbacbeClaude1050 firePreviewBuilds,
d199847Claude1051 fireDocDriftCheck,
783dd46Claude1052 fireServerTargetDeploys,
da3fc18Claude1053 fireDependencyScan,
9c5223fClaude1054 fireRepoOnboarding,
a686079Claude1055};