import { env } from "cloudflare:workers"; import { all, first, run } from "../db"; import { randomId } from "../crypto"; import { HttpError, badRequest } from "../errors"; import { loadRepoById } from "../repos"; import { manifestKey, PAGES_LIMITS, slotKey } from "./config"; import type { Manifest, PagesDeploymentRow, PagesEnvironment, PagesSiteRow, SitePointer } from "./config"; import type { CollectedFiles } from "./collect"; import type { RepoRow } from "~/lib/types"; const HOUR = 60 * 60 * 1000; const HTTP_TOO_MANY = 429; export type DeployRequest = { repo: RepoRow; site: PagesSiteRow; environment: PagesEnvironment; prNumber: number | null; ref: string; sha: string; artifactId: string | null; creatorId: string | null; }; export async function loadSite(repoId: string): Promise { return first("SELECT * FROM pages_sites WHERE repo_id = ?", repoId); } export async function loadDeployment(id: string): Promise { return first("SELECT * FROM pages_deployments WHERE id = ?", id); } /** Queues a deploy, bounded per repository per hour, and hands it to the deploy Workflow. */ export async function createDeployment(input: DeployRequest): Promise { if (input.repo.visibility !== "public") throw badRequest("Pages can only publish public repositories"); const recent = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM pages_deployments WHERE repo_id = ? AND created_at > ?", input.repo.id, Date.now() - HOUR, ); if ((recent?.count ?? 0) >= PAGES_LIMITS.deploysPerRepoPerHour) { throw new HttpError(HTTP_TOO_MANY, `At most ${PAGES_LIMITS.deploysPerRepoPerHour} Pages deploys per hour`); } const row: PagesDeploymentRow = { id: randomId(), repo_id: input.repo.id, environment: input.environment, pr_number: input.prNumber, source: input.artifactId ? "artifact" : "branch", ref: input.ref, sha: input.sha, artifact_id: input.artifactId, status: "queued", file_count: 0, total_bytes: 0, error: null, creator_id: input.creatorId, created_at: Date.now(), completed_at: null, }; await run( `INSERT INTO pages_deployments (id, repo_id, environment, pr_number, source, ref, sha, artifact_id, status, creator_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)`, row.id, row.repo_id, row.environment, row.pr_number, row.source, row.ref, row.sha, row.artifact_id, row.creator_id, row.created_at, ); await env.PAGES_WORKFLOW.create({ id: row.id, params: { deploymentId: row.id } }); return row; } export async function failDeployment(id: string, message: string): Promise { await run( "UPDATE pages_deployments SET status = 'failed', error = ?, completed_at = ? WHERE id = ? AND status IN ('queued', 'building')", message.slice(0, 500), Date.now(), id, ); } function slotFor(repo: RepoRow, deployment: PagesDeploymentRow): string { return slotKey(repo.owner_name, repo.name, deployment.environment === "preview" ? deployment.pr_number : null); } /** Points the deployment's slot at it and marks the previously active deployment in that slot superseded. */ export async function pointSlot(repo: RepoRow, site: PagesSiteRow, deployment: PagesDeploymentRow): Promise { const pointer: SitePointer = { deploymentId: deployment.id, spa: site.spa === 1 }; await env.KV.put(slotFor(repo, deployment), JSON.stringify(pointer)); const samePreview = deployment.environment === "preview" ? "AND pr_number = ?" : ""; await run( `UPDATE pages_deployments SET status = 'superseded' WHERE repo_id = ? AND environment = ? AND status = 'active' AND id != ? ${samePreview}`, ...(deployment.environment === "preview" ? [repo.id, deployment.environment, deployment.id, deployment.pr_number] : [repo.id, deployment.environment, deployment.id]), ); await run("UPDATE pages_deployments SET status = 'active' WHERE id = ?", deployment.id); if (deployment.environment === "production") { await run( "UPDATE pages_sites SET active_deployment_id = ?, updated_at = ? WHERE repo_id = ?", deployment.id, Date.now(), repo.id, ); } } /** Publishes collected files as the deployment's manifest and activates it unless a newer one already won. */ export async function activateDeployment(id: string, files: CollectedFiles): Promise { const deployment = await loadDeployment(id); if (!deployment) return "missing"; if (deployment.status !== "queued" && deployment.status !== "building") return "cancelled"; const [repo, site] = await Promise.all([loadRepoById(deployment.repo_id), loadSite(deployment.repo_id)]); if (!repo || !site) { await failDeployment(id, "Pages is no longer enabled for this repository"); return "disabled"; } // A deploy that finishes after the repository went private must not publish it again. if (repo.visibility !== "public") { await failDeployment(id, "Pages can only publish public repositories"); return "private"; } const paths = Object.keys(files); if (paths.length === 0) { await failDeployment(id, "Nothing to deploy: the source contains no files"); return "empty"; } const total = paths.reduce((sum, path) => sum + files[path][1], 0); const manifest: Manifest = { files }; await env.KV.put(manifestKey(id), JSON.stringify(manifest)); await run( "UPDATE pages_deployments SET file_count = ?, total_bytes = ?, completed_at = ? WHERE id = ?", paths.length, total, Date.now(), id, ); const newer = await first<{ id: string }>( `SELECT id FROM pages_deployments WHERE repo_id = ? AND environment = ? AND status = 'active' AND created_at > ? AND pr_number IS ?`, repo.id, deployment.environment, deployment.created_at, deployment.pr_number, ); if (newer) { await run("UPDATE pages_deployments SET status = 'superseded' WHERE id = ?", id); return "superseded"; } await pointSlot(repo, site, deployment); return "active"; } /** Re-points production at a previous successful deployment; manifests and objects are immutable. */ export async function rollbackTo(repo: RepoRow, site: PagesSiteRow, deploymentId: string): Promise { if (repo.visibility !== "public") throw badRequest("Pages can only publish public repositories"); const deployment = await loadDeployment(deploymentId); if (!deployment || deployment.repo_id !== repo.id || deployment.environment !== "production") { throw badRequest("Unknown production deployment"); } if (deployment.status !== "superseded" && deployment.status !== "active") { throw badRequest("Only previously successful deployments can be restored"); } if (!(await env.KV.get(manifestKey(deployment.id)))) throw badRequest("That deployment is no longer retained"); await pointSlot(repo, site, deployment); } /** Removes every served slot for a repository: production and all preview pointers. */ export async function unpublish(repo: RepoRow): Promise { const previews = await all<{ pr_number: number }>( "SELECT DISTINCT pr_number FROM pages_deployments WHERE repo_id = ? AND environment = 'preview' AND status = 'active' LIMIT 200", repo.id, ); await env.KV.delete(slotKey(repo.owner_name, repo.name, null)); for (const preview of previews) await env.KV.delete(slotKey(repo.owner_name, repo.name, preview.pr_number)); } /** Drops a pull request's preview once it is closed. */ export async function removePreview(repo: RepoRow, prNumber: number): Promise { await env.KV.delete(slotKey(repo.owner_name, repo.name, prNumber)); await run( "UPDATE pages_deployments SET status = 'superseded' WHERE repo_id = ? AND environment = 'preview' AND pr_number = ? AND status = 'active'", repo.id, prNumber, ); }