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.tsBlame393 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";
ba93444Claude22import { commitsBetween, getDefaultBranch } from "../git/repository";
fc1817aClaude23
24interface PushRef {
25 oldSha: string;
26 newSha: string;
27 refName: string;
28}
29
30export async function onPostReceive(
31 owner: string,
32 repo: string,
33 refs: PushRef[]
34): Promise<void> {
2c34075Claude35 for (const ref of refs) {
36 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
37 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude38
2c34075Claude39 // 1. Auto-repair (runs first, may create a new commit)
40 try {
41 const repair = await autoRepair(owner, repo, branchName);
42 if (repair.repaired) {
43 console.log(
44 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
45 );
46 }
47 } catch (err) {
48 console.error(`[autorepair] error:`, err);
49 }
fc1817aClaude50
2c34075Claude51 // 2. Push analysis
52 try {
53 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
54 console.log(
55 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
56 );
57 if (analysis.riskScore > 50) {
58 console.warn(
59 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
60 );
61 }
62 if (analysis.breakingChangeSignals.length > 0) {
63 console.warn(
64 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
65 );
66 }
67 } catch (err) {
68 console.error(`[push-analysis] error:`, err);
69 }
70
71 // 3. Health score (async, don't block)
72 computeHealthScore(owner, repo).then((report) => {
73 console.log(
74 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
75 );
76 }).catch((err) => {
77 console.error(`[health] error:`, err);
78 });
79 }
80
170ddb2Claude81 // 4. GateTest scan — fire-and-forget notification on every push. The
82 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
83 // deployments pay no overhead. Results flow back via the inbound
84 // webhook at POST /api/hooks/gatetest.
85 for (const ref of refs) {
86 if (ref.newSha.startsWith("0000")) continue;
87 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
88 console.warn("[gatetest] notify error:", err)
89 );
90 }
2c34075Claude91
ba93444Claude92 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
93 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
94 // default branch. The branch case (`Main` vs `main`) is determined by
95 // the bare repo's HEAD, not hardcoded.
96 if (`${owner}/${repo}` === config.crontechRepo) {
a28cedeClaude97 let defaultBranch =
98 (await getDefaultBranch(owner, repo).catch((err) => {
99 console.warn(
100 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
101 err instanceof Error ? err.message : err
102 );
103 return null;
104 })) || "main";
ba93444Claude105 const targetRef = `refs/heads/${defaultBranch}`;
106 const deployPush = refs.find(
107 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
108 );
109 if (deployPush) {
110 let repositoryId = "";
111 try {
112 const [row] = await db
113 .select({ id: repositories.id })
114 .from(repositories)
115 .innerJoin(users, eq(repositories.ownerId, users.id))
116 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
117 .limit(1);
118 repositoryId = row?.id || "";
119 } catch {
120 /* ignore */
121 }
122 if (repositoryId) {
123 triggerCrontechDeploy({
124 owner,
125 repo,
126 before: deployPush.oldSha,
127 after: deployPush.newSha,
128 ref: targetRef,
129 branch: defaultBranch,
130 repositoryId,
131 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
132 }
0316dbbClaude133 }
fc1817aClaude134 }
f2c00b4CC LABS App135
136 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
137 // main, fire the local deploy via scripts/self-deploy.sh. The script
138 // forks into the background, so this call returns immediately (git
139 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
140 // avoid firing on customer repos that happen to be named "Gluecron.com".
141 const selfHostRepo = process.env.SELF_HOST_REPO;
142 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
143 const mainRef = refs.find(
144 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
145 );
146 if (mainRef) {
147 const scriptPath =
148 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
149 "/opt/gluecron/scripts/self-deploy.sh";
150 try {
151 const child = __selfHostSpawn(
152 [scriptPath, mainRef.oldSha, mainRef.newSha],
153 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
154 );
155 try {
156 (child as any)?.unref?.();
157 } catch {
158 /* unref optional */
159 }
160 console.log(
161 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
162 );
163 } catch (err) {
164 console.error(`[self-host] failed to spawn:`, err);
165 }
166 }
167 }
168}
169
170// BLOCK W — DI seam so the test suite can capture the spawn call without
171// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
172// callers go straight to Bun.spawn.
bf19c50Test User173const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App174 Bun.spawn(cmd, opts);
bf19c50Test User175let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
176/**
177 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
178 */
179export function __setSelfHostSpawnForTests(
180 fn: typeof __selfHostSpawn | null
181): void {
182 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude183}
184
43cf9b0Claude185/**
ba93444Claude186 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude187 *
ba93444Claude188 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude189 *
ba93444Claude190 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude191 * Content-Type: application/json
ba93444Claude192 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude193 *
194 * {
ba93444Claude195 * "event": "push",
196 * "repository": { "full_name": "ccantynz-alt/crontech" },
197 * "ref": "refs/heads/Main",
198 * "after": "<40-hex commit SHA>",
199 * "before": "<40-hex previous SHA>",
200 * "pusher": { "name": "<author>", "email": "<email>" },
201 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude202 * }
203 *
ba93444Claude204 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude205 *
ba93444Claude206 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
207 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
208 * is unset the signature header is omitted and Crontech is expected to reject —
209 * we still record the deploy row as failed.
43cf9b0Claude210 */
ba93444Claude211const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
212
213interface TriggerArgs {
214 owner: string;
215 repo: string;
216 before: string;
217 after: string;
218 ref: string;
219 branch: string;
220 repositoryId: string;
221}
222
223interface TriggerOptions {
224 fetchImpl?: typeof fetch;
225 sleep?: (ms: number) => Promise<void>;
226 retryDelaysMs?: number[];
227 now?: () => Date;
228}
229
230function signBody(body: string, secret: string): string | null {
231 if (!secret) return null;
232 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
233}
234
235async function buildPayload(args: TriggerArgs, now: Date): Promise<{
236 payload: Record<string, unknown>;
237 pusherName: string;
238 pusherEmail: string;
239}> {
240 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
241 // `before` may be all-zeros for a first push to the branch — commitsBetween
242 // handles that by treating null `from` as "everything reachable from `to`".
243 const fromSha = /^0+$/.test(args.before) ? null : args.before;
244 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
245 let pusherName = "gluecron";
246 let pusherEmail = "noreply@gluecron.local";
247 try {
248 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
249 commits = list.slice(0, 50).map((c) => ({
250 id: c.sha,
251 message: c.message,
252 timestamp: c.date,
253 }));
254 if (list[0]) {
255 pusherName = list[0].author || pusherName;
256 pusherEmail = list[0].authorEmail || pusherEmail;
257 }
258 } catch {
259 /* ignore — payload still valid with empty commits[] */
260 }
261 return {
262 payload: {
263 event: "push",
264 repository: { full_name: `${args.owner}/${args.repo}` },
265 ref: args.ref,
266 after: args.after,
267 before: args.before,
268 pusher: { name: pusherName, email: pusherEmail },
269 commits,
270 // Ancillary fields — receiver may ignore but they're useful for logs:
271 sent_at: now.toISOString(),
272 source: "gluecron",
273 },
274 pusherName,
275 pusherEmail,
276 };
277}
278
fc1817aClaude279async function triggerCrontechDeploy(
ba93444Claude280 args: TriggerArgs,
281 opts: TriggerOptions = {}
fc1817aClaude282): Promise<void> {
ba93444Claude283 const fetchImpl = opts.fetchImpl ?? fetch;
284 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
285 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
286 const now = opts.now ?? (() => new Date());
287
3ef4c9dClaude288 let deployId = "";
fc1817aClaude289 try {
3ef4c9dClaude290 const [row] = await db
291 .insert(deployments)
292 .values({
ba93444Claude293 repositoryId: args.repositoryId,
3ef4c9dClaude294 environment: "production",
ba93444Claude295 commitSha: args.after,
296 ref: args.ref,
3ef4c9dClaude297 status: "pending",
298 target: "crontech",
299 })
300 .returning();
301 deployId = row?.id || "";
302 } catch {
303 /* ignore */
fc1817aClaude304 }
305
ba93444Claude306 const { payload } = await buildPayload(args, now());
307 const body = JSON.stringify(payload);
308 const signature = signBody(body, config.gluecronWebhookSecret);
309
310 const headers: Record<string, string> = {
311 "Content-Type": "application/json",
312 "User-Agent": "gluecron-webhook/1",
313 "X-Gluecron-Event": "push",
314 "X-Gluecron-Delivery": cryptoRandomId(),
315 };
316 if (signature) headers["X-Gluecron-Signature"] = signature;
317
318 let lastStatus = 0;
319 let lastError = "";
320 let success = false;
321
322 // Up to delays.length + 1 attempts (initial try + each delay).
323 const totalAttempts = delays.length + 1;
324 for (let attempt = 0; attempt < totalAttempts; attempt++) {
325 try {
326 const response = await fetchImpl(config.crontechDeployUrl, {
327 method: "POST",
328 headers,
329 body,
330 });
331 lastStatus = response.status;
332 console.log(
333 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
334 );
335 if (response.ok) {
336 success = true;
337 break;
338 }
339 // 4xx (except 408/429) is unrecoverable — stop retrying.
340 if (response.status >= 400 && response.status < 500 &&
341 response.status !== 408 && response.status !== 429) {
342 break;
343 }
344 } catch (err) {
345 lastError = err instanceof Error ? err.message : String(err);
346 console.error(
347 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
348 );
3ef4c9dClaude349 }
ba93444Claude350 const nextDelay = delays[attempt];
351 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
352 await sleep(nextDelay);
1e162a8Claude353 }
ba93444Claude354 }
355
356 if (deployId) {
357 try {
3ef4c9dClaude358 await db
359 .update(deployments)
360 .set({
ba93444Claude361 status: success ? "success" : "failed",
362 blockedReason: success
363 ? null
364 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude365 completedAt: new Date(),
366 })
367 .where(eq(deployments.id, deployId));
ba93444Claude368 } catch {
369 /* ignore */
3ef4c9dClaude370 }
371 }
372
ba93444Claude373 if (!success && deployId) {
374 void onDeployFailure({
375 repositoryId: args.repositoryId,
376 deploymentId: deployId,
377 ref: args.ref,
378 commitSha: args.after,
379 target: "crontech",
380 errorMessage: lastError || `HTTP ${lastStatus}`,
381 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude382 }
383}
43cf9b0Claude384
ba93444Claude385function cryptoRandomId(): string {
386 // Short opaque delivery ID for log correlation. Not security-sensitive.
387 const bytes = new Uint8Array(8);
388 crypto.getRandomValues(bytes);
389 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
390}
391
43cf9b0Claude392/** Test-only access to internal helpers. */
ba93444Claude393export const __test = { triggerCrontechDeploy, signBody, buildPayload, RETRY_DELAYS_MS };