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.tsBlame359 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";
eafe8c6Claude30import { workflows } from "../db/schema";
31import { parseWorkflow } from "../lib/workflow-parser";
32import { enqueueRun } from "../lib/workflow-runner";
fc1817aClaude33
34interface PushRef {
35 oldSha: string;
36 newSha: string;
37 refName: string;
38}
39
40export async function onPostReceive(
41 owner: string,
42 repo: string,
43 refs: PushRef[]
44): Promise<void> {
3ef4c9dClaude45 const [ownerRow] = await db
46 .select()
47 .from(users)
48 .where(eq(users.username, owner))
49 .limit(1);
50 const repoRow = ownerRow
51 ? (
52 await db
53 .select()
54 .from(repositories)
55 .where(
56 and(
57 eq(repositories.ownerId, ownerRow.id),
58 eq(repositories.name, repo)
59 )
60 )
61 .limit(1)
62 )[0]
63 : null;
64
65 const defaultBranch =
66 (await getDefaultBranch(owner, repo)) || repoRow?.defaultBranch || "main";
67
68 // --- 1. pushedAt + activity ---
69 if (repoRow) {
70 try {
71 await db
72 .update(repositories)
73 .set({ pushedAt: new Date(), updatedAt: new Date() })
74 .where(eq(repositories.id, repoRow.id));
75 for (const ref of refs) {
76 if (!ref.newSha.startsWith("0000")) {
77 await db.insert(activityFeed).values({
78 repositoryId: repoRow.id,
79 userId: ownerRow?.id || null,
80 action: "push",
81 targetType: "commit",
82 targetId: ref.newSha,
83 metadata: JSON.stringify({ ref: ref.refName }),
84 });
85 }
86 }
87 } catch (err) {
88 console.error("[post-receive] activity/pushedAt:", err);
89 }
90 }
91
92 // --- 2. CODEOWNERS sync (only when default branch changed) ---
93 const mainRef = refs.find(
94 (r) =>
95 r.refName === `refs/heads/${defaultBranch}` &&
96 !r.newSha.startsWith("0000")
97 );
98 if (mainRef && repoRow) {
99 try {
100 const paths = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
101 for (const p of paths) {
102 const blob = await getBlob(owner, repo, defaultBranch, p);
103 if (blob && !blob.isBinary) {
104 const rules = parseCodeowners(blob.content);
105 await syncCodeowners(repoRow.id, rules);
106 break;
107 }
108 }
109 } catch (err) {
110 console.error("[post-receive] codeowners sync:", err);
111 }
112 }
113
eafe8c6Claude114 // --- 2b. Workflow sync + trigger (Block C1) ---
115 // On pushes to the default branch, discover `.gluecron/workflows/*.yml`,
116 // upsert them in the workflows table, and enqueue a run for each workflow
117 // whose `on` triggers include `push`.
118 if (mainRef && repoRow) {
119 try {
120 const entries = await getTree(
121 owner,
122 repo,
123 defaultBranch,
124 ".gluecron/workflows"
125 );
126 const existing = await db
127 .select({ id: workflows.id, path: workflows.path })
128 .from(workflows)
129 .where(eq(workflows.repositoryId, repoRow.id));
130 const existingByPath = new Map(existing.map((e) => [e.path, e.id]));
131 const seenPaths = new Set<string>();
132
133 for (const entry of entries) {
134 if (entry.type !== "blob") continue;
135 if (!/\.ya?ml$/i.test(entry.name)) continue;
136 const path = `.gluecron/workflows/${entry.name}`;
137 seenPaths.add(path);
138 const blob = await getBlob(owner, repo, defaultBranch, path);
139 if (!blob || blob.isBinary) continue;
140 const parsed = parseWorkflow(blob.content);
141 if (!parsed.ok) {
142 console.error(
143 `[workflow-sync] ${owner}/${repo}:${path} invalid — ${parsed.error}`
144 );
145 continue;
146 }
147 const onEvents = JSON.stringify(parsed.workflow.on);
148 const parsedJson = JSON.stringify(parsed.workflow);
149 const existingId = existingByPath.get(path);
150 let workflowId: string;
151 if (existingId) {
152 await db
153 .update(workflows)
154 .set({
155 name: parsed.workflow.name,
156 yaml: blob.content,
157 parsed: parsedJson,
158 onEvents,
159 updatedAt: new Date(),
160 })
161 .where(eq(workflows.id, existingId));
162 workflowId = existingId;
163 } else {
164 const [row] = await db
165 .insert(workflows)
166 .values({
167 repositoryId: repoRow.id,
168 name: parsed.workflow.name,
169 path,
170 yaml: blob.content,
171 parsed: parsedJson,
172 onEvents,
173 })
174 .returning({ id: workflows.id });
175 workflowId = row.id;
176 }
177
178 // Enqueue a run if this workflow subscribes to the push event.
179 if (parsed.workflow.on.includes("push")) {
180 try {
181 await enqueueRun({
182 workflowId,
183 repositoryId: repoRow.id,
184 event: "push",
185 ref: mainRef.refName,
186 commitSha: mainRef.newSha,
187 triggeredBy: ownerRow?.id || null,
188 });
189 } catch (err) {
190 console.error(
191 `[workflow-enqueue] ${owner}/${repo}:${path}:`,
192 err
193 );
194 }
195 }
196 }
197
198 // Mark workflows whose files have been removed as disabled (soft-delete).
199 for (const [p, id] of existingByPath) {
200 if (!seenPaths.has(p)) {
201 await db
202 .update(workflows)
203 .set({ disabled: true, updatedAt: new Date() })
204 .where(eq(workflows.id, id));
205 }
206 }
207 } catch (err) {
208 console.error("[post-receive] workflow sync:", err);
209 }
210 }
211
3ef4c9dClaude212 // --- 3. Gates ---
213 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
fc1817aClaude214
3ef4c9dClaude215 const promises: Promise<void>[] = [];
e883329Claude216 for (const ref of refs) {
3ef4c9dClaude217 if (ref.newSha.startsWith("0000")) continue;
218
219 if (settings?.gateTestEnabled !== false) {
e883329Claude220 promises.push(
221 runGateTestScan(owner, repo, ref.refName, ref.newSha)
222 .then((result) => {
223 console.log(
224 `[gatetest] ${owner}/${repo} ${ref.refName}: ${result.passed ? "PASSED" : "FAILED"} — ${result.details}`
225 );
226 })
227 .catch((err) => {
228 console.error(`[gatetest] scan error for ${owner}/${repo}:`, err);
229 })
230 );
231 }
3ef4c9dClaude232
233 if (
234 settings?.secretScanEnabled !== false ||
235 settings?.securityScanEnabled !== false
236 ) {
237 promises.push(
238 runSecretAndSecurityScan(owner, repo, ref.refName, ref.newSha, {
239 scanSecrets: settings?.secretScanEnabled !== false,
240 scanSecurity: false, // semantic scan needs a diff — deferred to PR gate
241 })
242 .then((result) => {
243 if (
244 !result.secretResult.passed &&
245 ownerRow &&
246 repoRow &&
247 result.secrets.length > 0
248 ) {
249 void notify(ownerRow.id, {
250 kind: "security_alert",
251 title: `Secret detected in ${owner}/${repo}`,
252 body: result.secretResult.details,
253 url: `/${owner}/${repo}/gates`,
254 repositoryId: repoRow.id,
255 });
256 }
257 })
258 .catch((err) => {
259 console.error(`[secret-scan] error for ${owner}/${repo}:`, err);
260 })
261 );
262 }
e883329Claude263 }
fc1817aClaude264
3ef4c9dClaude265 // --- 4. Auto-deploy (only on default branch + green settings) ---
266 if (mainRef && settings?.autoDeployEnabled !== false && repoRow) {
267 promises.push(triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id));
268 }
269
270 // --- 5. Webhook fan-out ---
271 if (repoRow) {
272 promises.push(fanoutWebhooks(repoRow.id, owner, repo, refs));
fc1817aClaude273 }
274
275 await Promise.allSettled(promises);
276}
277
278async function triggerCrontechDeploy(
279 owner: string,
280 repo: string,
3ef4c9dClaude281 sha: string,
282 repositoryId: string
fc1817aClaude283): Promise<void> {
3ef4c9dClaude284 let deployId = "";
285 try {
286 const [row] = await db
287 .insert(deployments)
288 .values({
289 repositoryId,
290 environment: "production",
291 commitSha: sha,
292 ref: "refs/heads/main",
293 status: "pending",
294 target: "crontech",
295 })
296 .returning();
297 deployId = row?.id || "";
298 } catch {
299 /* ignore */
300 }
301
fc1817aClaude302 try {
303 const response = await fetch(config.crontechDeployUrl, {
304 method: "POST",
305 headers: { "Content-Type": "application/json" },
306 body: JSON.stringify({
307 repository: `${owner}/${repo}`,
308 sha,
309 branch: "main",
310 source: "gluecron",
311 }),
312 });
313 console.log(
314 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
315 );
3ef4c9dClaude316 if (deployId) {
317 await db
318 .update(deployments)
319 .set({
320 status: response.ok ? "success" : "failed",
321 completedAt: new Date(),
322 })
323 .where(eq(deployments.id, deployId));
324 }
fc1817aClaude325 } catch (err) {
326 console.error(`[crontech] failed to trigger deploy:`, err);
3ef4c9dClaude327 if (deployId) {
328 await db
329 .update(deployments)
330 .set({
331 status: "failed",
332 blockedReason: (err as Error).message,
333 completedAt: new Date(),
334 })
335 .where(eq(deployments.id, deployId));
336 }
337 }
338}
339
340async function fanoutWebhooks(
341 repositoryId: string,
342 owner: string,
343 repo: string,
344 refs: PushRef[]
345): Promise<void> {
346 try {
347 const { fireWebhooks } = await import("../routes/webhooks");
348 await fireWebhooks(repositoryId, "push", {
349 repository: `${owner}/${repo}`,
350 refs: refs.map((r) => ({
351 ref: r.refName,
352 before: r.oldSha,
353 after: r.newSha,
354 })),
355 });
356 } catch {
357 // best-effort
fc1817aClaude358 }
359}