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.tsBlame386 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) {
97 let defaultBranch = (await getDefaultBranch(owner, repo).catch(() => null)) || "main";
98 const targetRef = `refs/heads/${defaultBranch}`;
99 const deployPush = refs.find(
100 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
101 );
102 if (deployPush) {
103 let repositoryId = "";
104 try {
105 const [row] = await db
106 .select({ id: repositories.id })
107 .from(repositories)
108 .innerJoin(users, eq(repositories.ownerId, users.id))
109 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
110 .limit(1);
111 repositoryId = row?.id || "";
112 } catch {
113 /* ignore */
114 }
115 if (repositoryId) {
116 triggerCrontechDeploy({
117 owner,
118 repo,
119 before: deployPush.oldSha,
120 after: deployPush.newSha,
121 ref: targetRef,
122 branch: defaultBranch,
123 repositoryId,
124 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
125 }
0316dbbClaude126 }
fc1817aClaude127 }
f2c00b4CC LABS App128
129 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
130 // main, fire the local deploy via scripts/self-deploy.sh. The script
131 // forks into the background, so this call returns immediately (git
132 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
133 // avoid firing on customer repos that happen to be named "Gluecron.com".
134 const selfHostRepo = process.env.SELF_HOST_REPO;
135 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
136 const mainRef = refs.find(
137 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
138 );
139 if (mainRef) {
140 const scriptPath =
141 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
142 "/opt/gluecron/scripts/self-deploy.sh";
143 try {
144 const child = __selfHostSpawn(
145 [scriptPath, mainRef.oldSha, mainRef.newSha],
146 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
147 );
148 try {
149 (child as any)?.unref?.();
150 } catch {
151 /* unref optional */
152 }
153 console.log(
154 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
155 );
156 } catch (err) {
157 console.error(`[self-host] failed to spawn:`, err);
158 }
159 }
160 }
161}
162
163// BLOCK W — DI seam so the test suite can capture the spawn call without
164// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
165// callers go straight to Bun.spawn.
bf19c50Test User166const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App167 Bun.spawn(cmd, opts);
bf19c50Test User168let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
169/**
170 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
171 */
172export function __setSelfHostSpawnForTests(
173 fn: typeof __selfHostSpawn | null
174): void {
175 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude176}
177
43cf9b0Claude178/**
ba93444Claude179 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude180 *
ba93444Claude181 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude182 *
ba93444Claude183 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude184 * Content-Type: application/json
ba93444Claude185 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude186 *
187 * {
ba93444Claude188 * "event": "push",
189 * "repository": { "full_name": "ccantynz-alt/crontech" },
190 * "ref": "refs/heads/Main",
191 * "after": "<40-hex commit SHA>",
192 * "before": "<40-hex previous SHA>",
193 * "pusher": { "name": "<author>", "email": "<email>" },
194 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude195 * }
196 *
ba93444Claude197 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude198 *
ba93444Claude199 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
200 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
201 * is unset the signature header is omitted and Crontech is expected to reject —
202 * we still record the deploy row as failed.
43cf9b0Claude203 */
ba93444Claude204const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
205
206interface TriggerArgs {
207 owner: string;
208 repo: string;
209 before: string;
210 after: string;
211 ref: string;
212 branch: string;
213 repositoryId: string;
214}
215
216interface TriggerOptions {
217 fetchImpl?: typeof fetch;
218 sleep?: (ms: number) => Promise<void>;
219 retryDelaysMs?: number[];
220 now?: () => Date;
221}
222
223function signBody(body: string, secret: string): string | null {
224 if (!secret) return null;
225 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
226}
227
228async function buildPayload(args: TriggerArgs, now: Date): Promise<{
229 payload: Record<string, unknown>;
230 pusherName: string;
231 pusherEmail: string;
232}> {
233 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
234 // `before` may be all-zeros for a first push to the branch — commitsBetween
235 // handles that by treating null `from` as "everything reachable from `to`".
236 const fromSha = /^0+$/.test(args.before) ? null : args.before;
237 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
238 let pusherName = "gluecron";
239 let pusherEmail = "noreply@gluecron.local";
240 try {
241 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
242 commits = list.slice(0, 50).map((c) => ({
243 id: c.sha,
244 message: c.message,
245 timestamp: c.date,
246 }));
247 if (list[0]) {
248 pusherName = list[0].author || pusherName;
249 pusherEmail = list[0].authorEmail || pusherEmail;
250 }
251 } catch {
252 /* ignore — payload still valid with empty commits[] */
253 }
254 return {
255 payload: {
256 event: "push",
257 repository: { full_name: `${args.owner}/${args.repo}` },
258 ref: args.ref,
259 after: args.after,
260 before: args.before,
261 pusher: { name: pusherName, email: pusherEmail },
262 commits,
263 // Ancillary fields — receiver may ignore but they're useful for logs:
264 sent_at: now.toISOString(),
265 source: "gluecron",
266 },
267 pusherName,
268 pusherEmail,
269 };
270}
271
fc1817aClaude272async function triggerCrontechDeploy(
ba93444Claude273 args: TriggerArgs,
274 opts: TriggerOptions = {}
fc1817aClaude275): Promise<void> {
ba93444Claude276 const fetchImpl = opts.fetchImpl ?? fetch;
277 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
278 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
279 const now = opts.now ?? (() => new Date());
280
3ef4c9dClaude281 let deployId = "";
fc1817aClaude282 try {
3ef4c9dClaude283 const [row] = await db
284 .insert(deployments)
285 .values({
ba93444Claude286 repositoryId: args.repositoryId,
3ef4c9dClaude287 environment: "production",
ba93444Claude288 commitSha: args.after,
289 ref: args.ref,
3ef4c9dClaude290 status: "pending",
291 target: "crontech",
292 })
293 .returning();
294 deployId = row?.id || "";
295 } catch {
296 /* ignore */
fc1817aClaude297 }
298
ba93444Claude299 const { payload } = await buildPayload(args, now());
300 const body = JSON.stringify(payload);
301 const signature = signBody(body, config.gluecronWebhookSecret);
302
303 const headers: Record<string, string> = {
304 "Content-Type": "application/json",
305 "User-Agent": "gluecron-webhook/1",
306 "X-Gluecron-Event": "push",
307 "X-Gluecron-Delivery": cryptoRandomId(),
308 };
309 if (signature) headers["X-Gluecron-Signature"] = signature;
310
311 let lastStatus = 0;
312 let lastError = "";
313 let success = false;
314
315 // Up to delays.length + 1 attempts (initial try + each delay).
316 const totalAttempts = delays.length + 1;
317 for (let attempt = 0; attempt < totalAttempts; attempt++) {
318 try {
319 const response = await fetchImpl(config.crontechDeployUrl, {
320 method: "POST",
321 headers,
322 body,
323 });
324 lastStatus = response.status;
325 console.log(
326 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
327 );
328 if (response.ok) {
329 success = true;
330 break;
331 }
332 // 4xx (except 408/429) is unrecoverable — stop retrying.
333 if (response.status >= 400 && response.status < 500 &&
334 response.status !== 408 && response.status !== 429) {
335 break;
336 }
337 } catch (err) {
338 lastError = err instanceof Error ? err.message : String(err);
339 console.error(
340 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
341 );
3ef4c9dClaude342 }
ba93444Claude343 const nextDelay = delays[attempt];
344 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
345 await sleep(nextDelay);
1e162a8Claude346 }
ba93444Claude347 }
348
349 if (deployId) {
350 try {
3ef4c9dClaude351 await db
352 .update(deployments)
353 .set({
ba93444Claude354 status: success ? "success" : "failed",
355 blockedReason: success
356 ? null
357 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude358 completedAt: new Date(),
359 })
360 .where(eq(deployments.id, deployId));
ba93444Claude361 } catch {
362 /* ignore */
3ef4c9dClaude363 }
364 }
365
ba93444Claude366 if (!success && deployId) {
367 void onDeployFailure({
368 repositoryId: args.repositoryId,
369 deploymentId: deployId,
370 ref: args.ref,
371 commitSha: args.after,
372 target: "crontech",
373 errorMessage: lastError || `HTTP ${lastStatus}`,
374 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude375 }
376}
43cf9b0Claude377
ba93444Claude378function cryptoRandomId(): string {
379 // Short opaque delivery ID for log correlation. Not security-sensitive.
380 const bytes = new Uint8Array(8);
381 crypto.getRandomValues(bytes);
382 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
383}
384
43cf9b0Claude385/** Test-only access to internal helpers. */
ba93444Claude386export const __test = { triggerCrontechDeploy, signBody, buildPayload, RETRY_DELAYS_MS };