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.tsBlame478 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";
a79a9edClaude29import { notify, audit } 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";
a79a9edClaude36import { matchProtectedTag } from "../lib/protected-tags";
fc1817aClaude37
38interface PushRef {
39 oldSha: string;
40 newSha: string;
41 refName: string;
42}
43
44export async function onPostReceive(
45 owner: string,
46 repo: string,
47 refs: PushRef[]
48): Promise<void> {
3ef4c9dClaude49 const [ownerRow] = await db
50 .select()
51 .from(users)
52 .where(eq(users.username, owner))
53 .limit(1);
54 const repoRow = ownerRow
55 ? (
56 await db
57 .select()
58 .from(repositories)
59 .where(
60 and(
61 eq(repositories.ownerId, ownerRow.id),
62 eq(repositories.name, repo)
63 )
64 )
65 .limit(1)
66 )[0]
67 : null;
68
69 const defaultBranch =
70 (await getDefaultBranch(owner, repo)) || repoRow?.defaultBranch || "main";
71
72 // --- 1. pushedAt + activity ---
73 if (repoRow) {
74 try {
75 await db
76 .update(repositories)
77 .set({ pushedAt: new Date(), updatedAt: new Date() })
78 .where(eq(repositories.id, repoRow.id));
79 for (const ref of refs) {
80 if (!ref.newSha.startsWith("0000")) {
81 await db.insert(activityFeed).values({
82 repositoryId: repoRow.id,
83 userId: ownerRow?.id || null,
84 action: "push",
85 targetType: "commit",
86 targetId: ref.newSha,
87 metadata: JSON.stringify({ ref: ref.refName }),
88 });
89 }
90 }
91 } catch (err) {
92 console.error("[post-receive] activity/pushedAt:", err);
93 }
94 }
95
a79a9edClaude96 // --- 1b. Protected-tag advisory logging (Block E7) ---
97 // v1 is non-blocking: we log violations to the audit log and fan a notify
98 // event out to the repo owner. Actual pre-receive blocking is future work.
99 if (repoRow) {
100 for (const ref of refs) {
101 if (!ref.refName.startsWith("refs/tags/")) continue;
102 const rule = await matchProtectedTag(repoRow.id, ref.refName);
103 if (!rule) continue;
104 const isDelete = ref.newSha.startsWith("0000");
105 const isCreate = ref.oldSha.startsWith("0000");
106 const action = isDelete
107 ? "delete"
108 : isCreate
109 ? "create"
110 : "update";
111 try {
112 await audit({
113 userId: ownerRow?.id || null,
114 repositoryId: repoRow.id,
115 action: `protected_tags.${action}_violation_candidate`,
116 targetType: "ref",
117 targetId: ref.refName,
118 metadata: {
119 pattern: rule.pattern,
120 oldSha: ref.oldSha,
121 newSha: ref.newSha,
122 },
123 });
124 } catch {}
125 }
126 }
127
3ef4c9dClaude128 // --- 2. CODEOWNERS sync (only when default branch changed) ---
129 const mainRef = refs.find(
130 (r) =>
131 r.refName === `refs/heads/${defaultBranch}` &&
132 !r.newSha.startsWith("0000")
133 );
134 if (mainRef && repoRow) {
135 try {
136 const paths = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
137 for (const p of paths) {
138 const blob = await getBlob(owner, repo, defaultBranch, p);
139 if (blob && !blob.isBinary) {
140 const rules = parseCodeowners(blob.content);
141 await syncCodeowners(repoRow.id, rules);
142 break;
143 }
144 }
145 } catch (err) {
146 console.error("[post-receive] codeowners sync:", err);
147 }
148 }
149
eafe8c6Claude150 // --- 2b. Workflow sync + trigger (Block C1) ---
151 // On pushes to the default branch, discover `.gluecron/workflows/*.yml`,
152 // upsert them in the workflows table, and enqueue a run for each workflow
153 // whose `on` triggers include `push`.
154 if (mainRef && repoRow) {
155 try {
156 const entries = await getTree(
157 owner,
158 repo,
159 defaultBranch,
160 ".gluecron/workflows"
161 );
162 const existing = await db
163 .select({ id: workflows.id, path: workflows.path })
164 .from(workflows)
165 .where(eq(workflows.repositoryId, repoRow.id));
166 const existingByPath = new Map(existing.map((e) => [e.path, e.id]));
167 const seenPaths = new Set<string>();
168
169 for (const entry of entries) {
170 if (entry.type !== "blob") continue;
171 if (!/\.ya?ml$/i.test(entry.name)) continue;
172 const path = `.gluecron/workflows/${entry.name}`;
173 seenPaths.add(path);
174 const blob = await getBlob(owner, repo, defaultBranch, path);
175 if (!blob || blob.isBinary) continue;
176 const parsed = parseWorkflow(blob.content);
177 if (!parsed.ok) {
178 console.error(
179 `[workflow-sync] ${owner}/${repo}:${path} invalid — ${parsed.error}`
180 );
181 continue;
182 }
183 const onEvents = JSON.stringify(parsed.workflow.on);
184 const parsedJson = JSON.stringify(parsed.workflow);
185 const existingId = existingByPath.get(path);
186 let workflowId: string;
187 if (existingId) {
188 await db
189 .update(workflows)
190 .set({
191 name: parsed.workflow.name,
192 yaml: blob.content,
193 parsed: parsedJson,
194 onEvents,
195 updatedAt: new Date(),
196 })
197 .where(eq(workflows.id, existingId));
198 workflowId = existingId;
199 } else {
200 const [row] = await db
201 .insert(workflows)
202 .values({
203 repositoryId: repoRow.id,
204 name: parsed.workflow.name,
205 path,
206 yaml: blob.content,
207 parsed: parsedJson,
208 onEvents,
209 })
210 .returning({ id: workflows.id });
211 workflowId = row.id;
212 }
213
214 // Enqueue a run if this workflow subscribes to the push event.
215 if (parsed.workflow.on.includes("push")) {
216 try {
217 await enqueueRun({
218 workflowId,
219 repositoryId: repoRow.id,
220 event: "push",
221 ref: mainRef.refName,
222 commitSha: mainRef.newSha,
223 triggeredBy: ownerRow?.id || null,
224 });
225 } catch (err) {
226 console.error(
227 `[workflow-enqueue] ${owner}/${repo}:${path}:`,
228 err
229 );
230 }
231 }
232 }
233
234 // Mark workflows whose files have been removed as disabled (soft-delete).
235 for (const [p, id] of existingByPath) {
236 if (!seenPaths.has(p)) {
237 await db
238 .update(workflows)
239 .set({ disabled: true, updatedAt: new Date() })
240 .where(eq(workflows.id, id));
241 }
242 }
243 } catch (err) {
244 console.error("[post-receive] workflow sync:", err);
245 }
246 }
247
25a91a6Claude248 // --- 2c. Pages (Block C3) ---
249 // On any push, if the ref matches the configured pages source branch,
250 // record a pages_deployments row. Fire-and-forget; onPagesPush never throws.
251 if (repoRow) {
252 let pagesBranch = "gh-pages";
253 try {
254 const [pSettings] = await db
255 .select()
256 .from(pagesSettings)
257 .where(eq(pagesSettings.repositoryId, repoRow.id))
258 .limit(1);
259 if (pSettings) {
260 if (pSettings.enabled === false) pagesBranch = "";
261 else pagesBranch = pSettings.sourceBranch || "gh-pages";
262 }
263 } catch {
264 /* fall back to default */
265 }
266
267 if (pagesBranch) {
268 for (const ref of refs) {
269 if (ref.newSha.startsWith("0000")) continue;
270 if (ref.refName === `refs/heads/${pagesBranch}`) {
271 void onPagesPush({
272 ownerLogin: owner,
273 repoName: repo,
274 repositoryId: repoRow.id,
275 ref: ref.refName,
276 newSha: ref.newSha,
277 triggeredByUserId: ownerRow?.id || null,
278 });
279 }
280 }
281 }
282 }
283
3ef4c9dClaude284 // --- 3. Gates ---
285 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
fc1817aClaude286
3ef4c9dClaude287 const promises: Promise<void>[] = [];
e883329Claude288 for (const ref of refs) {
3ef4c9dClaude289 if (ref.newSha.startsWith("0000")) continue;
290
291 if (settings?.gateTestEnabled !== false) {
e883329Claude292 promises.push(
293 runGateTestScan(owner, repo, ref.refName, ref.newSha)
294 .then((result) => {
295 console.log(
296 `[gatetest] ${owner}/${repo} ${ref.refName}: ${result.passed ? "PASSED" : "FAILED"} — ${result.details}`
297 );
298 })
299 .catch((err) => {
300 console.error(`[gatetest] scan error for ${owner}/${repo}:`, err);
301 })
302 );
303 }
3ef4c9dClaude304
305 if (
306 settings?.secretScanEnabled !== false ||
307 settings?.securityScanEnabled !== false
308 ) {
309 promises.push(
310 runSecretAndSecurityScan(owner, repo, ref.refName, ref.newSha, {
311 scanSecrets: settings?.secretScanEnabled !== false,
312 scanSecurity: false, // semantic scan needs a diff — deferred to PR gate
313 })
314 .then((result) => {
315 if (
316 !result.secretResult.passed &&
317 ownerRow &&
318 repoRow &&
319 result.secrets.length > 0
320 ) {
321 void notify(ownerRow.id, {
322 kind: "security_alert",
323 title: `Secret detected in ${owner}/${repo}`,
324 body: result.secretResult.details,
325 url: `/${owner}/${repo}/gates`,
326 repositoryId: repoRow.id,
327 });
328 }
329 })
330 .catch((err) => {
331 console.error(`[secret-scan] error for ${owner}/${repo}:`, err);
332 })
333 );
334 }
e883329Claude335 }
fc1817aClaude336
3ef4c9dClaude337 // --- 4. Auto-deploy (only on default branch + green settings) ---
25a91a6Claude338 // Block C4: if a "production" environment is configured with approval
339 // required, insert a pending_approval deployment row instead of firing.
3ef4c9dClaude340 if (mainRef && settings?.autoDeployEnabled !== false && repoRow) {
25a91a6Claude341 const gate = await requiresApprovalFor(
342 repoRow.id,
343 "production",
344 mainRef.refName
345 ).catch(() => ({ required: false, env: null as null }));
346
347 if (gate.required && gate.env) {
348 try {
349 await db.insert(deployments).values({
350 repositoryId: repoRow.id,
351 environment: "production",
352 commitSha: mainRef.newSha,
353 ref: mainRef.refName,
354 status: "pending_approval",
355 target: "crontech",
356 blockedReason: `awaiting approval for environment '${gate.env.name}'`,
357 });
358 } catch (err) {
359 console.error("[post-receive] pending_approval insert:", err);
360 }
361 } else {
362 promises.push(
363 triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id)
364 );
365 }
3ef4c9dClaude366 }
367
368 // --- 5. Webhook fan-out ---
369 if (repoRow) {
370 promises.push(fanoutWebhooks(repoRow.id, owner, repo, refs));
fc1817aClaude371 }
372
373 await Promise.allSettled(promises);
374}
375
376async function triggerCrontechDeploy(
377 owner: string,
378 repo: string,
3ef4c9dClaude379 sha: string,
380 repositoryId: string
fc1817aClaude381): Promise<void> {
3ef4c9dClaude382 let deployId = "";
383 try {
384 const [row] = await db
385 .insert(deployments)
386 .values({
387 repositoryId,
388 environment: "production",
389 commitSha: sha,
390 ref: "refs/heads/main",
391 status: "pending",
392 target: "crontech",
393 })
394 .returning();
395 deployId = row?.id || "";
396 } catch {
397 /* ignore */
398 }
399
fc1817aClaude400 try {
401 const response = await fetch(config.crontechDeployUrl, {
402 method: "POST",
403 headers: { "Content-Type": "application/json" },
404 body: JSON.stringify({
405 repository: `${owner}/${repo}`,
406 sha,
407 branch: "main",
408 source: "gluecron",
409 }),
410 });
411 console.log(
412 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
413 );
3ef4c9dClaude414 if (deployId) {
415 await db
416 .update(deployments)
417 .set({
418 status: response.ok ? "success" : "failed",
419 completedAt: new Date(),
420 })
421 .where(eq(deployments.id, deployId));
422 }
1e162a8Claude423 // D4: when Crontech returns a non-ok HTTP status, kick off the AI
424 // incident responder AFTER the deployment row is flipped to "failed".
425 if (!response.ok && deployId) {
426 void onDeployFailure({
427 repositoryId,
428 deploymentId: deployId,
429 ref: "refs/heads/main",
430 commitSha: sha,
431 target: "crontech",
432 errorMessage: `HTTP ${response.status}`,
433 }).catch((e) => console.error("[ai-incident]", e));
434 }
fc1817aClaude435 } catch (err) {
436 console.error(`[crontech] failed to trigger deploy:`, err);
3ef4c9dClaude437 if (deployId) {
438 await db
439 .update(deployments)
440 .set({
441 status: "failed",
442 blockedReason: (err as Error).message,
443 completedAt: new Date(),
444 })
445 .where(eq(deployments.id, deployId));
1e162a8Claude446 // D4: fire-and-forget incident analysis AFTER marking the row failed.
447 void onDeployFailure({
448 repositoryId,
449 deploymentId: deployId,
450 ref: "refs/heads/main",
451 commitSha: sha,
452 target: "crontech",
453 errorMessage: (err as Error).message,
454 }).catch((e) => console.error("[ai-incident]", e));
3ef4c9dClaude455 }
456 }
457}
458
459async function fanoutWebhooks(
460 repositoryId: string,
461 owner: string,
462 repo: string,
463 refs: PushRef[]
464): Promise<void> {
465 try {
466 const { fireWebhooks } = await import("../routes/webhooks");
467 await fireWebhooks(repositoryId, "push", {
468 repository: `${owner}/${repo}`,
469 refs: refs.map((r) => ({
470 ref: r.refName,
471 before: r.oldSha,
472 after: r.newSha,
473 })),
474 });
475 } catch {
476 // best-effort
fc1817aClaude477 }
478}