import { env } from "cloudflare:workers"; import { first } from "../db"; import { repoDb } from "../scoped-db"; import { jobDb, runDb } from "./scope"; import { randomId } from "../crypto"; import { CAPS } from "../limits"; import { hasPermission, loadRepoById, repoPermission } from "../repos"; import { loadUserById, toViewer } from "../auth"; import { withStore } from "../git/store"; import { matchesFilters } from "~/lib/glob"; import { ENVIRONMENT_URL_OUTPUT } from "./compile"; import type { WorkflowJobRow, WorkflowRunRow } from "./jobs"; import type { Conclusion } from "~/lib/types"; export type EnvironmentRow = { id: string; repo_id: string; name: string; wait_timer_minutes: number; reviewers: string; branch_patterns: string; prevent_self_review: number; created_at: number; }; export type DeploymentRow = { id: string; repo_id: string; environment_id: string; run_id: string; job_id: string; ref: string; sha: string; creator_id: string | null; status: "waiting" | "queued" | "in_progress" | "success" | "failure" | "cancelled" | "rejected"; url: string | null; wait_until: number | null; reviewed_by: string | null; reviewed_at: number | null; created_at: number; updated_at: number; }; export type Gate = { state: "open" } | { state: "waiting"; until: number | null } | { state: "denied"; reason: string }; const MINUTE_MS = 60_000; const ENVIRONMENT_NAME = /^[A-Za-z0-9 ._-]{1,100}$/; /** Like GitHub, referencing an unknown environment creates it (bounded per repository). */ async function ensureEnvironment(repoId: string, name: string): Promise { const db = repoDb(repoId); const existing = await db.first( "SELECT * FROM environments WHERE repo_id = ? AND name = ?", repoId, name, ); if (existing) return existing; if (!ENVIRONMENT_NAME.test(name)) return null; const count = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM environments WHERE repo_id = ?", repoId, ); if ((count?.count ?? 0) >= CAPS.environmentsPerRepo) return null; await db.run( "INSERT OR IGNORE INTO environments (id, repo_id, name, created_at) VALUES (?, ?, ?, ?)", randomId(), repoId, name, Date.now(), ); return db.first("SELECT * FROM environments WHERE repo_id = ? AND name = ?", repoId, name); } function refName(ref: string): string { return ref.replace(/^refs\/(heads|tags)\//, ""); } /** * Who caused a deployment, for `prevent_self_review`. Scheduled and some chained runs have no actor; fall back to * the account whose verified email authored the head commit so the person who pushed the change cannot approve it. */ async function deploymentCreator(runRow: WorkflowRunRow): Promise { if (runRow.actor_id) return runRow.actor_id; const repo = await loadRepoById(runRow.repo_id); if (!repo) return null; const email = await withStore( repo.artifacts_name, async (store) => (await store.commit(runRow.sha))?.authorEmail, ).catch(() => undefined); if (!email) return null; const row = await first<{ account_id: string }>( "SELECT account_id FROM users WHERE email = ? AND email_verified_at IS NOT NULL", email, ); return row?.account_id ?? null; } /** Decides whether a job targeting an environment may start, recording its deployment on first evaluation. */ export async function gateJob(runRow: WorkflowRunRow, job: WorkflowJobRow): Promise { if (!job.environment) return { state: "open" }; const environment = await ensureEnvironment(runRow.repo_id, job.environment); if (!environment) return { state: "denied", reason: `environment '${job.environment}' is not available` }; const patterns: string[] = JSON.parse(environment.branch_patterns); if (patterns.length > 0 && (runRow.ref.startsWith("refs/pull/") || !matchesFilters(patterns, refName(runRow.ref)))) { return { state: "denied", reason: `${refName(runRow.ref)} is not allowed to deploy to ${environment.name}` }; } const reviewers: string[] = JSON.parse(environment.reviewers); const db = repoDb(runRow.repo_id); const now = Date.now(); const protectedEnvironment = reviewers.length > 0 || environment.wait_timer_minutes > 0; await db.run( `INSERT OR IGNORE INTO deployments (id, repo_id, environment_id, run_id, job_id, ref, sha, creator_id, status, wait_until, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, randomId(), runRow.repo_id, environment.id, runRow.id, job.id, runRow.ref, runRow.sha, reviewers.length > 0 && environment.prevent_self_review === 1 ? await deploymentCreator(runRow) : runRow.actor_id, protectedEnvironment ? "waiting" : "queued", environment.wait_timer_minutes > 0 ? now + environment.wait_timer_minutes * MINUTE_MS : null, now, now, ); const deployment = await db.first("SELECT * FROM deployments WHERE job_id = ?", job.id); if (!deployment) return { state: "denied", reason: "deployment could not be recorded" }; if (deployment.status === "rejected") return { state: "denied", reason: "deployment was rejected" }; if (deployment.status !== "waiting") return { state: "open" }; if (reviewers.length > 0 && !deployment.reviewed_by) return { state: "waiting", until: null }; if (deployment.wait_until && deployment.wait_until > now) return { state: "waiting", until: deployment.wait_until }; await db.run("UPDATE deployments SET status = 'queued', updated_at = ? WHERE id = ?", now, deployment.id); return { state: "open" }; } export async function markDeploymentStarted(jobId: string): Promise { const db = await jobDb(jobId); if (!db) return; await db.run( "UPDATE deployments SET status = 'in_progress', updated_at = ? WHERE job_id = ? AND status = 'queued'", Date.now(), jobId, ); } export async function settleDeployment(jobId: string, conclusion: Conclusion): Promise { const db = await jobDb(jobId); if (!db) return; const job = await db.first<{ outputs: string | null }>("SELECT outputs FROM workflow_jobs WHERE id = ?", jobId); const outputs: Record = job?.outputs ? JSON.parse(job.outputs) : {}; const url = outputs[ENVIRONMENT_URL_OUTPUT] ?? null; const status = conclusion === "success" ? "success" : conclusion === "failure" ? "failure" : "cancelled"; await db.run( `UPDATE deployments SET status = ?, url = COALESCE(?, url), updated_at = ? WHERE job_id = ? AND status IN ('waiting', 'queued', 'in_progress')`, status, url && /^https?:\/\//.test(url) ? url.slice(0, 500) : null, Date.now(), jobId, ); } /** Wakes the orchestrating Workflow so it re-evaluates gated jobs right away. */ async function wakeRun(runId: string): Promise { try { const instance = await env.CI_WORKFLOW.get(runId); await instance.sendEvent({ type: "job-done", payload: { reason: "deployment-review" } }); } catch (error) { console.error("Failed to wake workflow run", runId, error); } } export type PendingDeployment = { id: string; jobId: string; environment: string; waitUntil: number | null; needsReview: boolean; canReview: boolean; }; /** * Listed reviewers only count while they still hold write on the repository, so someone removed from the repo or * org cannot keep approving deployments. With `prevent_self_review`, an unknown creator fails closed. */ function reviewerAllowed( environment: EnvironmentRow, viewerId: string | null, creatorId: string | null, viewerCanWrite: boolean, ): boolean { if (!viewerId || !viewerCanWrite) return false; const reviewers: string[] = JSON.parse(environment.reviewers); if (!reviewers.includes(viewerId)) return false; if (environment.prevent_self_review !== 1) return true; return creatorId !== null && creatorId !== viewerId; } async function canWriteRepo(repoId: string, viewerId: string | null): Promise { if (!viewerId) return false; const [repo, user] = await Promise.all([loadRepoById(repoId), loadUserById(viewerId)]); if (!repo || !user) return false; return hasPermission(await repoPermission(repo, toViewer(user)), "write"); } export async function pendingDeployments(runId: string, viewerId: string | null): Promise { const db = await runDb(runId); if (!db) return []; const rows = await db.all( `SELECT d.*, json_object('id', e.id, 'repo_id', e.repo_id, 'name', e.name, 'wait_timer_minutes', e.wait_timer_minutes, 'reviewers', e.reviewers, 'branch_patterns', e.branch_patterns, 'prevent_self_review', e.prevent_self_review, 'created_at', e.created_at) AS environment_row FROM deployments d JOIN environments e ON e.id = d.environment_id WHERE d.run_id = ? AND d.status = 'waiting'`, runId, ); const canWrite = rows.length > 0 && rows[0] ? await canWriteRepo(rows[0].repo_id, viewerId) : false; return rows.map((row) => { const environment: EnvironmentRow = JSON.parse(row.environment_row); const needsReview = JSON.parse(environment.reviewers).length > 0 && !row.reviewed_by; return { id: row.id, jobId: row.job_id, environment: environment.name, waitUntil: row.wait_until, needsReview, canReview: needsReview && reviewerAllowed(environment, viewerId, row.creator_id, canWrite), }; }); } /** Approves or rejects a waiting deployment; only the environment's required reviewers may decide. */ export async function reviewDeployment( repoId: string, deploymentId: string, viewerId: string, approve: boolean, ): Promise { const db = repoDb(repoId); const deployment = await db.first( "SELECT * FROM deployments WHERE id = ? AND repo_id = ? AND status = 'waiting'", deploymentId, repoId, ); if (!deployment) return false; const environment = await db.first( "SELECT * FROM environments WHERE id = ?", deployment.environment_id, ); if (!environment) return false; if (!reviewerAllowed(environment, viewerId, deployment.creator_id, await canWriteRepo(repoId, viewerId))) return false; const now = Date.now(); await db.run( `UPDATE deployments SET status = ?, reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ? AND status = 'waiting'`, approve ? "waiting" : "rejected", viewerId, now, now, deployment.id, ); await wakeRun(deployment.run_id); return true; } /** Job outputs for display, without the internal environment URL entry. */ export function visibleOutputs(raw: string | null): Array<{ name: string; value: string }> { const outputs: Record = raw ? JSON.parse(raw) : {}; return Object.entries(outputs) .filter(([name]) => name !== ENVIRONMENT_URL_OUTPUT) .map(([name, value]) => ({ name, value })); }