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.tsBlame371 lines · 2 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 }
4992afaTest User119
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.
157let __selfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
158 Bun.spawn(cmd, opts);
159export function __setSelfHostSpawnForTests(fn: typeof __selfHostSpawn) {
160 __selfHostSpawn = fn;
fc1817aClaude161}
162
43cf9b0Claude163/**
ba93444Claude164 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude165 *
ba93444Claude166 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude167 *
ba93444Claude168 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude169 * Content-Type: application/json
ba93444Claude170 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude171 *
172 * {
ba93444Claude173 * "event": "push",
174 * "repository": { "full_name": "ccantynz-alt/crontech" },
175 * "ref": "refs/heads/Main",
176 * "after": "<40-hex commit SHA>",
177 * "before": "<40-hex previous SHA>",
178 * "pusher": { "name": "<author>", "email": "<email>" },
179 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude180 * }
181 *
ba93444Claude182 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude183 *
ba93444Claude184 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
185 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
186 * is unset the signature header is omitted and Crontech is expected to reject —
187 * we still record the deploy row as failed.
43cf9b0Claude188 */
ba93444Claude189const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
190
191interface TriggerArgs {
192 owner: string;
193 repo: string;
194 before: string;
195 after: string;
196 ref: string;
197 branch: string;
198 repositoryId: string;
199}
200
201interface TriggerOptions {
202 fetchImpl?: typeof fetch;
203 sleep?: (ms: number) => Promise<void>;
204 retryDelaysMs?: number[];
205 now?: () => Date;
206}
207
208function signBody(body: string, secret: string): string | null {
209 if (!secret) return null;
210 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
211}
212
213async function buildPayload(args: TriggerArgs, now: Date): Promise<{
214 payload: Record<string, unknown>;
215 pusherName: string;
216 pusherEmail: string;
217}> {
218 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
219 // `before` may be all-zeros for a first push to the branch — commitsBetween
220 // handles that by treating null `from` as "everything reachable from `to`".
221 const fromSha = /^0+$/.test(args.before) ? null : args.before;
222 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
223 let pusherName = "gluecron";
224 let pusherEmail = "noreply@gluecron.local";
225 try {
226 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
227 commits = list.slice(0, 50).map((c) => ({
228 id: c.sha,
229 message: c.message,
230 timestamp: c.date,
231 }));
232 if (list[0]) {
233 pusherName = list[0].author || pusherName;
234 pusherEmail = list[0].authorEmail || pusherEmail;
235 }
236 } catch {
237 /* ignore — payload still valid with empty commits[] */
238 }
239 return {
240 payload: {
241 event: "push",
242 repository: { full_name: `${args.owner}/${args.repo}` },
243 ref: args.ref,
244 after: args.after,
245 before: args.before,
246 pusher: { name: pusherName, email: pusherEmail },
247 commits,
248 // Ancillary fields — receiver may ignore but they're useful for logs:
249 sent_at: now.toISOString(),
250 source: "gluecron",
251 },
252 pusherName,
253 pusherEmail,
254 };
255}
256
fc1817aClaude257async function triggerCrontechDeploy(
ba93444Claude258 args: TriggerArgs,
259 opts: TriggerOptions = {}
fc1817aClaude260): Promise<void> {
ba93444Claude261 const fetchImpl = opts.fetchImpl ?? fetch;
262 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
263 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
264 const now = opts.now ?? (() => new Date());
265
3ef4c9dClaude266 let deployId = "";
fc1817aClaude267 try {
3ef4c9dClaude268 const [row] = await db
269 .insert(deployments)
270 .values({
ba93444Claude271 repositoryId: args.repositoryId,
3ef4c9dClaude272 environment: "production",
ba93444Claude273 commitSha: args.after,
274 ref: args.ref,
3ef4c9dClaude275 status: "pending",
276 target: "crontech",
277 })
278 .returning();
279 deployId = row?.id || "";
280 } catch {
281 /* ignore */
fc1817aClaude282 }
283
ba93444Claude284 const { payload } = await buildPayload(args, now());
285 const body = JSON.stringify(payload);
286 const signature = signBody(body, config.gluecronWebhookSecret);
287
288 const headers: Record<string, string> = {
289 "Content-Type": "application/json",
290 "User-Agent": "gluecron-webhook/1",
291 "X-Gluecron-Event": "push",
292 "X-Gluecron-Delivery": cryptoRandomId(),
293 };
294 if (signature) headers["X-Gluecron-Signature"] = signature;
295
296 let lastStatus = 0;
297 let lastError = "";
298 let success = false;
299
300 // Up to delays.length + 1 attempts (initial try + each delay).
301 const totalAttempts = delays.length + 1;
302 for (let attempt = 0; attempt < totalAttempts; attempt++) {
303 try {
304 const response = await fetchImpl(config.crontechDeployUrl, {
305 method: "POST",
306 headers,
307 body,
308 });
309 lastStatus = response.status;
310 console.log(
311 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
312 );
313 if (response.ok) {
314 success = true;
315 break;
316 }
317 // 4xx (except 408/429) is unrecoverable — stop retrying.
318 if (response.status >= 400 && response.status < 500 &&
319 response.status !== 408 && response.status !== 429) {
320 break;
321 }
322 } catch (err) {
323 lastError = err instanceof Error ? err.message : String(err);
324 console.error(
325 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
326 );
3ef4c9dClaude327 }
ba93444Claude328 const nextDelay = delays[attempt];
329 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
330 await sleep(nextDelay);
1e162a8Claude331 }
ba93444Claude332 }
333
334 if (deployId) {
335 try {
3ef4c9dClaude336 await db
337 .update(deployments)
338 .set({
ba93444Claude339 status: success ? "success" : "failed",
340 blockedReason: success
341 ? null
342 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude343 completedAt: new Date(),
344 })
345 .where(eq(deployments.id, deployId));
ba93444Claude346 } catch {
347 /* ignore */
3ef4c9dClaude348 }
349 }
350
ba93444Claude351 if (!success && deployId) {
352 void onDeployFailure({
353 repositoryId: args.repositoryId,
354 deploymentId: deployId,
355 ref: args.ref,
356 commitSha: args.after,
357 target: "crontech",
358 errorMessage: lastError || `HTTP ${lastStatus}`,
359 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude360 }
361}
43cf9b0Claude362
ba93444Claude363function cryptoRandomId(): string {
364 // Short opaque delivery ID for log correlation. Not security-sensitive.
365 const bytes = new Uint8Array(8);
366 crypto.getRandomValues(bytes);
367 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
368}
369
43cf9b0Claude370/** Test-only access to internal helpers. */
ba93444Claude371export const __test = { triggerCrontechDeploy, signBody, buildPayload, RETRY_DELAYS_MS };