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.tsBlame445 lines · 1 contributor
fc1817aClaude1/**
2 * Post-receive hook logic.
3ef4c9dClaude3 * Runs after a successful git push.
4 *
5 * 1. Update repo.pushedAt and push activity
6 * 2. Sync CODEOWNERS from the default branch
7 * 3. Run gates (GateTest + secret + security) on the new ref
8 * 4. Auto-deploy to Crontech ONLY if gates are green and settings allow it
9 * 5. Fan out webhooks
fc1817aClaude10 */
11
3ef4c9dClaude12import { and, eq } from "drizzle-orm";
fc1817aClaude13import { config } from "../lib/config";
3ef4c9dClaude14import { db } from "../db";
15import {
16 activityFeed,
17 deployments,
18 repoSettings,
19 repositories,
20 users,
21} from "../db/schema";
22import {
23 runGateTestScan,
24 runSecretAndSecurityScan,
25} from "../lib/gate";
26import { getOrCreateSettings } from "../lib/repo-bootstrap";
eafe8c6Claude27import { getBlob, getDefaultBranch, getTree } from "../git/repository";
3ef4c9dClaude28import { parseCodeowners, syncCodeowners } from "../lib/codeowners";
29import { notify } from "../lib/notify";
25a91a6Claude30import { workflows, pagesSettings } from "../db/schema";
eafe8c6Claude31import { parseWorkflow } from "../lib/workflow-parser";
32import { enqueueRun } from "../lib/workflow-runner";
25a91a6Claude33import { onPagesPush } from "../lib/pages";
34import { requiresApprovalFor } from "../lib/environments";
1e162a8Claude35import { onDeployFailure } from "../lib/ai-incident";
fc1817aClaude36
37interface PushRef {
38 oldSha: string;
39 newSha: string;
40 refName: string;
41}
42
43export async function onPostReceive(
44 owner: string,
45 repo: string,
46 refs: PushRef[]
47): Promise<void> {
3ef4c9dClaude48 const [ownerRow] = await db
49 .select()
50 .from(users)
51 .where(eq(users.username, owner))
52 .limit(1);
53 const repoRow = ownerRow
54 ? (
55 await db
56 .select()
57 .from(repositories)
58 .where(
59 and(
60 eq(repositories.ownerId, ownerRow.id),
61 eq(repositories.name, repo)
62 )
63 )
64 .limit(1)
65 )[0]
66 : null;
67
68 const defaultBranch =
69 (await getDefaultBranch(owner, repo)) || repoRow?.defaultBranch || "main";
70
71 // --- 1. pushedAt + activity ---
72 if (repoRow) {
73 try {
74 await db
75 .update(repositories)
76 .set({ pushedAt: new Date(), updatedAt: new Date() })
77 .where(eq(repositories.id, repoRow.id));
78 for (const ref of refs) {
79 if (!ref.newSha.startsWith("0000")) {
80 await db.insert(activityFeed).values({
81 repositoryId: repoRow.id,
82 userId: ownerRow?.id || null,
83 action: "push",
84 targetType: "commit",
85 targetId: ref.newSha,
86 metadata: JSON.stringify({ ref: ref.refName }),
87 });
88 }
89 }
90 } catch (err) {
91 console.error("[post-receive] activity/pushedAt:", err);
92 }
93 }
94
95 // --- 2. CODEOWNERS sync (only when default branch changed) ---
96 const mainRef = refs.find(
97 (r) =>
98 r.refName === `refs/heads/${defaultBranch}` &&
99 !r.newSha.startsWith("0000")
100 );
101 if (mainRef && repoRow) {
102 try {
103 const paths = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
104 for (const p of paths) {
105 const blob = await getBlob(owner, repo, defaultBranch, p);
106 if (blob && !blob.isBinary) {
107 const rules = parseCodeowners(blob.content);
108 await syncCodeowners(repoRow.id, rules);
109 break;
110 }
111 }
112 } catch (err) {
113 console.error("[post-receive] codeowners sync:", err);
114 }
115 }
116
eafe8c6Claude117 // --- 2b. Workflow sync + trigger (Block C1) ---
118 // On pushes to the default branch, discover `.gluecron/workflows/*.yml`,
119 // upsert them in the workflows table, and enqueue a run for each workflow
120 // whose `on` triggers include `push`.
121 if (mainRef && repoRow) {
122 try {
123 const entries = await getTree(
124 owner,
125 repo,
126 defaultBranch,
127 ".gluecron/workflows"
128 );
129 const existing = await db
130 .select({ id: workflows.id, path: workflows.path })
131 .from(workflows)
132 .where(eq(workflows.repositoryId, repoRow.id));
133 const existingByPath = new Map(existing.map((e) => [e.path, e.id]));
134 const seenPaths = new Set<string>();
135
136 for (const entry of entries) {
137 if (entry.type !== "blob") continue;
138 if (!/\.ya?ml$/i.test(entry.name)) continue;
139 const path = `.gluecron/workflows/${entry.name}`;
140 seenPaths.add(path);
141 const blob = await getBlob(owner, repo, defaultBranch, path);
142 if (!blob || blob.isBinary) continue;
143 const parsed = parseWorkflow(blob.content);
144 if (!parsed.ok) {
145 console.error(
146 `[workflow-sync] ${owner}/${repo}:${path} invalid — ${parsed.error}`
147 );
148 continue;
149 }
150 const onEvents = JSON.stringify(parsed.workflow.on);
151 const parsedJson = JSON.stringify(parsed.workflow);
152 const existingId = existingByPath.get(path);
153 let workflowId: string;
154 if (existingId) {
155 await db
156 .update(workflows)
157 .set({
158 name: parsed.workflow.name,
159 yaml: blob.content,
160 parsed: parsedJson,
161 onEvents,
162 updatedAt: new Date(),
163 })
164 .where(eq(workflows.id, existingId));
165 workflowId = existingId;
166 } else {
167 const [row] = await db
168 .insert(workflows)
169 .values({
170 repositoryId: repoRow.id,
171 name: parsed.workflow.name,
172 path,
173 yaml: blob.content,
174 parsed: parsedJson,
175 onEvents,
176 })
177 .returning({ id: workflows.id });
178 workflowId = row.id;
179 }
180
181 // Enqueue a run if this workflow subscribes to the push event.
182 if (parsed.workflow.on.includes("push")) {
183 try {
184 await enqueueRun({
185 workflowId,
186 repositoryId: repoRow.id,
187 event: "push",
188 ref: mainRef.refName,
189 commitSha: mainRef.newSha,
190 triggeredBy: ownerRow?.id || null,
191 });
192 } catch (err) {
193 console.error(
194 `[workflow-enqueue] ${owner}/${repo}:${path}:`,
195 err
196 );
197 }
198 }
199 }
200
201 // Mark workflows whose files have been removed as disabled (soft-delete).
202 for (const [p, id] of existingByPath) {
203 if (!seenPaths.has(p)) {
204 await db
205 .update(workflows)
206 .set({ disabled: true, updatedAt: new Date() })
207 .where(eq(workflows.id, id));
208 }
209 }
210 } catch (err) {
211 console.error("[post-receive] workflow sync:", err);
212 }
213 }
214
25a91a6Claude215 // --- 2c. Pages (Block C3) ---
216 // On any push, if the ref matches the configured pages source branch,
217 // record a pages_deployments row. Fire-and-forget; onPagesPush never throws.
218 if (repoRow) {
219 let pagesBranch = "gh-pages";
220 try {
221 const [pSettings] = await db
222 .select()
223 .from(pagesSettings)
224 .where(eq(pagesSettings.repositoryId, repoRow.id))
225 .limit(1);
226 if (pSettings) {
227 if (pSettings.enabled === false) pagesBranch = "";
228 else pagesBranch = pSettings.sourceBranch || "gh-pages";
229 }
230 } catch {
231 /* fall back to default */
232 }
233
234 if (pagesBranch) {
235 for (const ref of refs) {
236 if (ref.newSha.startsWith("0000")) continue;
237 if (ref.refName === `refs/heads/${pagesBranch}`) {
238 void onPagesPush({
239 ownerLogin: owner,
240 repoName: repo,
241 repositoryId: repoRow.id,
242 ref: ref.refName,
243 newSha: ref.newSha,
244 triggeredByUserId: ownerRow?.id || null,
245 });
246 }
247 }
248 }
249 }
250
3ef4c9dClaude251 // --- 3. Gates ---
252 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
fc1817aClaude253
3ef4c9dClaude254 const promises: Promise<void>[] = [];
e883329Claude255 for (const ref of refs) {
3ef4c9dClaude256 if (ref.newSha.startsWith("0000")) continue;
257
258 if (settings?.gateTestEnabled !== false) {
e883329Claude259 promises.push(
260 runGateTestScan(owner, repo, ref.refName, ref.newSha)
261 .then((result) => {
262 console.log(
263 `[gatetest] ${owner}/${repo} ${ref.refName}: ${result.passed ? "PASSED" : "FAILED"} — ${result.details}`
264 );
265 })
266 .catch((err) => {
267 console.error(`[gatetest] scan error for ${owner}/${repo}:`, err);
268 })
269 );
270 }
3ef4c9dClaude271
272 if (
273 settings?.secretScanEnabled !== false ||
274 settings?.securityScanEnabled !== false
275 ) {
276 promises.push(
277 runSecretAndSecurityScan(owner, repo, ref.refName, ref.newSha, {
278 scanSecrets: settings?.secretScanEnabled !== false,
279 scanSecurity: false, // semantic scan needs a diff — deferred to PR gate
280 })
281 .then((result) => {
282 if (
283 !result.secretResult.passed &&
284 ownerRow &&
285 repoRow &&
286 result.secrets.length > 0
287 ) {
288 void notify(ownerRow.id, {
289 kind: "security_alert",
290 title: `Secret detected in ${owner}/${repo}`,
291 body: result.secretResult.details,
292 url: `/${owner}/${repo}/gates`,
293 repositoryId: repoRow.id,
294 });
295 }
296 })
297 .catch((err) => {
298 console.error(`[secret-scan] error for ${owner}/${repo}:`, err);
299 })
300 );
301 }
e883329Claude302 }
fc1817aClaude303
3ef4c9dClaude304 // --- 4. Auto-deploy (only on default branch + green settings) ---
25a91a6Claude305 // Block C4: if a "production" environment is configured with approval
306 // required, insert a pending_approval deployment row instead of firing.
3ef4c9dClaude307 if (mainRef && settings?.autoDeployEnabled !== false && repoRow) {
25a91a6Claude308 const gate = await requiresApprovalFor(
309 repoRow.id,
310 "production",
311 mainRef.refName
312 ).catch(() => ({ required: false, env: null as null }));
313
314 if (gate.required && gate.env) {
315 try {
316 await db.insert(deployments).values({
317 repositoryId: repoRow.id,
318 environment: "production",
319 commitSha: mainRef.newSha,
320 ref: mainRef.refName,
321 status: "pending_approval",
322 target: "crontech",
323 blockedReason: `awaiting approval for environment '${gate.env.name}'`,
324 });
325 } catch (err) {
326 console.error("[post-receive] pending_approval insert:", err);
327 }
328 } else {
329 promises.push(
330 triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id)
331 );
332 }
3ef4c9dClaude333 }
334
335 // --- 5. Webhook fan-out ---
336 if (repoRow) {
337 promises.push(fanoutWebhooks(repoRow.id, owner, repo, refs));
fc1817aClaude338 }
339
340 await Promise.allSettled(promises);
341}
342
343async function triggerCrontechDeploy(
344 owner: string,
345 repo: string,
3ef4c9dClaude346 sha: string,
347 repositoryId: string
fc1817aClaude348): Promise<void> {
3ef4c9dClaude349 let deployId = "";
350 try {
351 const [row] = await db
352 .insert(deployments)
353 .values({
354 repositoryId,
355 environment: "production",
356 commitSha: sha,
357 ref: "refs/heads/main",
358 status: "pending",
359 target: "crontech",
360 })
361 .returning();
362 deployId = row?.id || "";
363 } catch {
364 /* ignore */
365 }
366
fc1817aClaude367 try {
368 const response = await fetch(config.crontechDeployUrl, {
369 method: "POST",
370 headers: { "Content-Type": "application/json" },
371 body: JSON.stringify({
372 repository: `${owner}/${repo}`,
373 sha,
374 branch: "main",
375 source: "gluecron",
376 }),
377 });
378 console.log(
379 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
380 );
3ef4c9dClaude381 if (deployId) {
382 await db
383 .update(deployments)
384 .set({
385 status: response.ok ? "success" : "failed",
386 completedAt: new Date(),
387 })
388 .where(eq(deployments.id, deployId));
389 }
1e162a8Claude390 // D4: when Crontech returns a non-ok HTTP status, kick off the AI
391 // incident responder AFTER the deployment row is flipped to "failed".
392 if (!response.ok && deployId) {
393 void onDeployFailure({
394 repositoryId,
395 deploymentId: deployId,
396 ref: "refs/heads/main",
397 commitSha: sha,
398 target: "crontech",
399 errorMessage: `HTTP ${response.status}`,
400 }).catch((e) => console.error("[ai-incident]", e));
401 }
fc1817aClaude402 } catch (err) {
403 console.error(`[crontech] failed to trigger deploy:`, err);
3ef4c9dClaude404 if (deployId) {
405 await db
406 .update(deployments)
407 .set({
408 status: "failed",
409 blockedReason: (err as Error).message,
410 completedAt: new Date(),
411 })
412 .where(eq(deployments.id, deployId));
1e162a8Claude413 // D4: fire-and-forget incident analysis AFTER marking the row failed.
414 void onDeployFailure({
415 repositoryId,
416 deploymentId: deployId,
417 ref: "refs/heads/main",
418 commitSha: sha,
419 target: "crontech",
420 errorMessage: (err as Error).message,
421 }).catch((e) => console.error("[ai-incident]", e));
3ef4c9dClaude422 }
423 }
424}
425
426async function fanoutWebhooks(
427 repositoryId: string,
428 owner: string,
429 repo: string,
430 refs: PushRef[]
431): Promise<void> {
432 try {
433 const { fireWebhooks } = await import("../routes/webhooks");
434 await fireWebhooks(repositoryId, "push", {
435 repository: `${owner}/${repo}`,
436 refs: refs.map((r) => ({
437 ref: r.refName,
438 before: r.oldSha,
439 after: r.newSha,
440 })),
441 });
442 } catch {
443 // best-effort
fc1817aClaude444 }
445}