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.tsBlame377 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";
17import { analyzePush, computeHealthScore } from "../lib/intelligence";
0316dbbClaude18import { db } from "../db";
19import { deployments, repositories, users } from "../db/schema";
20import { onDeployFailure } from "../lib/ai-incident";
ba93444Claude21import { commitsBetween, getDefaultBranch } from "../git/repository";
fc1817aClaude22
23interface PushRef {
24 oldSha: string;
25 newSha: string;
26 refName: string;
27}
28
29export async function onPostReceive(
30 owner: string,
31 repo: string,
32 refs: PushRef[]
33): Promise<void> {
2c34075Claude34 for (const ref of refs) {
35 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
36 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude37
2c34075Claude38 // 1. Auto-repair (runs first, may create a new commit)
39 try {
40 const repair = await autoRepair(owner, repo, branchName);
41 if (repair.repaired) {
42 console.log(
43 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
44 );
45 }
46 } catch (err) {
47 console.error(`[autorepair] error:`, err);
48 }
fc1817aClaude49
2c34075Claude50 // 2. Push analysis
51 try {
52 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
53 console.log(
54 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
55 );
56 if (analysis.riskScore > 50) {
57 console.warn(
58 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
59 );
60 }
61 if (analysis.breakingChangeSignals.length > 0) {
62 console.warn(
63 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
64 );
65 }
66 } catch (err) {
67 console.error(`[push-analysis] error:`, err);
68 }
69
70 // 3. Health score (async, don't block)
71 computeHealthScore(owner, repo).then((report) => {
72 console.log(
73 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
74 );
75 }).catch((err) => {
76 console.error(`[health] error:`, err);
77 });
78 }
79
0316dbbClaude80 // 4. GateTest scan — fire-and-forget via generic webhook; the standalone
81 // triggerGateTest helper is slated for the intelligence rework.
2c34075Claude82
ba93444Claude83 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
84 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
85 // default branch. The branch case (`Main` vs `main`) is determined by
86 // the bare repo's HEAD, not hardcoded.
87 if (`${owner}/${repo}` === config.crontechRepo) {
88 let defaultBranch = (await getDefaultBranch(owner, repo).catch(() => null)) || "main";
89 const targetRef = `refs/heads/${defaultBranch}`;
90 const deployPush = refs.find(
91 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
92 );
93 if (deployPush) {
94 let repositoryId = "";
95 try {
96 const [row] = await db
97 .select({ id: repositories.id })
98 .from(repositories)
99 .innerJoin(users, eq(repositories.ownerId, users.id))
100 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
101 .limit(1);
102 repositoryId = row?.id || "";
103 } catch {
104 /* ignore */
105 }
106 if (repositoryId) {
107 triggerCrontechDeploy({
108 owner,
109 repo,
110 before: deployPush.oldSha,
111 after: deployPush.newSha,
112 ref: targetRef,
113 branch: defaultBranch,
114 repositoryId,
115 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
116 }
0316dbbClaude117 }
fc1817aClaude118 }
f2c00b4CC LABS App119
120 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
121 // main, fire the local deploy via scripts/self-deploy.sh. The script
122 // forks into the background, so this call returns immediately (git
123 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
124 // avoid firing on customer repos that happen to be named "Gluecron.com".
125 const selfHostRepo = process.env.SELF_HOST_REPO;
126 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
127 const mainRef = refs.find(
128 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
129 );
130 if (mainRef) {
131 const scriptPath =
132 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
133 "/opt/gluecron/scripts/self-deploy.sh";
134 try {
135 const child = __selfHostSpawn(
136 [scriptPath, mainRef.oldSha, mainRef.newSha],
137 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
138 );
139 try {
140 (child as any)?.unref?.();
141 } catch {
142 /* unref optional */
143 }
144 console.log(
145 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
146 );
147 } catch (err) {
148 console.error(`[self-host] failed to spawn:`, err);
149 }
150 }
151 }
152}
153
154// BLOCK W — DI seam so the test suite can capture the spawn call without
155// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
156// callers go straight to Bun.spawn.
bf19c50Test User157const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App158 Bun.spawn(cmd, opts);
bf19c50Test User159let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
160/**
161 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
162 */
163export function __setSelfHostSpawnForTests(
164 fn: typeof __selfHostSpawn | null
165): void {
166 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude167}
168
43cf9b0Claude169/**
ba93444Claude170 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude171 *
ba93444Claude172 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude173 *
ba93444Claude174 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude175 * Content-Type: application/json
ba93444Claude176 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude177 *
178 * {
ba93444Claude179 * "event": "push",
180 * "repository": { "full_name": "ccantynz-alt/crontech" },
181 * "ref": "refs/heads/Main",
182 * "after": "<40-hex commit SHA>",
183 * "before": "<40-hex previous SHA>",
184 * "pusher": { "name": "<author>", "email": "<email>" },
185 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude186 * }
187 *
ba93444Claude188 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude189 *
ba93444Claude190 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
191 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
192 * is unset the signature header is omitted and Crontech is expected to reject —
193 * we still record the deploy row as failed.
43cf9b0Claude194 */
ba93444Claude195const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
196
197interface TriggerArgs {
198 owner: string;
199 repo: string;
200 before: string;
201 after: string;
202 ref: string;
203 branch: string;
204 repositoryId: string;
205}
206
207interface TriggerOptions {
208 fetchImpl?: typeof fetch;
209 sleep?: (ms: number) => Promise<void>;
210 retryDelaysMs?: number[];
211 now?: () => Date;
212}
213
214function signBody(body: string, secret: string): string | null {
215 if (!secret) return null;
216 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
217}
218
219async function buildPayload(args: TriggerArgs, now: Date): Promise<{
220 payload: Record<string, unknown>;
221 pusherName: string;
222 pusherEmail: string;
223}> {
224 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
225 // `before` may be all-zeros for a first push to the branch — commitsBetween
226 // handles that by treating null `from` as "everything reachable from `to`".
227 const fromSha = /^0+$/.test(args.before) ? null : args.before;
228 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
229 let pusherName = "gluecron";
230 let pusherEmail = "noreply@gluecron.local";
231 try {
232 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
233 commits = list.slice(0, 50).map((c) => ({
234 id: c.sha,
235 message: c.message,
236 timestamp: c.date,
237 }));
238 if (list[0]) {
239 pusherName = list[0].author || pusherName;
240 pusherEmail = list[0].authorEmail || pusherEmail;
241 }
242 } catch {
243 /* ignore — payload still valid with empty commits[] */
244 }
245 return {
246 payload: {
247 event: "push",
248 repository: { full_name: `${args.owner}/${args.repo}` },
249 ref: args.ref,
250 after: args.after,
251 before: args.before,
252 pusher: { name: pusherName, email: pusherEmail },
253 commits,
254 // Ancillary fields — receiver may ignore but they're useful for logs:
255 sent_at: now.toISOString(),
256 source: "gluecron",
257 },
258 pusherName,
259 pusherEmail,
260 };
261}
262
fc1817aClaude263async function triggerCrontechDeploy(
ba93444Claude264 args: TriggerArgs,
265 opts: TriggerOptions = {}
fc1817aClaude266): Promise<void> {
ba93444Claude267 const fetchImpl = opts.fetchImpl ?? fetch;
268 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
269 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
270 const now = opts.now ?? (() => new Date());
271
3ef4c9dClaude272 let deployId = "";
fc1817aClaude273 try {
3ef4c9dClaude274 const [row] = await db
275 .insert(deployments)
276 .values({
ba93444Claude277 repositoryId: args.repositoryId,
3ef4c9dClaude278 environment: "production",
ba93444Claude279 commitSha: args.after,
280 ref: args.ref,
3ef4c9dClaude281 status: "pending",
282 target: "crontech",
283 })
284 .returning();
285 deployId = row?.id || "";
286 } catch {
287 /* ignore */
fc1817aClaude288 }
289
ba93444Claude290 const { payload } = await buildPayload(args, now());
291 const body = JSON.stringify(payload);
292 const signature = signBody(body, config.gluecronWebhookSecret);
293
294 const headers: Record<string, string> = {
295 "Content-Type": "application/json",
296 "User-Agent": "gluecron-webhook/1",
297 "X-Gluecron-Event": "push",
298 "X-Gluecron-Delivery": cryptoRandomId(),
299 };
300 if (signature) headers["X-Gluecron-Signature"] = signature;
301
302 let lastStatus = 0;
303 let lastError = "";
304 let success = false;
305
306 // Up to delays.length + 1 attempts (initial try + each delay).
307 const totalAttempts = delays.length + 1;
308 for (let attempt = 0; attempt < totalAttempts; attempt++) {
309 try {
310 const response = await fetchImpl(config.crontechDeployUrl, {
311 method: "POST",
312 headers,
313 body,
314 });
315 lastStatus = response.status;
316 console.log(
317 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
318 );
319 if (response.ok) {
320 success = true;
321 break;
322 }
323 // 4xx (except 408/429) is unrecoverable — stop retrying.
324 if (response.status >= 400 && response.status < 500 &&
325 response.status !== 408 && response.status !== 429) {
326 break;
327 }
328 } catch (err) {
329 lastError = err instanceof Error ? err.message : String(err);
330 console.error(
331 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
332 );
3ef4c9dClaude333 }
ba93444Claude334 const nextDelay = delays[attempt];
335 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
336 await sleep(nextDelay);
1e162a8Claude337 }
ba93444Claude338 }
339
340 if (deployId) {
341 try {
3ef4c9dClaude342 await db
343 .update(deployments)
344 .set({
ba93444Claude345 status: success ? "success" : "failed",
346 blockedReason: success
347 ? null
348 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude349 completedAt: new Date(),
350 })
351 .where(eq(deployments.id, deployId));
ba93444Claude352 } catch {
353 /* ignore */
3ef4c9dClaude354 }
355 }
356
ba93444Claude357 if (!success && deployId) {
358 void onDeployFailure({
359 repositoryId: args.repositoryId,
360 deploymentId: deployId,
361 ref: args.ref,
362 commitSha: args.after,
363 target: "crontech",
364 errorMessage: lastError || `HTTP ${lastStatus}`,
365 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude366 }
367}
43cf9b0Claude368
ba93444Claude369function cryptoRandomId(): string {
370 // Short opaque delivery ID for log correlation. Not security-sensitive.
371 const bytes = new Uint8Array(8);
372 crypto.getRandomValues(bytes);
373 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
374}
375
43cf9b0Claude376/** Test-only access to internal helpers. */
ba93444Claude377export const __test = { triggerCrontechDeploy, signBody, buildPayload, RETRY_DELAYS_MS };