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.tsBlame258 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";
27import { getBlob, getDefaultBranch } from "../git/repository";
28import { parseCodeowners, syncCodeowners } from "../lib/codeowners";
29import { notify } from "../lib/notify";
fc1817aClaude30
31interface PushRef {
32 oldSha: string;
33 newSha: string;
34 refName: string;
35}
36
37export async function onPostReceive(
38 owner: string,
39 repo: string,
40 refs: PushRef[]
41): Promise<void> {
3ef4c9dClaude42 const [ownerRow] = await db
43 .select()
44 .from(users)
45 .where(eq(users.username, owner))
46 .limit(1);
47 const repoRow = ownerRow
48 ? (
49 await db
50 .select()
51 .from(repositories)
52 .where(
53 and(
54 eq(repositories.ownerId, ownerRow.id),
55 eq(repositories.name, repo)
56 )
57 )
58 .limit(1)
59 )[0]
60 : null;
61
62 const defaultBranch =
63 (await getDefaultBranch(owner, repo)) || repoRow?.defaultBranch || "main";
64
65 // --- 1. pushedAt + activity ---
66 if (repoRow) {
67 try {
68 await db
69 .update(repositories)
70 .set({ pushedAt: new Date(), updatedAt: new Date() })
71 .where(eq(repositories.id, repoRow.id));
72 for (const ref of refs) {
73 if (!ref.newSha.startsWith("0000")) {
74 await db.insert(activityFeed).values({
75 repositoryId: repoRow.id,
76 userId: ownerRow?.id || null,
77 action: "push",
78 targetType: "commit",
79 targetId: ref.newSha,
80 metadata: JSON.stringify({ ref: ref.refName }),
81 });
82 }
83 }
84 } catch (err) {
85 console.error("[post-receive] activity/pushedAt:", err);
86 }
87 }
88
89 // --- 2. CODEOWNERS sync (only when default branch changed) ---
90 const mainRef = refs.find(
91 (r) =>
92 r.refName === `refs/heads/${defaultBranch}` &&
93 !r.newSha.startsWith("0000")
94 );
95 if (mainRef && repoRow) {
96 try {
97 const paths = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
98 for (const p of paths) {
99 const blob = await getBlob(owner, repo, defaultBranch, p);
100 if (blob && !blob.isBinary) {
101 const rules = parseCodeowners(blob.content);
102 await syncCodeowners(repoRow.id, rules);
103 break;
104 }
105 }
106 } catch (err) {
107 console.error("[post-receive] codeowners sync:", err);
108 }
109 }
110
111 // --- 3. Gates ---
112 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
fc1817aClaude113
3ef4c9dClaude114 const promises: Promise<void>[] = [];
e883329Claude115 for (const ref of refs) {
3ef4c9dClaude116 if (ref.newSha.startsWith("0000")) continue;
117
118 if (settings?.gateTestEnabled !== false) {
e883329Claude119 promises.push(
120 runGateTestScan(owner, repo, ref.refName, ref.newSha)
121 .then((result) => {
122 console.log(
123 `[gatetest] ${owner}/${repo} ${ref.refName}: ${result.passed ? "PASSED" : "FAILED"} — ${result.details}`
124 );
125 })
126 .catch((err) => {
127 console.error(`[gatetest] scan error for ${owner}/${repo}:`, err);
128 })
129 );
130 }
3ef4c9dClaude131
132 if (
133 settings?.secretScanEnabled !== false ||
134 settings?.securityScanEnabled !== false
135 ) {
136 promises.push(
137 runSecretAndSecurityScan(owner, repo, ref.refName, ref.newSha, {
138 scanSecrets: settings?.secretScanEnabled !== false,
139 scanSecurity: false, // semantic scan needs a diff — deferred to PR gate
140 })
141 .then((result) => {
142 if (
143 !result.secretResult.passed &&
144 ownerRow &&
145 repoRow &&
146 result.secrets.length > 0
147 ) {
148 void notify(ownerRow.id, {
149 kind: "security_alert",
150 title: `Secret detected in ${owner}/${repo}`,
151 body: result.secretResult.details,
152 url: `/${owner}/${repo}/gates`,
153 repositoryId: repoRow.id,
154 });
155 }
156 })
157 .catch((err) => {
158 console.error(`[secret-scan] error for ${owner}/${repo}:`, err);
159 })
160 );
161 }
e883329Claude162 }
fc1817aClaude163
3ef4c9dClaude164 // --- 4. Auto-deploy (only on default branch + green settings) ---
165 if (mainRef && settings?.autoDeployEnabled !== false && repoRow) {
166 promises.push(triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id));
167 }
168
169 // --- 5. Webhook fan-out ---
170 if (repoRow) {
171 promises.push(fanoutWebhooks(repoRow.id, owner, repo, refs));
fc1817aClaude172 }
173
174 await Promise.allSettled(promises);
175}
176
177async function triggerCrontechDeploy(
178 owner: string,
179 repo: string,
3ef4c9dClaude180 sha: string,
181 repositoryId: string
fc1817aClaude182): Promise<void> {
3ef4c9dClaude183 let deployId = "";
184 try {
185 const [row] = await db
186 .insert(deployments)
187 .values({
188 repositoryId,
189 environment: "production",
190 commitSha: sha,
191 ref: "refs/heads/main",
192 status: "pending",
193 target: "crontech",
194 })
195 .returning();
196 deployId = row?.id || "";
197 } catch {
198 /* ignore */
199 }
200
fc1817aClaude201 try {
202 const response = await fetch(config.crontechDeployUrl, {
203 method: "POST",
204 headers: { "Content-Type": "application/json" },
205 body: JSON.stringify({
206 repository: `${owner}/${repo}`,
207 sha,
208 branch: "main",
209 source: "gluecron",
210 }),
211 });
212 console.log(
213 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
214 );
3ef4c9dClaude215 if (deployId) {
216 await db
217 .update(deployments)
218 .set({
219 status: response.ok ? "success" : "failed",
220 completedAt: new Date(),
221 })
222 .where(eq(deployments.id, deployId));
223 }
fc1817aClaude224 } catch (err) {
225 console.error(`[crontech] failed to trigger deploy:`, err);
3ef4c9dClaude226 if (deployId) {
227 await db
228 .update(deployments)
229 .set({
230 status: "failed",
231 blockedReason: (err as Error).message,
232 completedAt: new Date(),
233 })
234 .where(eq(deployments.id, deployId));
235 }
236 }
237}
238
239async function fanoutWebhooks(
240 repositoryId: string,
241 owner: string,
242 repo: string,
243 refs: PushRef[]
244): Promise<void> {
245 try {
246 const { fireWebhooks } = await import("../routes/webhooks");
247 await fireWebhooks(repositoryId, "push", {
248 repository: `${owner}/${repo}`,
249 refs: refs.map((r) => ({
250 ref: r.refName,
251 before: r.oldSha,
252 after: r.newSha,
253 })),
254 });
255 } catch {
256 // best-effort
fc1817aClaude257 }
258}