import { repoDb } from "../scoped-db"; import { locate, registerLocator } from "../scoped-db/locators"; import { scopedId } from "../scoped-db/ids"; import { siteDb } from "./store"; import { HttpError } from "../errors"; import { loadRepoById } from "../repos"; import { ZERO_SHA } from "../git/store"; import { env } from "cloudflare:workers"; import { labelUrl, SITE_LIMITS, slotKey } from "./config"; import type { SlotPointer } from "./config"; import { isOrphanSite } from "./sync"; import { createSiteDeployment, loadSiteForRepo, removePreview } from "./deploy"; import { previewLabel, pullLabel } from "./naming"; import type { SiteDeployRequest } from "./deploy"; import type { SiteRow } from "./config"; const MERGE_QUEUE_PREFIX = "gitfor-merge-queue/"; async function tryDeploy(input: SiteDeployRequest): Promise { try { await createSiteDeployment(input); return true; } catch (error) { if (error instanceof HttpError) { console.warn("Site deploy skipped", input.site.id, error.message); return false; } throw error; } } /** Comments the pull request's `pr-N` preview link once; the pusher is the author so no bot account is needed. */ async function commentPreview(site: SiteRow, branch: string, authorId: string | null): Promise { if (!authorId || !site.repo_id || !labelUrl(site.label)) return; const db = repoDb(site.repo_id); const pulls = await db.all<{ number: number; issue_id: string }>( `SELECT i.number, i.id AS issue_id FROM pull_requests p JOIN issues i ON i.id = p.issue_id WHERE p.repo_id = ? AND p.head_repo_id = ? AND p.head_ref = ? AND i.state = 'open' LIMIT ?`, site.repo_id, site.repo_id, branch, SITE_LIMITS.previewsPerPush, ); for (const pull of pulls) { const alias = pullLabel(site.label, pull.number); const holder = await env.KV.get(slotKey(alias), "json"); // Never link a host another live site already serves; the alias would not be ours. if (holder && holder.siteId !== site.id && !(await isOrphanSite(holder.siteId))) continue; const now = Date.now(); const inserted = await db.run( "INSERT OR IGNORE INTO site_preview_comments (site_id, pr_number, created_at) VALUES (?, ?, ?)", site.id, pull.number, now, ); if (!inserted.meta.changes) continue; const url = labelUrl(alias); const commentId = scopedId(site.repo_id); await registerLocator("comment", commentId, site.repo_id); await db.run( "INSERT INTO comments (id, issue_id, author_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", commentId, pull.issue_id, authorId, `Preview deployment for #${pull.number} (\`${branch}\`): ${url}\n\nIt follows the latest push to this branch while the pull request is open.`, now, now, ); } } function canPublish(site: SiteRow | null, visibility: string | undefined): site is SiteRow { if (!site || site.disabled_reason) return false; // Scratch repositories are private but publish publicly by design; unclaimed upload sites have no repo. if (site.scratch === 1) return site.repo_id !== null && (site.expires_at ?? 0) > Date.now(); // Private repositories may only publish behind an access gate. return visibility === "public" || site.access !== "public"; } /** Merge-queue refs are throwaway speculative merges: they never publish a site or comment a preview (CI-P4). */ export function isMergeQueueBranch(branch: string): boolean { return branch.startsWith(MERGE_QUEUE_PREFIX); } /** * Who pushed `sha` to `ref`. A run's actor may only have rerun or dispatched someone else's commit, so preview * variables unlock only for the commit's own pusher (CI-P2); null when no push of it is on record. */ export async function shaPusher(repoId: string, ref: string, sha: string): Promise { const push = await repoDb(repoId).first<{ pusher_id: string | null }>( "SELECT pusher_id FROM push_events WHERE repo_id = ? AND ref = ? AND after_sha = ? ORDER BY created_at DESC LIMIT 1", repoId, ref, sha, ); return push?.pusher_id ?? null; } /** Push hook: the site branch deploys to production; any other branch of the same repo deploys a preview. */ export async function deploySitesForPush( repoId: string, branch: string | null, sha: string, pusherId: string | null, ): Promise { if (!branch || isMergeQueueBranch(branch)) return 0; if (sha === ZERO_SHA) { const deleted = await loadSiteForRepo(repoId); if (deleted && branch !== deleted.branch) await removePreview(deleted, previewLabel(deleted.label, branch)); return 0; } const [repo, site] = await Promise.all([loadRepoById(repoId), loadSiteForRepo(repoId)]); if (!canPublish(site, repo?.visibility) || site.source !== "branch") return 0; const base = { site, branch, ref: `refs/heads/${branch}`, sha, artifactId: null, creatorId: pusherId } as const; if (branch === site.branch) { return (await tryDeploy({ ...base, source: "branch", environment: "production", label: site.label })) ? 1 : 0; } if (site.previews !== 1) return 0; const label = previewLabel(site.label, branch); if (!(await tryDeploy({ ...base, source: "branch", environment: "preview", label }))) return 0; await commentPreview(site, branch, pusherId); return 1; } type RunRow = { repo_id: string; source_repo_id: string; untrusted: number; event: string; ref: string; sha: string; pr_number: number | null; actor_id: string | null; conclusion: string | null; }; const PULL_REF = /^refs\/pull\/(\d+)\/head$/; /** * The branch a run builds: its own branch for push-style runs; for a `pull_request` run, the head branch of that * open, same-repository pull request, and only while the run built the current head (a stale run must not move the * preview back). Merge-queue refs and fork heads never qualify. */ async function runBranch(runRow: RunRow): Promise { if (runRow.ref.startsWith("refs/heads/")) { const branch = runRow.ref.slice("refs/heads/".length); return isMergeQueueBranch(branch) ? null : branch; } const match = PULL_REF.exec(runRow.ref); if (runRow.event !== "pull_request" || !match || runRow.pr_number !== Number(match[1])) return null; const pull = await repoDb(runRow.repo_id).first<{ head_repo_id: string | null; head_ref: string; head_sha: string; state: string; }>( `SELECT p.head_repo_id, p.head_ref, p.head_sha, i.state FROM pull_requests p JOIN issues i ON i.id = p.issue_id WHERE p.repo_id = ? AND i.number = ?`, runRow.repo_id, runRow.pr_number, ); if (!pull || pull.state !== "open" || pull.head_repo_id !== runRow.repo_id) return null; if (pull.head_sha !== runRow.sha || isMergeQueueBranch(pull.head_ref)) return null; return pull.head_ref; } /** * CI hook: a successful trusted run's artifact deploys to production or to its branch preview. A same-repository * `pull_request` run deploys as the preview of the pull request's head branch, so it shares that host, its `pr-N` * alias and commit previews with branch runs and pushes; newest deployment wins. */ export async function deploySitesForRun(runId: string): Promise { const runRepo = await locate("workflow_run", runId); if (!runRepo) return false; const runRow = await repoDb(runRepo).first( "SELECT repo_id, source_repo_id, untrusted, event, ref, sha, pr_number, actor_id, conclusion FROM workflow_runs WHERE id = ?", runId, ); if (!runRow || runRow.conclusion !== "success") return false; // Fork code never deploys, even to an isolated preview origin (PAGES-1). if (runRow.source_repo_id !== runRow.repo_id || runRow.untrusted !== 0) return false; const branch = await runBranch(runRow); if (!branch) return false; const pullRun = !runRow.ref.startsWith("refs/heads/"); const [repo, site] = await Promise.all([loadRepoById(runRow.repo_id), loadSiteForRepo(runRow.repo_id)]); if (!canPublish(site, repo?.visibility) || site.source !== "workflow") return false; // A pull request run is always a preview, even when its head is the production branch. const production = !pullRun && branch === site.branch; if (!production && (site.previews !== 1 || branch === site.branch)) return false; const artifact = await repoDb(runRow.repo_id).first<{ id: string }>( "SELECT id FROM job_artifacts WHERE run_id = ? AND name = ? ORDER BY created_at DESC LIMIT 1", runId, site.artifact_name, ); if (!artifact) return false; const label = production ? site.label : previewLabel(site.label, branch); // A same-repo pull request usually runs twice per head (push and pull_request); deploy that commit once. const existing = await siteDb(site).first<{ id: string }>( "SELECT id FROM site_deployments WHERE site_id = ? AND label = ? AND sha = ? AND status IN ('queued', 'building', 'active') LIMIT 1", site.id, label, runRow.sha, ); if (existing) return false; const deployed = await tryDeploy({ site, environment: production ? "production" : "preview", label, branch, ref: `refs/heads/${branch}`, sha: runRow.sha, artifactId: artifact.id, source: "artifact", creatorId: runRow.actor_id, trustedId: await shaPusher(runRow.repo_id, `refs/heads/${branch}`, runRow.sha), }); if (deployed && !production) await commentPreview(site, branch, runRow.actor_id); return deployed; }