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

preview-builder.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.

preview-builder.tsBlame325 lines · 2 contributors
1df50d5Claude1/**
2 * PR preview builder (migration 0077).
3 *
4 * When a PR is opened or updated and the repo has `preview_build_command`
5 * configured, this module:
6 * 1. Clones the PR's head branch into /tmp/previews/<prId>-<shortSha>
7 * 2. Runs the configured build command with a 2-minute timeout
8 * 3. Captures stdout + stderr as the build log
9 * 4. On success: marks the pr_previews row as 'ready' and posts a PR comment
10 * 5. On failure: marks it 'failed' and stores the log
11 *
12 * Feature flag: preview builds only run when PREVIEW_DOMAIN env var is set
13 * AND the repo has preview_build_command configured.
14 *
15 * Philosophy: never throw — every DB + subprocess call is wrapped in
16 * try/catch so a failure cannot disrupt the PR creation path. Callers use
17 * `buildPreview(...).catch(() => {})` fire-and-forget style.
18 */
19
20import { eq, and } from "drizzle-orm";
21import { db } from "../db";
22import {
23 repositories,
24 pullRequests,
25 prPreviews,
26 prComments,
27 users,
28} from "../db/schema";
29import { config } from "./config";
30
31const PREVIEW_BUILD_DIR = process.env.PREVIEW_BUILD_DIR || "/tmp/previews";
32const BUILD_TIMEOUT_MS = 2 * 60 * 1_000; // 2 minutes
33
34// ─── helpers ────────────────────────────────────────────────────────────────
35
36/** Slugify a string for use in a URL path segment. */
37function slug(s: string): string {
38 return (s || "")
39 .toLowerCase()
40 .replace(/[^a-z0-9]+/g, "-")
41 .replace(/^-+|-+$/g, "")
42 .slice(0, 60);
43}
44
45/** Compute the public URL for a built preview. */
46export function previewBuilderUrl(
47 ownerName: string,
48 repoName: string,
49 branchName: string
50): string {
51 const domain = config.previewDomain || config.appBaseUrl;
52 return `${domain}/previews/${ownerName}/${repoName}/${slug(branchName)}/`;
53}
54
55/** The on-disk directory where built output lives. */
56export function previewBuildPath(
57 prId: string,
58 headSha: string,
59 outputDir: string
60): string {
61 const shortSha = headSha.slice(0, 8);
62 return `${PREVIEW_BUILD_DIR}/${slug(prId)}-${shortSha}/${outputDir}`;
63}
64
65// ─── core builder ───────────────────────────────────────────────────────────
66
67/**
68 * Build a preview for the given PR. Fire-and-forget by callers:
69 * `buildPreview(prId, repoId, headSha).catch(() => {})`
70 */
71export async function buildPreview(
72 prId: string,
73 repoId: string,
74 headSha: string
75): Promise<void> {
76 // Feature flag: only run when PREVIEW_DOMAIN is set
77 if (!config.previewDomain) return;
78
79 // ── look up the repo for build config ──
80 let repo: {
81 id: string;
82 name: string;
83 ownerId: string;
84 previewBuildCommand: string | null;
85 previewOutputDir: string | null;
86 diskPath: string;
87 } | null = null;
88
89 let ownerUsername = "";
90
91 try {
92 const [row] = await db
93 .select({
94 id: repositories.id,
95 name: repositories.name,
96 ownerId: repositories.ownerId,
97 previewBuildCommand: repositories.previewBuildCommand,
98 previewOutputDir: repositories.previewOutputDir,
99 diskPath: repositories.diskPath,
100 })
101 .from(repositories)
102 .where(eq(repositories.id, repoId))
103 .limit(1);
104 if (!row) return;
105 repo = row;
106
107 // Resolve owner username for URL construction
108 const [ownerRow] = await db
109 .select({ username: users.username })
110 .from(users)
111 .where(eq(users.id, repo.ownerId))
112 .limit(1);
113 ownerUsername = ownerRow?.username ?? "";
114 } catch (err) {
115 console.warn("[preview-builder] repo lookup failed:", err instanceof Error ? err.message : err);
116 return;
117 }
118
119 // Opt-in gate: skip if no build command configured
120 if (!repo.previewBuildCommand) return;
121
122 // ── look up the PR for branch name ──
123 let pr: { id: string; headBranch: string; number: number } | null = null;
124 try {
125 const [row] = await db
126 .select({ id: pullRequests.id, headBranch: pullRequests.headBranch, number: pullRequests.number })
127 .from(pullRequests)
128 .where(eq(pullRequests.id, prId))
129 .limit(1);
130 if (!row) return;
131 pr = row;
132 } catch (err) {
133 console.warn("[preview-builder] pr lookup failed:", err instanceof Error ? err.message : err);
134 return;
135 }
136
137 const buildCommand = repo.previewBuildCommand;
138 const outputDir = repo.previewOutputDir || "dist";
139 const shortSha = headSha.slice(0, 8);
140 const previewUrl = previewBuilderUrl(ownerUsername, repo.name, pr.headBranch);
141 const buildDir = `${PREVIEW_BUILD_DIR}/${slug(prId)}-${shortSha}`;
142
143 // ── upsert preview row to 'building' ──
144 let previewRowId: number | null = null;
145 try {
146 const [row] = await db
147 .insert(prPreviews)
148 .values({
149 repoId,
150 prId,
151 branchName: pr.headBranch,
152 headSha,
153 status: "building",
154 previewUrl,
155 buildCommand,
156 outputDir,
157 createdAt: new Date(),
158 updatedAt: new Date(),
159 })
160 .onConflictDoNothing()
161 .returning({ id: prPreviews.id });
162 // If there's already a row (same prId+headSha isn't unique, but let's handle it)
163 if (row) {
164 previewRowId = row.id;
165 } else {
166 // Find existing
167 const [existing] = await db
168 .select({ id: prPreviews.id })
169 .from(prPreviews)
170 .where(and(eq(prPreviews.prId, prId), eq(prPreviews.headSha, headSha)))
171 .limit(1);
172 previewRowId = existing?.id ?? null;
173 }
174 } catch (err) {
175 console.warn("[preview-builder] insert failed:", err instanceof Error ? err.message : err);
176 return;
177 }
178
179 // ── clone + build ──
180 const buildStart = Date.now();
181 let buildLog = "";
182 let buildOk = false;
183
184 try {
185 // Step 1: clone the bare repo and checkout the head branch
186 const cloneProc = Bun.spawn(
187 ["git", "clone", "--branch", pr.headBranch, "--depth", "1", repo.diskPath, buildDir],
188 { stderr: "pipe", stdout: "pipe" }
189 );
190
191 const cloneStdout = await new Response(cloneProc.stdout).text();
192 const cloneStderr = await new Response(cloneProc.stderr).text();
193 await cloneProc.exited;
194
195 buildLog += `=== clone ===\n${cloneStdout}${cloneStderr}\n`;
196
197 if (cloneProc.exitCode !== 0) {
198 throw new Error(`git clone failed (exit ${cloneProc.exitCode}): ${cloneStderr.slice(0, 500)}`);
199 }
200
201 // Step 2: run the build command with timeout
5e0d7baccantynz-alt202 // `buildCommand` is repo.previewBuildCommand — a value any repo owner sets
203 // from the repo settings form. It was executed with the FULL process.env,
204 // and stdout/stderr is captured into buildLog and rendered back to that
205 // same user. So `previewBuildCommand = "env"` printed DATABASE_URL (the
206 // production Neon credentials), ANTHROPIC_API_KEY, GLUECRON_PAT,
207 // WORKFLOW_SECRETS_KEY, SERVER_TARGETS_KEY and the OAuth secrets straight
208 // into a page the attacker controls. Complete platform compromise from a
209 // settings field.
210 //
211 // Use the same curated allowlist the workflow runner already uses for
212 // user-supplied `run:` steps — identical threat model, so it should never
213 // have been a separate decision.
214 const { buildRunnerEnv } = await import("./workflow-runner");
1df50d5Claude215 const buildProc = Bun.spawn(
216 ["sh", "-c", buildCommand],
217 {
218 cwd: buildDir,
219 stderr: "pipe",
220 stdout: "pipe",
5e0d7baccantynz-alt221 env: buildRunnerEnv({ CI: "1" }),
1df50d5Claude222 }
223 );
224
225 // Apply 2-minute timeout
226 const timeoutHandle = setTimeout(() => {
227 try { buildProc.kill(); } catch {}
228 }, BUILD_TIMEOUT_MS);
229
230 const buildStdout = await new Response(buildProc.stdout).text();
231 const buildStderr = await new Response(buildProc.stderr).text();
232 await buildProc.exited;
233 clearTimeout(timeoutHandle);
234
235 buildLog += `\n=== build: ${buildCommand} ===\n${buildStdout}${buildStderr}\n`;
236
237 if (buildProc.exitCode !== 0) {
238 throw new Error(`build command failed (exit ${buildProc.exitCode})`);
239 }
240
241 buildOk = true;
242 } catch (err) {
243 buildLog += `\n=== error ===\n${err instanceof Error ? err.message : String(err)}\n`;
244 }
245
246 const durationMs = Date.now() - buildStart;
247
248 // ── update preview row ──
249 try {
250 if (previewRowId !== null) {
251 await db
252 .update(prPreviews)
253 .set({
254 status: buildOk ? "ready" : "failed",
255 buildLog: buildLog.slice(0, 50_000),
256 previewUrl: buildOk ? previewUrl : null,
257 buildDurationMs: durationMs,
258 updatedAt: new Date(),
259 })
260 .where(eq(prPreviews.id, previewRowId));
261 }
262 } catch (err) {
263 console.warn("[preview-builder] update failed:", err instanceof Error ? err.message : err);
264 }
265
266 // ── post a PR comment with the result ──
267 try {
268 // Use the system bot user (first admin user, or fall back to the PR author)
269 const [authorRow] = await db
270 .select({ authorId: pullRequests.authorId })
271 .from(pullRequests)
272 .where(eq(pullRequests.id, prId))
273 .limit(1);
274
275 if (authorRow) {
276 const buildTimeS = Math.round(durationMs / 1000);
277 const body = buildOk
278 ? `🚀 **Preview deployed**\nURL: ${previewUrl}\nBuilt from: \`${shortSha}\`\nBuild time: ${buildTimeS}s`
279 : `❌ **Preview build failed**\nCommit: \`${shortSha}\`\nBuild time: ${buildTimeS}s\n\nSee build log in the [Previews tab](/${ownerUsername}/${repo.name}/previews).`;
280
281 await db.insert(prComments).values({
282 pullRequestId: prId,
283 authorId: authorRow.authorId,
284 body,
285 isAiReview: false,
286 moderationStatus: "approved",
287 createdAt: new Date(),
288 updatedAt: new Date(),
289 });
290 }
291 } catch (err) {
292 console.warn("[preview-builder] comment failed:", err instanceof Error ? err.message : err);
293 }
294}
295
296/**
297 * Look up the most recent pr_previews row for a given PR.
298 * Returns null if none exists or the table doesn't exist yet.
299 */
300export async function getPreviewForPr(prId: string): Promise<{
301 id: number;
302 status: string;
303 previewUrl: string | null;
304 headSha: string;
305 buildDurationMs: number | null;
306} | null> {
307 if (!prId) return null;
308 try {
309 const [row] = await db
310 .select({
311 id: prPreviews.id,
312 status: prPreviews.status,
313 previewUrl: prPreviews.previewUrl,
314 headSha: prPreviews.headSha,
315 buildDurationMs: prPreviews.buildDurationMs,
316 })
317 .from(prPreviews)
318 .where(eq(prPreviews.prId, prId))
319 .orderBy(prPreviews.id)
320 .limit(1);
321 return row ?? null;
322 } catch {
323 return null;
324 }
325}