import { WorkflowEntrypoint } from "cloudflare:workers"; import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers"; import { first, run } from "../db"; import { loadRepoById } from "../repos"; import { PAGES_LIMITS } from "./config"; import { activateDeployment, failDeployment, loadDeployment, loadSite } from "./deploy"; import { DeployError, extractArtifact, planGitFiles, totalBytes, uploadGitBatch } from "./collect"; import type { CollectedFiles, PlannedFile } from "./collect"; export type PagesDeployParams = { deploymentId: string }; type Plan = | { kind: "branch"; artifactsName: string; sha: string; folder: string } | { kind: "artifact"; r2Key: string; folder: string } | { kind: "skip"; error: string | null }; type Outcome = { ok: true; value: T } | { ok: false; error: string }; async function attempt(work: () => Promise): Promise> { try { return { ok: true, value: await work() }; } catch (error) { if (error instanceof DeployError) return { ok: false, error: error.message }; throw error; } } async function start(deploymentId: string): Promise { const deployment = await loadDeployment(deploymentId); if (!deployment || deployment.status !== "queued") return { kind: "skip", error: null }; const [repo, site] = await Promise.all([loadRepoById(deployment.repo_id), loadSite(deployment.repo_id)]); if (!repo || !site) return { kind: "skip", error: "Pages is not enabled for this repository" }; await run("UPDATE pages_deployments SET status = 'building' WHERE id = ?", deploymentId); if (!deployment.artifact_id) { return { kind: "branch", artifactsName: repo.artifacts_name, sha: deployment.sha, folder: site.folder }; } const artifact = await first<{ r2_key: string }>( "SELECT a.r2_key FROM job_artifacts a JOIN workflow_runs r ON r.id = a.run_id WHERE a.id = ? AND r.repo_id = ?", deployment.artifact_id, repo.id, ); if (!artifact) return { kind: "skip", error: "Workflow artifact not found" }; return { kind: "artifact", r2Key: artifact.r2_key, folder: site.folder }; } /** Durable deploy pipeline: collect files into content-addressed R2 objects, publish a manifest, flip the KV pointer. */ export class PagesDeployWorkflow extends WorkflowEntrypoint { override async run(event: Readonly>, step: WorkflowStep): Promise { const { deploymentId } = event.payload; const plan = await step.do("start", () => start(deploymentId)); if (plan.kind === "skip") { if (plan.error) await step.do("fail", () => failDeployment(deploymentId, plan.error ?? "Skipped")); return; } let files: CollectedFiles = {}; if (plan.kind === "artifact") { const extracted = await step.do("extract", () => attempt(() => extractArtifact(plan.r2Key, plan.folder))); if (!extracted.ok) { await step.do("fail", () => failDeployment(deploymentId, extracted.error)); return; } files = extracted.value; } else { const planned = await step.do("plan", () => attempt(() => planGitFiles(plan.artifactsName, plan.sha, plan.folder)), ); if (!planned.ok) { await step.do("fail", () => failDeployment(deploymentId, planned.error)); return; } const list: PlannedFile[] = planned.value; for (let index = 0; index < list.length; index += PAGES_LIMITS.uploadBatch) { const batch = list.slice(index, index + PAGES_LIMITS.uploadBatch); const uploaded = await step.do(`upload-${index}`, () => attempt(() => uploadGitBatch(plan.artifactsName, batch)), ); if (!uploaded.ok) { await step.do("fail", () => failDeployment(deploymentId, uploaded.error)); return; } files = { ...files, ...uploaded.value }; if (totalBytes(files) > PAGES_LIMITS.maxTotalBytes) { await step.do("fail", () => failDeployment(deploymentId, "Site exceeds the total size limit")); return; } } } const collected = files; await step.do("activate", () => activateDeployment(deploymentId, collected)); } }