import { first, run } from "../db"; import { repoDb } from "../scoped-db"; import { sha256Hex } from "../crypto"; import { enforceRateLimit } from "../limits"; import { HttpError } from "../errors"; import { loadRepoById } from "../repos"; import { isScratchRepo } from "../sites/config"; import { branchSha, listRefs } from "../git/refs"; import type { ExprValue } from "./expressions"; import { isJobTokenPush } from "./job-token"; import { scheduleWorkflows } from "./scheduler"; import type { Trigger, TriggerEvent } from "./triggers"; import { hasTriggerFor } from "./workflow-index"; import type { RepoRow } from "~/lib/types"; const MAX_WORKFLOW_RUN_DEPTH = 3; const ZERO_SHA = "0000000000000000000000000000000000000000"; export function toExprValue(value: unknown): ExprValue { if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return value; } if (Array.isArray(value)) return value.map(toExprValue); if (typeof value === "object") { return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toExprValue(item)])); } return null; } function record(value: ExprValue | undefined): Record { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } function text(value: ExprValue | undefined): string { return typeof value === "string" ? value : typeof value === "number" ? String(value) : ""; } type Mapped = { event: TriggerEvent; action: string | null; title: string; ref?: string; sha?: string; depth?: number; workflowRunName?: string; /** Actor inherited from the parent run when the payload carries no `sender` (`workflow_run`). */ actorId?: string | null; }; async function mapEvent(repoId: string, event: string, payload: Record): Promise { const action = text(payload.action) || null; switch (event) { case "issues": case "issue_comment": { const issue = record(payload.issue ?? payload.pull_request); return { event, action, title: `${event.replace("_", " ")} ${action ?? ""}: ${text(issue.title)}`.trim() }; } case "pull_request_review": return { event, action, title: `Review ${action ?? ""} on #${text(record(payload.pull_request).number)}` }; case "release": if (action === "published") return null; return { event, action, title: `Release ${action ?? ""}: ${text(record(payload.release).name)}` }; case "workflow_run": { const parent = record(payload.workflow_run); const row = await repoDb(repoId).first<{ trigger_depth: number; workflow_name: string; workflow_path: string; actor_id: string | null; }>( "SELECT trigger_depth, workflow_name, workflow_path, actor_id FROM workflow_runs WHERE id = ? AND repo_id = ?", text(parent.id), repoId, ); // Fork runs may trigger follow-ups (the usual untrusted-build, trusted-publish split): the consumer is always // compiled from the default branch, and the parent must be a workflow that also exists there. if (!row) return null; const defined = await repoDb(repoId).first<{ found: number }>( "SELECT 1 AS found FROM workflow_triggers WHERE repo_id = ? AND workflow_path = ? AND workflow_name = ?", repoId, row.workflow_path, row.workflow_name, ); if (!defined) return null; const depth = row.trigger_depth + 1; if (depth > MAX_WORKFLOW_RUN_DEPTH) return null; return { event, action, title: `${row.workflow_name} ${text(parent.conclusion)}`, ref: `refs/heads/${text(parent.head_branch)}`, depth, workflowRunName: row.workflow_name, actorId: row.actor_id, }; } case "push": { const ref = text(payload.ref); const kind = ref.startsWith("refs/tags/") ? "tag" : "branch"; const name = ref.replace(/^refs\/(heads|tags)\//, ""); if (payload.created === true) { const sha = text(payload.after); if (await isJobTokenPush(repoId, sha)) return null; return { event: "create", action: null, title: `Created ${kind} ${name}`, ref, sha }; } if (payload.deleted === true) return { event: "delete", action: null, title: `Deleted ${kind} ${name}` }; return null; } default: return null; } } /** * Starts workflows for repository events delivered through the events Queue. Only repositories whose indexed * default-branch workflows listen for the event pay for a git read, and the per-repo event rate is limited. */ export async function triggerWorkflowsForEvent( repoId: string, event: string, rawPayload: Record, ): Promise { const payload = record(toExprValue(rawPayload)); const mapped = await mapEvent(repoId, event, payload); if (!mapped || !(await hasTriggerFor(repoId, mapped.event))) return; try { await enforceRateLimit("WRITE_LIMITER", `ci-event:${repoId}`); } catch (error) { if (error instanceof HttpError) { console.warn("Dropping workflow event: rate limited", repoId, event); return; } throw error; } const receipt = await sha256Hex(`${repoId}\n${event}\n${JSON.stringify(rawPayload)}`); const fresh = await run( "INSERT OR IGNORE INTO workflow_event_receipts (id, created_at) VALUES (?, ?)", receipt, Date.now(), ); if (fresh.meta.changes !== 1) return; const repo = await loadRepoById(repoId); if (!repo || repo.has_actions !== 1 || repo.is_archived === 1 || repo.owner_disabled_at || isScratchRepo(repo)) return; const sha = mapped.sha && mapped.sha !== ZERO_SHA ? mapped.sha : branchSha(await listRefs(repo), repo.default_branch); if (!sha) return; const senderRecord = record(payload.sender); const sender = text(senderRecord.login); // workflow_run payloads have no sender; the consumer run inherits the parent's actor so environment // `prevent_self_review` can still identify who caused the deployment (CI-A7). const actor = (await actorId(text(senderRecord.id), sender)) ?? mapped.actorId ?? null; await scheduleWorkflows(repo, buildTrigger(repo, mapped, sha, sender, payload, actor)); } /** * Resolved by account id: logins can be renamed and re-registered while the event waits in the queue. The login * is only a fallback for payloads that carry no id. */ async function actorId(id: string, login: string): Promise { if (id) return (await first<{ id: string }>("SELECT id FROM accounts WHERE id = ?", id))?.id ?? null; if (!login) return null; return (await first<{ id: string }>("SELECT id FROM accounts WHERE name = ? COLLATE NOCASE", login))?.id ?? null; } function buildTrigger( repo: RepoRow, mapped: Mapped, sha: string, sender: string, payload: Record, actor: string | null, ): Trigger { const number = record(payload.issue ?? payload.pull_request).number; return { event: mapped.event, action: mapped.action, ref: mapped.event === "create" && mapped.ref ? mapped.ref : `refs/heads/${repo.default_branch}`, sha, actorId: actor, actorName: sender || "gitfor", title: mapped.title.slice(0, 200), prNumber: typeof number === "number" && mapped.event === "pull_request_review" ? number : null, headRef: mapped.event === "workflow_run" ? (mapped.ref ?? null) : null, baseRef: null, changedPaths: null, inputs: {}, workflowPath: null, sourceRepo: repo, payload, depth: mapped.depth, workflowRunName: mapped.workflowRunName, }; }