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.tsBlame423 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";
fc1817aClaude35
36interface PushRef {
37 oldSha: string;
38 newSha: string;
39 refName: string;
40}
41
42export async function onPostReceive(
43 owner: string,
44 repo: string,
45 refs: PushRef[]
46): Promise<void> {
3ef4c9dClaude47 const [ownerRow] = await db
48 .select()
49 .from(users)
50 .where(eq(users.username, owner))
51 .limit(1);
52 const repoRow = ownerRow
53 ? (
54 await db
55 .select()
56 .from(repositories)
57 .where(
58 and(
59 eq(repositories.ownerId, ownerRow.id),
60 eq(repositories.name, repo)
61 )
62 )
63 .limit(1)
64 )[0]
65 : null;
66
67 const defaultBranch =
68 (await getDefaultBranch(owner, repo)) || repoRow?.defaultBranch || "main";
69
70 // --- 1. pushedAt + activity ---
71 if (repoRow) {
72 try {
73 await db
74 .update(repositories)
75 .set({ pushedAt: new Date(), updatedAt: new Date() })
76 .where(eq(repositories.id, repoRow.id));
77 for (const ref of refs) {
78 if (!ref.newSha.startsWith("0000")) {
79 await db.insert(activityFeed).values({
80 repositoryId: repoRow.id,
81 userId: ownerRow?.id || null,
82 action: "push",
83 targetType: "commit",
84 targetId: ref.newSha,
85 metadata: JSON.stringify({ ref: ref.refName }),
86 });
87 }
88 }
89 } catch (err) {
90 console.error("[post-receive] activity/pushedAt:", err);
91 }
92 }
93
94 // --- 2. CODEOWNERS sync (only when default branch changed) ---
95 const mainRef = refs.find(
96 (r) =>
97 r.refName === `refs/heads/${defaultBranch}` &&
98 !r.newSha.startsWith("0000")
99 );
100 if (mainRef && repoRow) {
101 try {
102 const paths = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
103 for (const p of paths) {
104 const blob = await getBlob(owner, repo, defaultBranch, p);
105 if (blob && !blob.isBinary) {
106 const rules = parseCodeowners(blob.content);
107 await syncCodeowners(repoRow.id, rules);
108 break;
109 }
110 }
111 } catch (err) {
112 console.error("[post-receive] codeowners sync:", err);
113 }
114 }
115
eafe8c6Claude116 // --- 2b. Workflow sync + trigger (Block C1) ---
117 // On pushes to the default branch, discover `.gluecron/workflows/*.yml`,
118 // upsert them in the workflows table, and enqueue a run for each workflow
119 // whose `on` triggers include `push`.
120 if (mainRef && repoRow) {
121 try {
122 const entries = await getTree(
123 owner,
124 repo,
125 defaultBranch,
126 ".gluecron/workflows"
127 );
128 const existing = await db
129 .select({ id: workflows.id, path: workflows.path })
130 .from(workflows)
131 .where(eq(workflows.repositoryId, repoRow.id));
132 const existingByPath = new Map(existing.map((e) => [e.path, e.id]));
133 const seenPaths = new Set<string>();
134
135 for (const entry of entries) {
136 if (entry.type !== "blob") continue;
137 if (!/\.ya?ml$/i.test(entry.name)) continue;
138 const path = `.gluecron/workflows/${entry.name}`;
139 seenPaths.add(path);
140 const blob = await getBlob(owner, repo, defaultBranch, path);
141 if (!blob || blob.isBinary) continue;
142 const parsed = parseWorkflow(blob.content);
143 if (!parsed.ok) {
144 console.error(
145 `[workflow-sync] ${owner}/${repo}:${path} invalid — ${parsed.error}`
146 );
147 continue;
148 }
149 const onEvents = JSON.stringify(parsed.workflow.on);
150 const parsedJson = JSON.stringify(parsed.workflow);
151 const existingId = existingByPath.get(path);
152 let workflowId: string;
153 if (existingId) {
154 await db
155 .update(workflows)
156 .set({
157 name: parsed.workflow.name,
158 yaml: blob.content,
159 parsed: parsedJson,
160 onEvents,
161 updatedAt: new Date(),
162 })
163 .where(eq(workflows.id, existingId));
164 workflowId = existingId;
165 } else {
166 const [row] = await db
167 .insert(workflows)
168 .values({
169 repositoryId: repoRow.id,
170 name: parsed.workflow.name,
171 path,
172 yaml: blob.content,
173 parsed: parsedJson,
174 onEvents,
175 })
176 .returning({ id: workflows.id });
177 workflowId = row.id;
178 }
179
180 // Enqueue a run if this workflow subscribes to the push event.
181 if (parsed.workflow.on.includes("push")) {
182 try {
183 await enqueueRun({
184 workflowId,
185 repositoryId: repoRow.id,
186 event: "push",
187 ref: mainRef.refName,
188 commitSha: mainRef.newSha,
189 triggeredBy: ownerRow?.id || null,
190 });
191 } catch (err) {
192 console.error(
193 `[workflow-enqueue] ${owner}/${repo}:${path}:`,
194 err
195 );
196 }
197 }
198 }
199
200 // Mark workflows whose files have been removed as disabled (soft-delete).
201 for (const [p, id] of existingByPath) {
202 if (!seenPaths.has(p)) {
203 await db
204 .update(workflows)
205 .set({ disabled: true, updatedAt: new Date() })
206 .where(eq(workflows.id, id));
207 }
208 }
209 } catch (err) {
210 console.error("[post-receive] workflow sync:", err);
211 }
212 }
213
25a91a6Claude214 // --- 2c. Pages (Block C3) ---
215 // On any push, if the ref matches the configured pages source branch,
216 // record a pages_deployments row. Fire-and-forget; onPagesPush never throws.
217 if (repoRow) {
218 let pagesBranch = "gh-pages";
219 try {
220 const [pSettings] = await db
221 .select()
222 .from(pagesSettings)
223 .where(eq(pagesSettings.repositoryId, repoRow.id))
224 .limit(1);
225 if (pSettings) {
226 if (pSettings.enabled === false) pagesBranch = "";
227 else pagesBranch = pSettings.sourceBranch || "gh-pages";
228 }
229 } catch {
230 /* fall back to default */
231 }
232
233 if (pagesBranch) {
234 for (const ref of refs) {
235 if (ref.newSha.startsWith("0000")) continue;
236 if (ref.refName === `refs/heads/${pagesBranch}`) {
237 void onPagesPush({
238 ownerLogin: owner,
239 repoName: repo,
240 repositoryId: repoRow.id,
241 ref: ref.refName,
242 newSha: ref.newSha,
243 triggeredByUserId: ownerRow?.id || null,
244 });
245 }
246 }
247 }
248 }
249
3ef4c9dClaude250 // --- 3. Gates ---
251 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
fc1817aClaude252
3ef4c9dClaude253 const promises: Promise<void>[] = [];
e883329Claude254 for (const ref of refs) {
3ef4c9dClaude255 if (ref.newSha.startsWith("0000")) continue;
256
257 if (settings?.gateTestEnabled !== false) {
e883329Claude258 promises.push(
259 runGateTestScan(owner, repo, ref.refName, ref.newSha)
260 .then((result) => {
261 console.log(
262 `[gatetest] ${owner}/${repo} ${ref.refName}: ${result.passed ? "PASSED" : "FAILED"} — ${result.details}`
263 );
264 })
265 .catch((err) => {
266 console.error(`[gatetest] scan error for ${owner}/${repo}:`, err);
267 })
268 );
269 }
3ef4c9dClaude270
271 if (
272 settings?.secretScanEnabled !== false ||
273 settings?.securityScanEnabled !== false
274 ) {
275 promises.push(
276 runSecretAndSecurityScan(owner, repo, ref.refName, ref.newSha, {
277 scanSecrets: settings?.secretScanEnabled !== false,
278 scanSecurity: false, // semantic scan needs a diff — deferred to PR gate
279 })
280 .then((result) => {
281 if (
282 !result.secretResult.passed &&
283 ownerRow &&
284 repoRow &&
285 result.secrets.length > 0
286 ) {
287 void notify(ownerRow.id, {
288 kind: "security_alert",
289 title: `Secret detected in ${owner}/${repo}`,
290 body: result.secretResult.details,
291 url: `/${owner}/${repo}/gates`,
292 repositoryId: repoRow.id,
293 });
294 }
295 })
296 .catch((err) => {
297 console.error(`[secret-scan] error for ${owner}/${repo}:`, err);
298 })
299 );
300 }
e883329Claude301 }
fc1817aClaude302
3ef4c9dClaude303 // --- 4. Auto-deploy (only on default branch + green settings) ---
25a91a6Claude304 // Block C4: if a "production" environment is configured with approval
305 // required, insert a pending_approval deployment row instead of firing.
3ef4c9dClaude306 if (mainRef && settings?.autoDeployEnabled !== false && repoRow) {
25a91a6Claude307 const gate = await requiresApprovalFor(
308 repoRow.id,
309 "production",
310 mainRef.refName
311 ).catch(() => ({ required: false, env: null as null }));
312
313 if (gate.required && gate.env) {
314 try {
315 await db.insert(deployments).values({
316 repositoryId: repoRow.id,
317 environment: "production",
318 commitSha: mainRef.newSha,
319 ref: mainRef.refName,
320 status: "pending_approval",
321 target: "crontech",
322 blockedReason: `awaiting approval for environment '${gate.env.name}'`,
323 });
324 } catch (err) {
325 console.error("[post-receive] pending_approval insert:", err);
326 }
327 } else {
328 promises.push(
329 triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id)
330 );
331 }
3ef4c9dClaude332 }
333
334 // --- 5. Webhook fan-out ---
335 if (repoRow) {
336 promises.push(fanoutWebhooks(repoRow.id, owner, repo, refs));
fc1817aClaude337 }
338
339 await Promise.allSettled(promises);
340}
341
342async function triggerCrontechDeploy(
343 owner: string,
344 repo: string,
3ef4c9dClaude345 sha: string,
346 repositoryId: string
fc1817aClaude347): Promise<void> {
3ef4c9dClaude348 let deployId = "";
349 try {
350 const [row] = await db
351 .insert(deployments)
352 .values({
353 repositoryId,
354 environment: "production",
355 commitSha: sha,
356 ref: "refs/heads/main",
357 status: "pending",
358 target: "crontech",
359 })
360 .returning();
361 deployId = row?.id || "";
362 } catch {
363 /* ignore */
364 }
365
fc1817aClaude366 try {
367 const response = await fetch(config.crontechDeployUrl, {
368 method: "POST",
369 headers: { "Content-Type": "application/json" },
370 body: JSON.stringify({
371 repository: `${owner}/${repo}`,
372 sha,
373 branch: "main",
374 source: "gluecron",
375 }),
376 });
377 console.log(
378 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
379 );
3ef4c9dClaude380 if (deployId) {
381 await db
382 .update(deployments)
383 .set({
384 status: response.ok ? "success" : "failed",
385 completedAt: new Date(),
386 })
387 .where(eq(deployments.id, deployId));
388 }
fc1817aClaude389 } catch (err) {
390 console.error(`[crontech] failed to trigger deploy:`, err);
3ef4c9dClaude391 if (deployId) {
392 await db
393 .update(deployments)
394 .set({
395 status: "failed",
396 blockedReason: (err as Error).message,
397 completedAt: new Date(),
398 })
399 .where(eq(deployments.id, deployId));
400 }
401 }
402}
403
404async function fanoutWebhooks(
405 repositoryId: string,
406 owner: string,
407 repo: string,
408 refs: PushRef[]
409): Promise<void> {
410 try {
411 const { fireWebhooks } = await import("../routes/webhooks");
412 await fireWebhooks(repositoryId, "push", {
413 repository: `${owner}/${repo}`,
414 refs: refs.map((r) => ({
415 ref: r.refName,
416 before: r.oldSha,
417 after: r.newSha,
418 })),
419 });
420 } catch {
421 // best-effort
fc1817aClaude422 }
423}