import { env } from "cloudflare:workers"; import { repoDb } from "../scoped-db"; import { randomId } from "../crypto"; import { clearWorkflowCompileError, setCommitStatus } from "../statuses"; import { CAPS } from "../limits"; import { publicUrl } from "../events/emit"; import { withStore } from "../git/store"; import { compileWorkflow } from "./compile"; import { resolveWorkflowActions } from "./actions/resolve"; import type { CompiledJob, CompileInput, EventContext } from "./compile"; import { loadVariables } from "./config-values"; import { isScratchRepo } from "../sites/config"; import { isJobTokenPush } from "./job-token"; import { allocateRunNumber, insertRun, reuseSuccessfulJobs, supersedePendingApprovals } from "./run-insert"; import { parseWorkflow, WORKFLOW_DIR } from "./spec"; import { workflowMatches } from "./triggers"; import type { Trigger } from "./triggers"; import { accountDisableState } from "../account-disable"; import type { RepoRow } from "~/lib/types"; const WORKFLOW_FILE = /\.ya?ml$/; const MAX_WORKFLOW_FILES = 30; const MAX_WORKFLOW_BYTES = 128 * 1024; const MAX_JOBS_PER_RUN = 50; const MAX_STEP_ROWS_PER_RUN = 800; type WorkflowFile = { path: string; source: string; tooLarge: boolean }; export async function readWorkflowFiles(sourceRepo: RepoRow, sha: string): Promise { return withStore(sourceRepo.artifacts_name, async (store) => { const commit = await store.commit(sha); if (!commit) return []; const directory = await store.entryAtPath(commit.treeSha, WORKFLOW_DIR); if (!directory || directory.type !== "tree") return []; const entries = (await store.listTree(directory.sha, WORKFLOW_DIR)) .filter((entry) => entry.type !== "tree" && WORKFLOW_FILE.test(entry.name)) .slice(0, MAX_WORKFLOW_FILES); const files = await Promise.all( entries.map(async (entry) => { const bytes = await store.blob(entry.sha); if (!bytes) return null; if (bytes.byteLength > MAX_WORKFLOW_BYTES) return { path: entry.path, source: "", tooLarge: true }; return { path: entry.path, source: new TextDecoder().decode(bytes), tooLarge: false }; }), ); return files.filter((file): file is WorkflowFile => file !== null); }); } function eventContext( repo: RepoRow, trigger: Trigger, runId: string, runNumber: number, workflowName: string, ): EventContext { const refName = trigger.ref.replace(/^refs\/(heads|tags)\//, ""); return { sha: trigger.sha, ref: trigger.ref, ref_name: refName, ref_type: trigger.ref.startsWith("refs/tags/") ? "tag" : "branch", repository: `${repo.owner_name}/${repo.name}`, repository_owner: repo.owner_name, event_name: trigger.event, actor: trigger.actorName, run_id: runId, run_number: runNumber, workflow: workflowName, server_url: env.PUBLIC_URL, head_ref: trigger.headRef ?? "", base_ref: trigger.baseRef ?? "", event: { ...trigger.payload, action: trigger.action ?? "", number: trigger.prNumber ?? 0, inputs: trigger.inputs, ...(trigger.cron ? { schedule: trigger.cron } : {}), }, }; } export function statusContext(workflowName: string, jobName: string): string { return `${workflowName} / ${jobName}`; } export function runUrl(repo: Pick, runNumber: number): string { return publicUrl(`/${repo.owner_name}/${repo.name}/actions/runs/${runNumber}`); } /** Discovers matching workflow files at the trigger commit and enqueues a durable run for each. */ export async function scheduleWorkflows( repo: RepoRow, trigger: Trigger, options: { forceWorkflowPath?: boolean; requireApproval?: boolean; rerunOf?: string; failedOnly?: boolean } = {}, ): Promise { if (repo.has_actions !== 1 || isScratchRepo(repo)) return []; if (await accountDisableState(repo.owner_id)) return []; if (trigger.event === "push" && (await isJobTokenPush(repo.id, trigger.sha))) return []; const discovered = await readWorkflowFiles( trigger.workflowSource?.repo ?? trigger.sourceRepo, trigger.workflowSource?.sha ?? trigger.sha, ); const vars = trigger.sourceRepo.id === repo.id && !trigger.untrusted ? await loadVariables(repo, null) : {}; const files = options.forceWorkflowPath ? discovered.filter((file) => file.path === trigger.workflowPath) : discovered; const runIds: string[] = []; for (const file of files) { if (await atRunCap(repo.id, options.requireApproval === true)) { console.warn("Skipping workflow scheduling: too many active runs", repo.id); break; } const parsed = file.tooLarge ? { ok: false as const, error: `workflow files must be smaller than ${MAX_WORKFLOW_BYTES / 1024} KB` } : parseWorkflow(file.source); if (!parsed.ok) { const reportsErrors = trigger.event === "push" || trigger.event === "pull_request" || (trigger.event === "workflow_dispatch" && trigger.workflowPath === file.path); if (!reportsErrors) continue; const runId = randomId(); const runNumber = await allocateRunNumber(repo.id); await insertRun({ repo, trigger, path: file.path, workflowName: file.path, runId, runNumber, jobs: [], error: parsed.error, approvalRequired: false, source: null, context: null, rerunOf: options.rerunOf ?? null, }); await setCommitStatus({ repoId: repo.id, sha: trigger.sha, context: file.path, state: "error", description: "Invalid workflow file", targetUrl: runUrl(repo, runNumber), workflowPath: file.path, workflowSource: file.source, source: "ci", }); continue; } if (!options.forceWorkflowPath && !workflowMatches(parsed.spec, trigger, file.path)) continue; const workflowName = parsed.spec.name ?? file.path.split("/").pop() ?? file.path; const runId = randomId(); const runNumber = await allocateRunNumber(repo.id); let jobs: CompiledJob[] = []; let error: string | null = null; const context: CompileInput = { event: eventContext(repo, trigger, runId, runNumber, workflowName), inputs: trigger.inputs, vars, }; try { context.actions = await resolveWorkflowActions({ spec: parsed.spec, repo, workspace: { repo: trigger.sourceRepo, sha: trigger.sha }, runId, }); jobs = compileWorkflow(parsed.spec, context); const stepRows = jobs.reduce((sum, job) => sum + job.steps.length, 0); if (jobs.length > MAX_JOBS_PER_RUN) throw new Error(`a run may contain at most ${MAX_JOBS_PER_RUN} jobs`); if (stepRows > MAX_STEP_ROWS_PER_RUN) throw new Error(`a run may contain at most ${MAX_STEP_ROWS_PER_RUN} steps in total`); } catch (compileError) { error = compileError instanceof Error ? compileError.message : "Failed to compile workflow"; } const approvalRequired = options.requireApproval === true; if (approvalRequired && trigger.prNumber !== null) await supersedePendingApprovals(repo.id, trigger.prNumber, file.path); const ids = await insertRun({ repo, trigger, path: file.path, workflowName, runId, runNumber, jobs, error, approvalRequired, source: file.source, context, rerunOf: options.rerunOf ?? null, }); if (error) { await setCommitStatus({ repoId: repo.id, sha: trigger.sha, context: workflowName, state: "error", description: `Workflow failed to compile: ${error}`.slice(0, 140), targetUrl: runUrl(repo, runNumber), workflowPath: file.path, workflowSource: file.source, source: "ci", }); continue; } await clearWorkflowCompileError(repo.id, trigger.sha, [workflowName, file.path], file.path); const reused = new Set( options.rerunOf && options.failedOnly ? await reuseSuccessfulJobs(options.rerunOf, ids) : [], ); await Promise.all( jobs .filter((job) => !reused.has(job.key)) .map((job) => setCommitStatus({ repoId: repo.id, sha: trigger.sha, context: statusContext(workflowName, job.name), state: "pending", description: approvalRequired ? "Awaiting maintainer approval" : "Queued", targetUrl: runUrl(repo, runNumber), workflowPath: file.path, workflowSource: file.source, source: "ci", }), ), ); if (!approvalRequired) await startRun(runId); runIds.push(runId); } return runIds; } export async function startRun(runId: string): Promise { await env.CI_WORKFLOW.create({ id: runId, params: { runId } }); } async function atRunCap(repoId: string, approval: boolean): Promise { const active = await repoDb(repoId).first<{ count: number }>( "SELECT COUNT(*) AS count FROM workflow_runs WHERE repo_id = ? AND status != 'completed' AND approval_required = ?", repoId, approval ? 1 : 0, ); return (active?.count ?? 0) >= (approval ? CAPS.pendingApprovalsPerRepo : CAPS.activeRunsPerRepo); }