Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame576 lines · 3 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
9 * 5. Crontech deploy — auto-deploy on push to main
10 * 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";
170ddb2Claude17import { notifyGateTestOfPush } from "../lib/gate";
2c34075Claude18import { analyzePush, computeHealthScore } from "../lib/intelligence";
0316dbbClaude19import { db } from "../db";
20import { deployments, repositories, users } from "../db/schema";
21import { onDeployFailure } from "../lib/ai-incident";
a686079Claude22import {
23 commitsBetween,
24 getDefaultBranch,
25 getRepoPath,
26} from "../git/repository";
27import { indexChangedFiles } from "../lib/semantic-index";
4bbacbeClaude28import { enqueuePreviewBuild } from "../lib/branch-previews";
fc1817aClaude29
30interface PushRef {
31 oldSha: string;
32 newSha: string;
33 refName: string;
34}
35
36export async function onPostReceive(
37 owner: string,
38 repo: string,
39 refs: PushRef[]
40): Promise<void> {
2c34075Claude41 for (const ref of refs) {
42 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
43 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude44
2c34075Claude45 // 1. Auto-repair (runs first, may create a new commit)
46 try {
47 const repair = await autoRepair(owner, repo, branchName);
48 if (repair.repaired) {
49 console.log(
50 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
51 );
52 }
53 } catch (err) {
54 console.error(`[autorepair] error:`, err);
55 }
fc1817aClaude56
2c34075Claude57 // 2. Push analysis
58 try {
59 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
60 console.log(
61 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
62 );
63 if (analysis.riskScore > 50) {
64 console.warn(
65 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
66 );
67 }
68 if (analysis.breakingChangeSignals.length > 0) {
69 console.warn(
70 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
71 );
72 }
73 } catch (err) {
74 console.error(`[push-analysis] error:`, err);
75 }
76
77 // 3. Health score (async, don't block)
78 computeHealthScore(owner, repo).then((report) => {
79 console.log(
80 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
81 );
82 }).catch((err) => {
83 console.error(`[health] error:`, err);
84 });
85 }
86
170ddb2Claude87 // 4. GateTest scan — fire-and-forget notification on every push. The
88 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
89 // deployments pay no overhead. Results flow back via the inbound
90 // webhook at POST /api/hooks/gatetest.
91 for (const ref of refs) {
92 if (ref.newSha.startsWith("0000")) continue;
93 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
94 console.warn("[gatetest] notify error:", err)
95 );
96 }
2c34075Claude97
a686079Claude98 // 4b. Continuous semantic index — embed changed files on every push so
99 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
100 // all failures are swallowed inside semantic-index.ts so a missing
101 // pgvector extension or absent embeddings API key never breaks the
102 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
103 for (const ref of refs) {
104 if (ref.newSha.startsWith("0000")) continue;
105 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
106 console.warn("[semantic-index] dispatch error:", err)
107 );
108 }
109
4bbacbeClaude110 // 4c. Per-branch preview URLs (migration 0062). Every push to a
111 // non-default branch enqueues a preview-build row. Gated on
112 // repositories.preview_builds_enabled (default on) so owners can
113 // opt out per-repo via repo-settings. Fire-and-forget; failures
114 // never break the push path.
115 void firePreviewBuilds(owner, repo, refs).catch((err) =>
116 console.warn("[branch-previews] dispatch error:", err)
117 );
118
ba93444Claude119 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
120 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
121 // default branch. The branch case (`Main` vs `main`) is determined by
122 // the bare repo's HEAD, not hardcoded.
123 if (`${owner}/${repo}` === config.crontechRepo) {
a28cedeClaude124 let defaultBranch =
125 (await getDefaultBranch(owner, repo).catch((err) => {
126 console.warn(
127 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
128 err instanceof Error ? err.message : err
129 );
130 return null;
131 })) || "main";
ba93444Claude132 const targetRef = `refs/heads/${defaultBranch}`;
133 const deployPush = refs.find(
134 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
135 );
136 if (deployPush) {
137 let repositoryId = "";
138 try {
139 const [row] = await db
140 .select({ id: repositories.id })
141 .from(repositories)
142 .innerJoin(users, eq(repositories.ownerId, users.id))
143 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
144 .limit(1);
145 repositoryId = row?.id || "";
146 } catch {
147 /* ignore */
148 }
149 if (repositoryId) {
150 triggerCrontechDeploy({
151 owner,
152 repo,
153 before: deployPush.oldSha,
154 after: deployPush.newSha,
155 ref: targetRef,
156 branch: defaultBranch,
157 repositoryId,
158 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
159 }
0316dbbClaude160 }
fc1817aClaude161 }
f2c00b4CC LABS App162
163 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
164 // main, fire the local deploy via scripts/self-deploy.sh. The script
165 // forks into the background, so this call returns immediately (git
166 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
167 // avoid firing on customer repos that happen to be named "Gluecron.com".
168 const selfHostRepo = process.env.SELF_HOST_REPO;
169 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
170 const mainRef = refs.find(
171 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
172 );
173 if (mainRef) {
174 const scriptPath =
175 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
176 "/opt/gluecron/scripts/self-deploy.sh";
177 try {
178 const child = __selfHostSpawn(
179 [scriptPath, mainRef.oldSha, mainRef.newSha],
180 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
181 );
182 try {
183 (child as any)?.unref?.();
184 } catch {
185 /* unref optional */
186 }
187 console.log(
188 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
189 );
190 } catch (err) {
191 console.error(`[self-host] failed to spawn:`, err);
192 }
193 }
194 }
195}
196
197// BLOCK W — DI seam so the test suite can capture the spawn call without
198// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
199// callers go straight to Bun.spawn.
bf19c50Test User200const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App201 Bun.spawn(cmd, opts);
bf19c50Test User202let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
203/**
204 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
205 */
206export function __setSelfHostSpawnForTests(
207 fn: typeof __selfHostSpawn | null
208): void {
209 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude210}
211
43cf9b0Claude212/**
ba93444Claude213 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude214 *
ba93444Claude215 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude216 *
ba93444Claude217 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude218 * Content-Type: application/json
ba93444Claude219 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude220 *
221 * {
ba93444Claude222 * "event": "push",
223 * "repository": { "full_name": "ccantynz-alt/crontech" },
224 * "ref": "refs/heads/Main",
225 * "after": "<40-hex commit SHA>",
226 * "before": "<40-hex previous SHA>",
227 * "pusher": { "name": "<author>", "email": "<email>" },
228 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude229 * }
230 *
ba93444Claude231 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude232 *
ba93444Claude233 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
234 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
235 * is unset the signature header is omitted and Crontech is expected to reject —
236 * we still record the deploy row as failed.
43cf9b0Claude237 */
ba93444Claude238const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
239
240interface TriggerArgs {
241 owner: string;
242 repo: string;
243 before: string;
244 after: string;
245 ref: string;
246 branch: string;
247 repositoryId: string;
248}
249
250interface TriggerOptions {
251 fetchImpl?: typeof fetch;
252 sleep?: (ms: number) => Promise<void>;
253 retryDelaysMs?: number[];
254 now?: () => Date;
255}
256
257function signBody(body: string, secret: string): string | null {
258 if (!secret) return null;
259 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
260}
261
262async function buildPayload(args: TriggerArgs, now: Date): Promise<{
263 payload: Record<string, unknown>;
264 pusherName: string;
265 pusherEmail: string;
266}> {
267 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
268 // `before` may be all-zeros for a first push to the branch — commitsBetween
269 // handles that by treating null `from` as "everything reachable from `to`".
270 const fromSha = /^0+$/.test(args.before) ? null : args.before;
271 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
272 let pusherName = "gluecron";
273 let pusherEmail = "noreply@gluecron.local";
274 try {
275 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
276 commits = list.slice(0, 50).map((c) => ({
277 id: c.sha,
278 message: c.message,
279 timestamp: c.date,
280 }));
281 if (list[0]) {
282 pusherName = list[0].author || pusherName;
283 pusherEmail = list[0].authorEmail || pusherEmail;
284 }
285 } catch {
286 /* ignore — payload still valid with empty commits[] */
287 }
288 return {
289 payload: {
290 event: "push",
291 repository: { full_name: `${args.owner}/${args.repo}` },
292 ref: args.ref,
293 after: args.after,
294 before: args.before,
295 pusher: { name: pusherName, email: pusherEmail },
296 commits,
297 // Ancillary fields — receiver may ignore but they're useful for logs:
298 sent_at: now.toISOString(),
299 source: "gluecron",
300 },
301 pusherName,
302 pusherEmail,
303 };
304}
305
fc1817aClaude306async function triggerCrontechDeploy(
ba93444Claude307 args: TriggerArgs,
308 opts: TriggerOptions = {}
fc1817aClaude309): Promise<void> {
ba93444Claude310 const fetchImpl = opts.fetchImpl ?? fetch;
311 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
312 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
313 const now = opts.now ?? (() => new Date());
314
3ef4c9dClaude315 let deployId = "";
fc1817aClaude316 try {
3ef4c9dClaude317 const [row] = await db
318 .insert(deployments)
319 .values({
ba93444Claude320 repositoryId: args.repositoryId,
3ef4c9dClaude321 environment: "production",
ba93444Claude322 commitSha: args.after,
323 ref: args.ref,
3ef4c9dClaude324 status: "pending",
325 target: "crontech",
326 })
327 .returning();
328 deployId = row?.id || "";
329 } catch {
330 /* ignore */
fc1817aClaude331 }
332
ba93444Claude333 const { payload } = await buildPayload(args, now());
334 const body = JSON.stringify(payload);
335 const signature = signBody(body, config.gluecronWebhookSecret);
336
337 const headers: Record<string, string> = {
338 "Content-Type": "application/json",
339 "User-Agent": "gluecron-webhook/1",
340 "X-Gluecron-Event": "push",
341 "X-Gluecron-Delivery": cryptoRandomId(),
342 };
343 if (signature) headers["X-Gluecron-Signature"] = signature;
344
345 let lastStatus = 0;
346 let lastError = "";
347 let success = false;
348
349 // Up to delays.length + 1 attempts (initial try + each delay).
350 const totalAttempts = delays.length + 1;
351 for (let attempt = 0; attempt < totalAttempts; attempt++) {
352 try {
353 const response = await fetchImpl(config.crontechDeployUrl, {
354 method: "POST",
355 headers,
356 body,
357 });
358 lastStatus = response.status;
359 console.log(
360 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
361 );
362 if (response.ok) {
363 success = true;
364 break;
365 }
366 // 4xx (except 408/429) is unrecoverable — stop retrying.
367 if (response.status >= 400 && response.status < 500 &&
368 response.status !== 408 && response.status !== 429) {
369 break;
370 }
371 } catch (err) {
372 lastError = err instanceof Error ? err.message : String(err);
373 console.error(
374 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
375 );
3ef4c9dClaude376 }
ba93444Claude377 const nextDelay = delays[attempt];
378 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
379 await sleep(nextDelay);
1e162a8Claude380 }
ba93444Claude381 }
382
383 if (deployId) {
384 try {
3ef4c9dClaude385 await db
386 .update(deployments)
387 .set({
ba93444Claude388 status: success ? "success" : "failed",
389 blockedReason: success
390 ? null
391 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude392 completedAt: new Date(),
393 })
394 .where(eq(deployments.id, deployId));
ba93444Claude395 } catch {
396 /* ignore */
3ef4c9dClaude397 }
398 }
399
ba93444Claude400 if (!success && deployId) {
401 void onDeployFailure({
402 repositoryId: args.repositoryId,
403 deploymentId: deployId,
404 ref: args.ref,
405 commitSha: args.after,
406 target: "crontech",
407 errorMessage: lastError || `HTTP ${lastStatus}`,
408 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude409 }
410}
43cf9b0Claude411
ba93444Claude412function cryptoRandomId(): string {
413 // Short opaque delivery ID for log correlation. Not security-sensitive.
414 const bytes = new Uint8Array(8);
415 crypto.getRandomValues(bytes);
416 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
417}
418
a686079Claude419/**
420 * Resolve `owner/repo` to its DB repository.id and dispatch the
421 * semantic-index update. Pulls the list of changed paths via
422 * `git diff --name-only`, dropping any deletions (handled implicitly
423 * because deleted blobs simply don't resolve in `indexChangedFiles`).
424 *
425 * Never throws — exhaust every external call inside a try/catch so the
426 * push completes even if Postgres or the embedding API is down.
427 */
428async function fireSemanticIndex(
429 owner: string,
430 repo: string,
431 oldSha: string,
432 newSha: string
433): Promise<void> {
434 let repositoryId = "";
435 try {
436 const [row] = await db
437 .select({ id: repositories.id })
438 .from(repositories)
439 .innerJoin(users, eq(repositories.ownerId, users.id))
440 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
441 .limit(1);
442 repositoryId = row?.id || "";
443 } catch {
444 return;
445 }
446 if (!repositoryId) return;
447
448 let changedPaths: string[] = [];
449 try {
450 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
451 } catch {
452 return;
453 }
454 if (!changedPaths.length) return;
455
456 try {
457 const out = await indexChangedFiles({
458 repositoryId,
459 ownerName: owner,
460 repoName: repo,
461 commitSha: newSha,
462 changedPaths,
463 });
464 if (out.indexed > 0) {
465 console.log(
466 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
467 );
468 }
469 } catch (err) {
470 console.warn("[semantic-index] indexChangedFiles error:", err);
471 }
472}
473
474/**
475 * Returns the list of files touched between `oldSha` and `newSha`. For
476 * the initial push on a branch (oldSha all-zero) we walk every file in
477 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
478 */
479async function listChangedPaths(
480 owner: string,
481 repo: string,
482 oldSha: string,
483 newSha: string
484): Promise<string[]> {
485 const cwd = getRepoPath(owner, repo);
486 const allZero = /^0+$/.test(oldSha);
487 const cmd = allZero
488 ? ["git", "ls-tree", "-r", "--name-only", newSha]
489 : ["git", "diff", "--name-only", oldSha, newSha];
490 try {
491 const proc = Bun.spawn(cmd, {
492 cwd,
493 stdout: "pipe",
494 stderr: "pipe",
495 });
496 const text = await new Response(proc.stdout).text();
497 await proc.exited;
498 return text
499 .split("\n")
500 .map((s) => s.trim())
501 .filter((s) => s.length > 0);
502 } catch {
503 return [];
504 }
505}
506
4bbacbeClaude507/**
508 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
509 * non-default branch on a `preview_builds_enabled` repo.
510 *
511 * Resolves the repo row once, fetches the default branch + opt-out flag,
512 * then upserts one preview row per pushed ref that isn't the default.
513 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
514 * are skipped — they shouldn't create preview rows. Never throws.
515 */
516async function firePreviewBuilds(
517 owner: string,
518 repo: string,
519 refs: PushRef[]
520): Promise<void> {
521 // Filter to live branch pushes only.
522 const branchRefs = refs.filter(
523 (r) =>
524 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
525 );
526 if (branchRefs.length === 0) return;
527
528 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
529 try {
530 const [row] = await db
531 .select({
532 id: repositories.id,
533 previewBuildsEnabled: repositories.previewBuildsEnabled,
534 defaultBranch: repositories.defaultBranch,
535 })
536 .from(repositories)
537 .innerJoin(users, eq(repositories.ownerId, users.id))
538 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
539 .limit(1);
540 repoRow = row || null;
541 } catch {
542 return;
543 }
544 if (!repoRow) return;
545 if (!repoRow.previewBuildsEnabled) return;
546
547 for (const ref of branchRefs) {
548 const branchName = ref.refName.replace("refs/heads/", "");
549 if (branchName === repoRow.defaultBranch) continue;
550 try {
551 await enqueuePreviewBuild({
552 repositoryId: repoRow.id,
553 ownerName: owner,
554 repoName: repo,
555 branchName,
556 commitSha: ref.newSha,
557 });
558 } catch (err) {
559 console.warn(
560 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
561 err instanceof Error ? err.message : err
562 );
563 }
564 }
565}
566
43cf9b0Claude567/** Test-only access to internal helpers. */
a686079Claude568export const __test = {
569 triggerCrontechDeploy,
570 signBody,
571 buildPayload,
572 RETRY_DELAYS_MS,
573 listChangedPaths,
574 fireSemanticIndex,
4bbacbeClaude575 firePreviewBuilds,
a686079Claude576};